Compare commits

...

82 Commits
v1.1.3 ... main

Author SHA1 Message Date
platane
83033510f0 add test case for color optiion 2025-02-21 08:37:20 +07:00
release bot
a69d1dbca7 📦 3.3.0 2025-02-20 17:07:41 +00:00
platane
f2057e5efe disable cloudflare logging 2025-02-20 23:52:24 +07:00
platane
7fbc58b61d use cloudflare endpoint 2025-02-20 23:51:16 +07:00
platane
1f7630d984 👷 cloudflare deploy ci 2025-02-20 23:48:16 +07:00
platane
852f0ae376 deploy github user contribution endpoint to cloudflare 2025-02-20 23:43:25 +07:00
platane
10c4c3c7bd 🔧 fix manual run 2025-02-20 20:07:36 +07:00
platane
ace186c41f ⬆️ update prettier 2025-02-20 19:58:59 +07:00
platane
79c252356c ⬆️ update typescript 2025-02-20 19:57:25 +07:00
platane
3c171061b3 ⬆️ update tooling dependencies 2025-02-20 19:50:24 +07:00
platane
4783e68ce7 ⬆️ update tooling dependencies 2025-02-20 19:41:00 +07:00
Platane
85da3901f5 use bun as package manager and runner for the docke rcontainer, plus some tweak on the github action 2025-02-20 19:34:18 +07:00
platane
74bc4f0651 ⬆️ update canvas 2025-02-20 18:35:26 +07:00
platane
e55fe1f13c ⬆️ bump dependencies 2024-07-06 11:48:21 +02:00
platane
876448a004 🔨 fix cors issue (2) 2024-07-06 11:47:56 +02:00
platane
d35dc83cf2 🔨 fix cors issue 2024-07-06 11:38:34 +02:00
platane
bb7d69dde8 vercel cache 2024-02-20 16:33:48 +01:00
platane
14a003db51 cache the request from vercel for 6h 2024-02-20 16:28:37 +01:00
platane
2479713155 🚑 2024-02-20 16:23:46 +01:00
platane
debec31440 ⬆️ bump dependencies 2024-02-20 16:15:42 +01:00
platane
5332254423 👷 add manual run 2023-10-19 19:23:20 +02:00
platane
a9052b7ca2 📓 2023-10-17 17:45:17 +02:00
release bot
8b7b3e6ace 📦 3.2.0 2023-10-17 15:44:23 +00:00
platane
92f4de3970 use process.env. instead of @action/core in test and local 2023-10-17 17:30:48 +02:00
Awayume
c9644d3dfa use input instead of env to receive github token
Co-authored-by: Platane <me@platane.me>
2023-10-17 17:26:17 +02:00
platane
01fa6d7aac 🚑 vix vercel endpoint 2023-10-06 10:38:48 +02:00
release bot
b58af55b7d 📦 3.1.0 2023-09-23 18:22:23 +00:00
platane
4e5805f8af ⬆️ use node 20 2023-09-23 20:18:51 +02:00
platane
743771147d ⬆️ 2023-09-23 20:07:23 +02:00
Platane
8eddcbdbea 📓 2023-09-13 20:59:51 +02:00
Alfi Maulana
6f0ace6560 docs: fix indentation of GITHUB_TOKEN env in the README's usage section 2023-07-20 14:02:52 +02:00
platane
835fdd6b84 🚑 fix vercel function 2023-07-17 23:14:15 +02:00
platane
e6034f3972 📓 update readme 2023-07-17 23:04:14 +02:00
release bot
aebc3a9285 📦 3.0.0 2023-07-17 20:57:38 +00:00
platane
1574f65738 📓 update readme 2023-07-17 22:55:37 +02:00
platane
ebeb59fced read contribution calendar from github api 2023-07-17 22:55:37 +02:00
release bot
4489504b7a 📦 2.3.0 2023-07-17 20:37:27 +00:00
platane
027f89563f ⬆️ bump dependencies 2023-07-17 22:34:45 +02:00
platane
7233ec9e15 update contribution parser 2023-07-17 22:20:09 +02:00
platane
54dbbbf73d ♻️ run scripts with npm run vs yarn 2023-07-17 22:13:00 +02:00
Tanmoy
3eed9ce6d6 docs: remove unnecessary whitespace
there is an inconsistency in the whitespace surrounding the URL within the `srcset` attribute, hence we always get the snake in light mode
2023-07-04 01:18:04 +02:00
release bot
3acebc09eb 📦 2.2.1 2023-02-26 09:32:32 +00:00
platane
82417bf9f5 ⬆️ bump ncc 2023-02-26 10:30:34 +01:00
platane
7b6d52d221 ⬆️ bump tooling dependencies 2023-02-26 10:30:34 +01:00
platane
fd133c88c7 remove dark theme media query on default option 2023-02-26 10:21:56 +01:00
platane
229c9a9cd6 ⬆️ bump action dependencies 2023-02-26 10:15:41 +01:00
platane
3803e1ccfa 📓 use picture element to detect dark-mode in readme 2023-02-26 10:15:17 +01:00
platane
8ca289e908 🚑 fix readme lint 2023-02-26 10:00:59 +01:00
Platane
fd7cc1f05a docs(readme): syntax-highlight the darkmode snippet as html 2023-01-18 05:01:25 +01:00
Feng Kaiyu
632fcf6cb7 docs(readme): update the description of dark mode. 2023-01-18 05:01:25 +01:00
platane
e2eb91cf8f allows to select palette in demo 2023-01-06 09:19:20 +01:00
platane
38e2ed4f23 📝 add badges 2023-01-06 08:56:13 +01:00
release bot
b7a9c1e353 📦 2.2.0 2023-01-06 07:36:57 +00:00
platane
a0e08722d9 🚑 adapt the parser to the new github page markup 2023-01-06 08:25:04 +01:00
release bot
29c7ee48ec 📦 2.1.0 2022-11-03 09:06:43 +00:00
platane
21655d1bda 👷 update release script 2022-11-03 09:59:26 +01:00
platane
b895ed2e0f ⬆️ bump dev dependencies 2022-11-03 09:41:53 +01:00
platane
96773d2b2e ⬆️ bump canvas 2022-11-03 09:25:25 +01:00
platane
79ae29668c 🔨 add script to run the built action locally 2022-11-03 09:23:35 +01:00
platane
62f6ff3091 👷 add test for svg only action 2022-11-03 09:20:48 +01:00
platane
4a03759871 ⬆️ bump jest + use sucrase/jest 2022-11-03 08:50:04 +01:00
platane
463b90d43c 🩹 temporary disable unreliable test 2022-11-03 08:45:33 +01:00
platane
b40f17a02e ♻️ remove csso dependency
do a custom css optimization instead
2022-11-03 08:45:33 +01:00
platane
f83b9ab0c3 📓update readme 2022-04-22 08:36:50 +02:00
Platane
fb80d60b23 📓 update version 2022-04-21 20:21:07 +02:00
release bot
d078b2d231 📦 2.0.0 2022-04-21 18:20:09 +00:00
platane
a81c1bcc97 ♻️ drop cheerio 2022-04-21 20:16:28 +02:00
platane
40b26d0110 ♻️ refactor getGithubUserContribution 2022-04-21 20:16:28 +02:00
platane
d6e930af5b 📓 2022-04-21 20:10:36 +02:00
platane
98feaa6035 🚀 Allow to pass option as Json without ? 2022-04-21 19:04:21 +02:00
mdgw
8f1481341a fix svg-only version in readme to fix warning (#27) 2022-04-20 08:51:06 +02:00
release bot
2e275adbb6 📦 2.0.0-rc.3 2022-04-12 21:06:46 +00:00
platane
66fef03781 👷 2022-04-12 22:04:20 +02:00
platane
5841a21a09 👷 remove benchmark test 2022-04-12 22:01:29 +02:00
platane
cce5c4514d ♻️ refacto: rename options 2022-04-12 22:01:29 +02:00
platane
fb82d42d53 🚀 Allow to pass option as Json 2022-04-12 21:34:25 +02:00
release bot
e3ad8b2caf 📦 2.0.0-rc.2 2022-04-11 22:00:35 +00:00
platane
c21e390ca9 🐛 fix svg-only action.yaml 2022-04-11 23:57:57 +02:00
platane
7077112ba4 📓 2022-04-11 23:48:03 +02:00
release bot
e7aa7b7289 📦 2.0.0-rc.1 2022-04-11 21:23:16 +00:00
Platane
6b320a1ac4 change options, drop svg_out_path in favor of outputs list 2022-04-11 23:19:33 +02:00
platane
579bcf1afe 📓 2022-04-09 01:25:34 +02:00
85 changed files with 32145 additions and 58695 deletions

View File

@@ -7,34 +7,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-node@v2 - uses: oven-sh/setup-bun@v1
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: yarn type - run: bun install --frozen-lockfile
- run: yarn lint
- run: yarn test --ci
test-benchmark: - run: npm run type
runs-on: ubuntu-latest - run: npm run lint
- run: bun test
steps: env:
- uses: actions/checkout@v2 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: ( cd packages/gif-creator ; yarn benchmark )
test-action: test-action:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: update action.yml to use image from local Dockerfile - name: update action.yml to use image from local Dockerfile
run: | run: |
@@ -45,40 +32,87 @@ jobs:
uses: ./ uses: ./
with: with:
github_user_name: platane github_user_name: platane
gif_out_path: dist/github-contribution-grid-snake.gif outputs: |
svg_out_path: dist/github-contribution-grid-snake.svg dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists - name: ensure the generated file exists
run: | run: |
ls dist ls dist
test -f ${{ steps.generate-snake.outputs.gif_out_path }} test -f dist/github-contribution-grid-snake.svg
test -f ${{ steps.generate-snake.outputs.svg_out_path }} test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake.gif
- uses: crazy-max/ghaction-github-pages@v2.5.0 - uses: crazy-max/ghaction-github-pages@v4.1.0
with: with:
target_branch: output target_branch: output
build_dir: dist build_dir: dist
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy-ghpages: test-action-svg-only:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-node@v2 - uses: oven-sh/setup-bun@v1
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: yarn build:demo - run: bun install --frozen-lockfile
env:
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
- uses: crazy-max/ghaction-github-pages@v2.6.0 - name: build svg-only action
if: success() && github.ref == 'refs/heads/master' run: |
npm run build:action
rm -r svg-only/dist
mv packages/action/dist svg-only/dist
- name: generate-snake-game-from-github-contribution-grid
id: generate-snake
uses: ./svg-only
with: with:
target_branch: gh-pages github_user_name: platane
build_dir: packages/demo/dist outputs: |
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake-blue.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists
run: |
ls dist
test -f dist/github-contribution-grid-snake.svg
test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake-blue.svg
- uses: crazy-max/ghaction-github-pages@v4.1.0
with:
target_branch: output-svg-only
build_dir: dist
env: env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy-ghpages:
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install --frozen-lockfile
- run: npm run build:demo
env:
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://github-user-contribution.platane.workers.dev/github-user-contribution/
- uses: actions/upload-pages-artifact@v3
with:
path: packages/demo/dist
- uses: actions/deploy-pages@v4
if: success() && github.ref == 'refs/heads/main'
- run: bunx wrangler deploy
if: success() && github.ref == 'refs/heads/main'
working-directory: packages/github-user-contribution-service
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

47
.github/workflows/manual-run.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: manual run
on:
workflow_dispatch:
jobs:
generate:
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: Platane/snk/svg-only@v3
with:
github_user_name: ${{ github.repository_owner }}
outputs: |
dist/only-svg/github-contribution-grid-snake.svg
dist/only-svg/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/only-svg/github-contribution-grid-snake-blue.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- uses: Platane/snk@v3
with:
github_user_name: ${{ github.repository_owner }}
outputs: |
dist/docker/github-contribution-grid-snake.svg
dist/docker/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/docker/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists
run: |
ls dist
test -f dist/only-svg/github-contribution-grid-snake.svg
test -f dist/only-svg/github-contribution-grid-snake-dark.svg
test -f dist/only-svg/github-contribution-grid-snake-blue.svg
test -f dist/docker/github-contribution-grid-snake.svg
test -f dist/docker/github-contribution-grid-snake-dark.svg
test -f dist/docker/github-contribution-grid-snake.gif
- name: push github-contribution-grid-snake.svg to the output branch
uses: crazy-max/ghaction-github-pages@v4.1.0
with:
target_branch: manual-run-output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,7 +4,10 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version" description: |
New version for the release
If the version is in format <major>.<minor>.<patch> a new release is emitted.
Otherwise for other format ( for example <major>.<minor>.<patch>-beta.1 ), a prerelease is emitted.
default: "0.0.1" default: "0.0.1"
required: true required: true
type: string type: string
@@ -15,20 +18,22 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v1 - uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v1 - uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v1 - uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build and publish the docker image - name: build and publish the docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v4
id: docker-build id: docker-build
with: with:
push: true push: true
@@ -40,20 +45,18 @@ jobs:
run: | run: |
sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml
- uses: actions/setup-node@v2 - uses: oven-sh/setup-bun@v1
with:
cache: yarn - run: bun install --frozen-lockfile
node-version: 16
- name: build svg-only action - name: build svg-only action
run: | run: |
yarn install --frozen-lockfile npm run build:action
yarn build:action
rm -r svg-only/dist rm -r svg-only/dist
mv packages/action/dist svg-only/dist mv packages/action/dist svg-only/dist
- name: bump package version - name: bump package version
run: yarn version --no-git-tag-version --new-version ${{ github.event.inputs.version }} run: npm version --no-git-tag-version --new-version ${{ github.event.inputs.version }}
- name: push new build, tag version and push - name: push new build, tag version and push
id: push-tags id: push-tags
@@ -65,21 +68,19 @@ jobs:
git add package.json svg-only/dist action.yml git add package.json svg-only/dist action.yml
git commit -m "📦 $VERSION" git commit -m "📦 $VERSION"
git tag v$VERSION git tag v$VERSION
git push origin master --tags git push origin main --tags
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
git tag v$( echo $VERSION | cut -d. -f 1-1 ) git tag v$( echo $VERSION | cut -d. -f 1-1 )
git tag v$( echo $VERSION | cut -d. -f 1-2 ) git tag v$( echo $VERSION | cut -d. -f 1-2 )
git push origin --tags --force git push origin --tags --force
echo ::set-output name=prerelease::false echo "prerelease=false" >> $GITHUB_OUTPUT
else else
echo ::set-output name=prerelease::true echo "prerelease=true" >> $GITHUB_OUTPUT
fi fi
- uses: actions/create-release@v1 - uses: ncipollo/release-action@v1.15.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: v${{ github.event.inputs.version }} tag: v${{ github.event.inputs.version }}
body: ${{ github.event.inputs.description }} body: ${{ github.event.inputs.description }}
prerelease: ${{ steps.push-tags.outputs.prerelease }} prerelease: ${{ steps.push-tags.outputs.prerelease }}

6
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules node_modules
npm-debug.log* npm-debug.log*
yarn-error.log*
dist dist
!svg-only/dist !svg-only/dist
build build
.env
.wrangler
.dev.vars

2
.nvmrc
View File

@@ -1 +1 @@
16 20

View File

@@ -1,32 +1,27 @@
FROM node:16-slim as builder FROM oven/bun:1.2.2-slim as builder
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json bun.lock ./
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY packages packages COPY packages packages
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \ RUN bun install --no-cache
&& yarn install --frozen-lockfile \
&& rm -r "$YARN_CACHE_FOLDER"
RUN yarn build:action RUN bun run build:action
FROM node:16-slim FROM oven/bun:1.2.2-slim
WORKDIR /action-release WORKDIR /action-release
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \ RUN bun add canvas@3.1.0 gifsicle@5.3.0 --no-lockfile --no-cache
&& yarn add canvas@2.9.1 gifsicle@5.3.0 --no-lockfile \
&& rm -r "$YARN_CACHE_FOLDER"
COPY --from=builder /app/packages/action/dist/ /action-release/ COPY --from=builder /app/packages/action/dist/ /action-release/
CMD ["node", "/action-release/index.js"] CMD ["bun", "/action-release/index.js"]

View File

@@ -1,5 +1,6 @@
# snk # snk
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/platane/platane/main.yml?label=action&style=flat-square)](https://github.com/Platane/Platane/actions/workflows/main.yml)
[![GitHub release](https://img.shields.io/github/release/platane/snk.svg?style=flat-square)](https://github.com/platane/snk/releases/latest) [![GitHub release](https://img.shields.io/github/release/platane/snk.svg?style=flat-square)](https://github.com/platane/snk/releases/latest)
[![GitHub marketplace](https://img.shields.io/badge/marketplace-snake-blue?logo=github&style=flat-square)](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid) [![GitHub marketplace](https://img.shields.io/badge/marketplace-snake-blue?logo=github&style=flat-square)](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square) ![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square)
@@ -7,38 +8,69 @@
Generates a snake game from a github user contributions graph Generates a snake game from a github user contributions graph
![](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) <picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake-dark.svg"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
/>
<img
alt="github contribution grid snake animation"
src="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
/>
</picture>
Pull a github user's contribution graph. Pull a github user's contribution graph.
Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion. Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion.
Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image. Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image.
Available as github action. Automatically generate a new image at the end of the day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme) Available as github action. It can automatically generate a new image each day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
## Usage ## Usage
**github action** **github action**
```yaml ```yaml
- uses: Platane/snk@v1.1.0 - uses: Platane/snk@v3
with: with:
# github user name to read the contribution graph from (**required**) # github user name to read the contribution graph from (**required**)
# using action context var `github.repository_owner` or specified user # using action context var `github.repository_owner` or specified user
github_user_name: ${{ github.repository_owner }} github_user_name: ${{ github.repository_owner }}
# path of the generated gif file # list of files to generate.
# If left empty, the gif file will not be generated # one file per line. Each output can be customized with options as query string.
gif_out_path: dist/github-snake.gif #
# supported options:
# path of the generated svg file # - palette: A preset of color, one of [github, github-dark, github-light]
# If left empty, the svg file will not be generated # - color_snake: Color of the snake
svg_out_path: dist/github-snake.svg # - color_dots: Coma separated list of dots color.
# The first one is 0 contribution, then it goes from the low contribution to the highest.
# Exactly 5 colors are expected.
outputs: |
dist/github-snake.svg
dist/github-snake-dark.svg?palette=github-dark
dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
``` ```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29) [example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L26-L33)
If you are only interested in generating a svg, you can use this other faster action: `uses: Platane/snk/svg-only@v1.1.0` If you are only interested in generating a svg, consider using this faster action: `uses: Platane/snk/svg-only@v3`
**dark mode**
For **dark mode** support on github, use this [special syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to) in your readme.
```html
<picture>
<source media="(prefers-color-scheme: dark)" srcset="github-snake-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="github-snake.svg" />
<img alt="github-snake" src="github-snake.svg" />
</picture>
```
**interactive demo** **interactive demo**

View File

@@ -4,23 +4,31 @@ author: "platane"
runs: runs:
using: docker using: docker
image: docker://platane/snk@sha256:74d02183a9a4adb8e00d9f50e6eb5035a5b6ef02644d848363ef3301235ebd1d image: docker://platane/snk@sha256:96390294299275740e5963058c9784c60c5393b3b8b16082dcf41b240db791f9
inputs: inputs:
github_user_name: github_user_name:
description: "github user name" description: "github user name"
required: true required: true
gif_out_path: github_token:
description: "path of the generated gif file. If left empty, the gif file will not be generated." description: "github token used to fetch the contribution calendar. Default to the action token if empty."
required: false required: false
default: null default: ${{ github.token }}
svg_out_path: outputs:
description: "path of the generated svg file. If left empty, the svg file will not be generated."
required: false required: false
default: null description: |
list of files to generate.
one file per line. Each output can be customized with options as query string.
outputs: supported query string options:
gif_out_path:
description: "path of the generated gif" - palette: A preset of color, one of [github, github-dark, github-light]
svg_out_path: - color_snake: Color of the snake
description: "path of the generated svg" - color_dots: Coma separated list of dots color.
The first one is 0 contribution, then it goes from the low contribution to the highest.
Exactly 5 colors are expected.
example:
outputs: |
dark.svg?palette=github-dark&color_snake=blue
light.svg?color_snake=#7845ab
ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9

1489
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/?(*.)+(spec|test).ts"],
};

View File

@@ -1,26 +1,22 @@
{ {
"name": "snk", "name": "snk",
"description": "Generates a snake game from a github user contributions grid", "description": "Generates a snake game from a github user contributions grid",
"version": "1.1.3", "version": "3.3.0",
"private": true, "private": true,
"repository": "github:platane/snk", "repository": "github:platane/snk",
"devDependencies": { "devDependencies": {
"@types/jest": "27.4.1", "@types/bun": "1.2.2",
"@types/node": "16.11.7", "prettier": "3.5.1",
"jest": "27.5.1", "typescript": "5.7.3"
"prettier": "2.6.2",
"ts-jest": "27.1.4",
"typescript": "4.6.3"
}, },
"workspaces": [ "workspaces": [
"packages/**" "packages/*"
], ],
"scripts": { "scripts": {
"type": "tsc --noEmit", "type": "tsc --noEmit",
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**' '!svg-only/dist/**'", "lint": "prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**' '!svg-only/dist/**'",
"test": "jest --verbose --passWithNoTests --no-cache", "dev:demo": "( cd packages/demo ; npm run dev )",
"dev:demo": "( cd packages/demo ; yarn dev )", "build:demo": "( cd packages/demo ; npm run build )",
"build:demo": "( cd packages/demo ; yarn build )", "build:action": "( cd packages/action ; npm run build )"
"build:action": "( cd packages/action ; yarn build )"
} }
} }

View File

@@ -8,8 +8,6 @@ Contains the github action code.
Because the gif generation requires some native libs, we cannot use a node.js action. Because the gif generation requires some native libs, we cannot use a node.js action.
Use a docker action instead, the image is created from the [Dockerfile](./Dockerfile). Use a docker action instead, the image is created from the [Dockerfile](../../Dockerfile).
It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs ) It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs )
Notice that the [action.yml](../../action.yml) point to the latest version of the image. Which makes releasing sematic versioning of the action pointless. Which is probably fine for a wacky project like this one.

View File

@@ -1,2 +1,3 @@
* *
!.gitignore !.gitignore
!*.snap

View File

@@ -0,0 +1,120 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should parse /out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]} 1`] = `
{
"animationOptions": {
"frameDuration": 100,
"step": 1,
},
"drawOptions": {
"colorDotBorder": "#1b1f230a",
"colorDots": [
"#000",
"#111",
"#222",
"#333",
"#444",
],
"colorEmpty": "#000",
"colorSnake": "yellow",
"dark": undefined,
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "/out.svg",
"format": "svg",
}
`;
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444 1`] = `
{
"animationOptions": {
"frameDuration": 100,
"step": 1,
},
"drawOptions": {
"colorDotBorder": "#1b1f230a",
"colorDots": [
"#000",
"#111",
"#222",
"#333",
"#444",
],
"colorEmpty": "#000",
"colorSnake": "orange",
"dark": undefined,
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "/out.svg",
"format": "svg",
}
`;
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44 1`] = `
{
"animationOptions": {
"frameDuration": 100,
"step": 1,
},
"drawOptions": {
"colorDotBorder": "#1b1f230a",
"colorDots": [
"#000",
"#111",
"#222",
"#333",
"#444",
],
"colorEmpty": "#000",
"colorSnake": "orange",
"dark": {
"colorDotBorder": "#1b1f230a",
"colorDots": [
"#a00",
"#a11",
"#a22",
"#a33",
"#a44",
],
"colorEmpty": "#a00",
"colorSnake": "orange",
},
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "/out.svg",
"format": "svg",
}
`;
exports[`should parse path/to/out.gif 1`] = `
{
"animationOptions": {
"frameDuration": 100,
"step": 1,
},
"drawOptions": {
"colorDotBorder": "#1b1f230a",
"colorDots": [
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
],
"colorEmpty": "#ebedf0",
"colorSnake": "purple",
"dark": undefined,
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "path/to/out.gif",
"format": "gif",
}
`;

View File

@@ -1,19 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import { generateContributionSnake } from "../generateContributionSnake";
(async () => {
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
const buffer = await generateContributionSnake("platane", {
svg: true,
gif: true,
});
console.log("💾 writing to", outputSvg);
fs.writeFileSync(outputSvg, buffer.svg);
console.log("💾 writing to", outputGif);
fs.writeFileSync(outputGif, buffer.gif);
})();

View File

@@ -1,8 +1,8 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { it, expect } from "bun:test";
import { generateContributionSnake } from "../generateContributionSnake"; import { generateContributionSnake } from "../generateContributionSnake";
import { parseOutputsOption } from "../outputsOptions";
jest.setTimeout(2 * 60 * 1000);
const silent = (handler: () => void | Promise<void>) => async () => { const silent = (handler: () => void | Promise<void>) => async () => {
const originalConsoleLog = console.log; const originalConsoleLog = console.log;
@@ -17,22 +17,29 @@ const silent = (handler: () => void | Promise<void>) => async () => {
it( it(
"should generate contribution snake", "should generate contribution snake",
silent(async () => { silent(async () => {
const outputSvg = path.join(__dirname, "__snapshots__/out.svg"); const entries = [
const outputGif = path.join(__dirname, "__snapshots__/out.gif"); path.join(__dirname, "__snapshots__/out.svg"),
console.log = () => undefined; path.join(__dirname, "__snapshots__/out-dark.svg") +
const buffer = await generateContributionSnake("platane", { "?palette=github-dark&color_snake=orange",
svg: true,
gif: true, path.join(__dirname, "__snapshots__/out.gif") +
"?color_snake=orange&color_dots=#d4e0f0,#8dbdff,#64a1f4,#4b91f1,#3c7dd9",
];
const outputs = parseOutputsOption(entries);
const results = await generateContributionSnake("platane", outputs, {
githubToken: process.env.GITHUB_TOKEN!,
}); });
expect(buffer.svg).toBeDefined(); expect(results[0]).toBeDefined();
expect(buffer.gif).toBeDefined(); expect(results[1]).toBeDefined();
expect(results[2]).toBeDefined();
console.log("💾 writing to", outputSvg); fs.writeFileSync(outputs[0]!.filename, results[0]!);
fs.writeFileSync(outputSvg, buffer.svg); fs.writeFileSync(outputs[1]!.filename, results[1]!);
fs.writeFileSync(outputs[2]!.filename, results[2]!);
console.log("💾 writing to", outputGif); }),
fs.writeFileSync(outputGif, buffer.gif); { timeout: 2 * 60 * 1000 },
})
); );

View File

@@ -0,0 +1,61 @@
import { parseEntry } from "../outputsOptions";
import { it, expect } from "bun:test";
it("should parse options as json", () => {
expect(
parseEntry(`/out.svg {"color_snake":"yellow"}`)?.drawOptions,
).toHaveProperty("colorSnake", "yellow");
expect(
parseEntry(`/out.svg?{"color_snake":"yellow"}`)?.drawOptions,
).toHaveProperty("colorSnake", "yellow");
expect(
parseEntry(`/out.svg?{"color_dots":["#000","#111","#222","#333","#444"]}`)
?.drawOptions.colorDots,
).toEqual(["#000", "#111", "#222", "#333", "#444"]);
});
it("should parse options as searchparams", () => {
expect(parseEntry(`/out.svg?color_snake=yellow`)?.drawOptions).toHaveProperty(
"colorSnake",
"yellow",
);
expect(
parseEntry(`/out.svg?color_dots=#000,#111,#222,#333,#444`)?.drawOptions
.colorDots,
).toEqual(["#000", "#111", "#222", "#333", "#444"]);
});
it("should parse filename", () => {
expect(parseEntry(`/a/b/c.svg?{"color_snake":"yellow"}`)).toHaveProperty(
"filename",
"/a/b/c.svg",
);
expect(
parseEntry(`/a/b/out.svg?.gif.svg?{"color_snake":"yellow"}`),
).toHaveProperty("filename", "/a/b/out.svg?.gif.svg");
expect(
parseEntry(`/a/b/{[-1].svg?.gif.svg?{"color_snake":"yellow"}`),
).toHaveProperty("filename", "/a/b/{[-1].svg?.gif.svg");
});
[
// default
"path/to/out.gif",
// overwrite colors (search params)
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444",
// overwrite colors (json)
`/out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]}`,
// overwrite dark colors
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44",
].forEach((entry) =>
it(`should parse ${entry}`, () => {
expect(parseEntry(entry)).toMatchSnapshot();
}),
);

View File

@@ -3,51 +3,50 @@ import { userContributionToGrid } from "./userContributionToGrid";
import { getBestRoute } from "@snk/solver/getBestRoute"; import { getBestRoute } from "@snk/solver/getBestRoute";
import { snake4 } from "@snk/types/__fixtures__/snake"; import { snake4 } from "@snk/types/__fixtures__/snake";
import { getPathToPose } from "@snk/solver/getPathToPose"; import { getPathToPose } from "@snk/solver/getPathToPose";
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
import type { AnimationOptions } from "@snk/gif-creator";
export const generateContributionSnake = async ( export const generateContributionSnake = async (
userName: string, userName: string,
format: { svg?: boolean; gif?: boolean } outputs: ({
format: "svg" | "gif";
drawOptions: DrawOptions;
animationOptions: AnimationOptions;
} | null)[],
options: { githubToken: string },
) => { ) => {
console.log("🎣 fetching github user contribution"); console.log("🎣 fetching github user contribution");
const { cells, colorScheme } = await getGithubUserContribution(userName); const cells = await getGithubUserContribution(userName, options);
const grid = userContributionToGrid(cells, colorScheme); const grid = userContributionToGrid(cells);
const snake = snake4; const snake = snake4;
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: colorScheme as any,
colorEmpty: colorScheme[0],
colorSnake: "purple",
cells,
dark: {
colorEmpty: "#161b22",
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
},
};
const gifOptions = { frameDuration: 100, step: 1 };
console.log("📡 computing best route"); console.log("📡 computing best route");
const chain = getBestRoute(grid, snake)!; const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
const output: Record<string, Buffer | string> = {}; return Promise.all(
outputs.map(async (out, i) => {
if (format.gif) { if (!out) return;
console.log("📹 creating gif"); const { format, drawOptions, animationOptions } = out;
const { createGif } = await import("@snk/gif-creator"); switch (format) {
output.gif = await createGif(grid, chain, drawOptions, gifOptions); case "svg": {
} console.log(`🖌 creating svg (outputs[${i}])`);
const { createSvg } = await import("@snk/svg-creator");
if (format.svg) { return createSvg(grid, cells, chain, drawOptions, animationOptions);
console.log("🖌 creating svg"); }
const { createSvg } = await import("@snk/svg-creator"); case "gif": {
output.svg = createSvg(grid, chain, drawOptions, gifOptions); console.log(`📹 creating gif (outputs[${i}])`);
} const { createGif } = await import("@snk/gif-creator");
return await createGif(
return output; grid,
cells,
chain,
drawOptions,
animationOptions,
);
}
}
}),
);
}; };

View File

@@ -1,31 +1,35 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { generateContributionSnake } from "./generateContributionSnake"; import { parseOutputsOption } from "./outputsOptions";
(async () => { (async () => {
try { try {
const userName = core.getInput("github_user_name"); const userName = core.getInput("github_user_name");
const format = { const outputs = parseOutputsOption(
svg: core.getInput("svg_out_path"), core.getMultilineInput("outputs") ?? [
gif: core.getInput("gif_out_path"), core.getInput("gif_out_path"),
}; core.getInput("svg_out_path"),
],
const { svg, gif } = await generateContributionSnake(
userName,
format as any
); );
const githubToken =
process.env.GITHUB_TOKEN ?? core.getInput("github_token");
if (svg) { const { generateContributionSnake } = await import(
fs.mkdirSync(path.dirname(format.svg), { recursive: true }); "./generateContributionSnake"
fs.writeFileSync(format.svg, svg); );
core.setOutput("svg_out_path", format.svg); const results = await generateContributionSnake(userName, outputs, {
} githubToken,
if (gif) { });
fs.mkdirSync(path.dirname(format.gif), { recursive: true });
fs.writeFileSync(format.gif, gif); outputs.forEach((out, i) => {
core.setOutput("gif_out_path", format.gif); const result = results[i];
} if (out?.filename && result) {
console.log(`💾 writing to ${out?.filename}`);
fs.mkdirSync(path.dirname(out?.filename), { recursive: true });
fs.writeFileSync(out?.filename, result);
}
});
} catch (e: any) { } catch (e: any) {
core.setFailed(`Action failed with "${e.message}"`); core.setFailed(`Action failed with "${e.message}"`);
} }

View File

@@ -0,0 +1,86 @@
import type { AnimationOptions } from "@snk/gif-creator";
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
import { palettes } from "./palettes";
export const parseOutputsOption = (lines: string[]) => lines.map(parseEntry);
export const parseEntry = (entry: string) => {
const m = entry.trim().match(/^(.+\.(svg|gif))(\?(.*)|\s*({.*}))?$/);
if (!m) return null;
const [, filename, format, _, q1, q2] = m;
const query = q1 ?? q2;
let sp = new URLSearchParams(query || "");
try {
const o = JSON.parse(query);
if (Array.isArray(o.color_dots)) o.color_dots = o.color_dots.join(",");
if (Array.isArray(o.dark_color_dots))
o.dark_color_dots = o.dark_color_dots.join(",");
sp = new URLSearchParams(o);
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
}
const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
...palettes["default"],
dark: palettes["default"].dark && { ...palettes["default"].dark },
};
const animationOptions: AnimationOptions = { step: 1, frameDuration: 100 };
{
const palette = palettes[sp.get("palette")!];
if (palette) {
Object.assign(drawOptions, palette);
drawOptions.dark = palette.dark && { ...palette.dark };
}
}
{
const dark_palette = palettes[sp.get("dark_palette")!];
if (dark_palette) {
const clone = { ...dark_palette, dark: undefined };
drawOptions.dark = clone;
}
}
if (sp.has("color_snake")) drawOptions.colorSnake = sp.get("color_snake")!;
if (sp.has("color_dots")) {
const colors = sp.get("color_dots")!.split(/[,;]/);
drawOptions.colorDots = colors;
drawOptions.colorEmpty = colors[0];
drawOptions.dark = undefined;
}
if (sp.has("color_dot_border"))
drawOptions.colorDotBorder = sp.get("color_dot_border")!;
if (sp.has("dark_color_dots")) {
const colors = sp.get("dark_color_dots")!.split(/[,;]/);
drawOptions.dark = {
colorDotBorder: drawOptions.colorDotBorder,
colorSnake: drawOptions.colorSnake,
...drawOptions.dark,
colorDots: colors,
colorEmpty: colors[0],
};
}
if (sp.has("dark_color_dot_border") && drawOptions.dark)
drawOptions.dark.colorDotBorder = sp.get("color_dot_border")!;
if (sp.has("dark_color_snake") && drawOptions.dark)
drawOptions.dark.colorSnake = sp.get("color_snake")!;
return {
filename,
format: format as "svg" | "gif",
drawOptions,
animationOptions,
};
};

View File

@@ -2,7 +2,7 @@
"name": "@snk/action", "name": "@snk/action",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@actions/core": "1.6.0", "@actions/core": "1.11.1",
"@snk/gif-creator": "1.0.0", "@snk/gif-creator": "1.0.0",
"@snk/github-user-contribution": "1.0.0", "@snk/github-user-contribution": "1.0.0",
"@snk/solver": "1.0.0", "@snk/solver": "1.0.0",
@@ -10,11 +10,9 @@
"@snk/types": "1.0.0" "@snk/types": "1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vercel/ncc": "0.24.1", "@vercel/ncc": "0.38.3"
"ts-node": "10.7.0"
}, },
"scripts": { "scripts": {
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts", "build": "ncc build --external canvas --external gifsicle --out dist ./index.ts"
"dev": "ts-node __tests__/dev.ts"
} }
} }

View File

@@ -0,0 +1,27 @@
import { DrawOptions as DrawOptions } from "@snk/svg-creator";
export const basePalettes: Record<
string,
Pick<
DrawOptions,
"colorDotBorder" | "colorEmpty" | "colorSnake" | "colorDots" | "dark"
>
> = {
"github-light": {
colorDotBorder: "#1b1f230a",
colorDots: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
colorEmpty: "#ebedf0",
colorSnake: "purple",
},
"github-dark": {
colorDotBorder: "#1b1f230a",
colorEmpty: "#161b22",
colorDots: ["#161b22", "#01311f", "#034525", "#0f6d31", "#00c647"],
colorSnake: "purple",
},
};
// aliases
export const palettes = { ...basePalettes };
palettes["github"] = palettes["github-light"];
palettes["default"] = palettes["github"];

View File

@@ -2,17 +2,13 @@ import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid";
import type { Cell } from "@snk/github-user-contribution"; import type { Cell } from "@snk/github-user-contribution";
import type { Color } from "@snk/types/grid"; import type { Color } from "@snk/types/grid";
export const userContributionToGrid = ( export const userContributionToGrid = (cells: Cell[]) => {
cells: Cell[],
colorScheme: string[]
) => {
const width = Math.max(0, ...cells.map((c) => c.x)) + 1; const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
const height = Math.max(0, ...cells.map((c) => c.y)) + 1; const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
const grid = createEmptyGrid(width, height); const grid = createEmptyGrid(width, height);
for (const c of cells) { for (const c of cells) {
const k = colorScheme.indexOf(c.color); if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color);
if (k > 0) setColor(grid, c.x, c.y, k as Color);
else setColorEmpty(grid, c.x, c.y); else setColorEmpty(grid, c.x, c.y);
} }

View File

@@ -1,12 +1,13 @@
import { Color, Grid } from "@snk/types/grid"; import { Color, Grid } from "@snk/types/grid";
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld"; import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
import { Snake } from "@snk/types/snake"; import { Snake } from "@snk/types/snake";
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
export const drawOptions = { export const drawOptions: DrawOptions = {
sizeBorderRadius: 2, sizeDotBorderRadius: 2,
sizeCell: 16, sizeCell: 16,
sizeDot: 12, sizeDot: 12,
colorBorder: "#1b1f230a", colorDotBorder: "#1b1f230a",
colorDots: { colorDots: {
1: "#9be9a8", 1: "#9be9a8",
2: "#40c463", 2: "#40c463",
@@ -67,7 +68,7 @@ export const createCanvas = ({
const draw = (grid: Grid, snake: Snake, stack: Color[]) => { const draw = (grid: Grid, snake: Snake, stack: Color[]) => {
ctx.clearRect(0, 0, 9999, 9999); ctx.clearRect(0, 0, 9999, 9999);
drawWorld(ctx, grid, snake, stack, drawOptions); drawWorld(ctx, grid, null, snake, stack, drawOptions);
}; };
const drawLerp = ( const drawLerp = (
@@ -75,10 +76,10 @@ export const createCanvas = ({
snake0: Snake, snake0: Snake,
snake1: Snake, snake1: Snake,
stack: Color[], stack: Color[],
k: number k: number,
) => { ) => {
ctx.clearRect(0, 0, 9999, 9999); ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions); drawLerpWorld(ctx, grid, null, snake0, snake1, stack, k, drawOptions);
}; };
const highlightCell = (x: number, y: number, color = "orange") => { const highlightCell = (x: number, y: number, color = "orange") => {

View File

@@ -26,7 +26,7 @@ const tunnels = ones.map(({ x, y }) => ({
x, x,
y, y,
3 as Color, 3 as Color,
getSnakeLength(snake) getSnakeLength(snake),
), ),
})); }));

View File

@@ -8,7 +8,7 @@ const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
canvas.style.pointerEvents = "auto"; canvas.style.pointerEvents = "auto";
const target = createSnakeFromCells( const target = createSnakeFromCells(
snakeToCells(snake).map((p) => ({ ...p, x: p.x - 1 })) snakeToCells(snake).map((p) => ({ ...p, x: p.x - 1 })),
); );
let chain = [snake, ...getPathToPose(snake, target)!]; let chain = [snake, ...getPathToPose(snake, target)!];

View File

@@ -3,15 +3,18 @@ import { step } from "@snk/solver/step";
import { isStableAndBound, stepSpring } from "./springUtils"; import { isStableAndBound, stepSpring } from "./springUtils";
import type { Res } from "@snk/github-user-contribution"; import type { Res } from "@snk/github-user-contribution";
import type { Snake } from "@snk/types/snake"; import type { Snake } from "@snk/types/snake";
import type { Point } from "@snk/types/point";
import { import {
drawLerpWorld, drawLerpWorld,
getCanvasWorldSize, getCanvasWorldSize,
Options, Options as DrawOptions,
} from "@snk/draw/drawWorld"; } from "@snk/draw/drawWorld";
import { userContributionToGrid } from "@snk/action/userContributionToGrid"; import { userContributionToGrid } from "@snk/action/userContributionToGrid";
import { createSvg } from "@snk/svg-creator"; import { createSvg } from "@snk/svg-creator";
import { createRpcClient } from "./worker-utils"; import { createRpcClient } from "./worker-utils";
import type { API as WorkerAPI } from "./demo.interactive.worker"; import type { API as WorkerAPI } from "./demo.interactive.worker";
import { AnimationOptions } from "@snk/gif-creator";
import { basePalettes } from "@snk/action/palettes";
const createForm = ({ const createForm = ({
onSubmit, onSubmit,
@@ -116,12 +119,19 @@ const createGithubProfile = () => {
const createViewer = ({ const createViewer = ({
grid0, grid0,
chain, chain,
drawOptions, cells,
}: { }: {
grid0: Grid; grid0: Grid;
chain: Snake[]; chain: Snake[];
drawOptions: Options; cells: Point[];
}) => { }) => {
const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
...basePalettes["github-light"],
};
// //
// canvas // canvas
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@@ -159,7 +169,7 @@ const createViewer = ({
const k = spring.x % 1; const k = spring.x % 1;
ctx.clearRect(0, 0, 9999, 9999); ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions); drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k, drawOptions);
if (!stable) animationFrame = requestAnimationFrame(loop); if (!stable) animationFrame = requestAnimationFrame(loop);
}; };
@@ -167,12 +177,12 @@ const createViewer = ({
// //
// controls // controls
const input = document.createElement("input") as any; const input = document.createElement("input");
input.type = "range"; input.type = "range";
input.value = 0; input.value = "0";
input.step = 1; input.step = "1";
input.min = 0; input.min = "0";
input.max = chain.length; input.max = "" + chain.length;
input.style.width = "calc( 100% - 20px )"; input.style.width = "calc( 100% - 20px )";
input.addEventListener("input", () => { input.addEventListener("input", () => {
spring.target = +input.value; spring.target = +input.value;
@@ -186,12 +196,51 @@ const createViewer = ({
window.addEventListener("click", onClickBackground); window.addEventListener("click", onClickBackground);
document.body.append(input); document.body.append(input);
//
const schemaSelect = document.createElement("select");
schemaSelect.style.margin = "10px";
schemaSelect.style.alignSelf = "flex-start";
schemaSelect.value = "github-light";
schemaSelect.addEventListener("change", () => {
Object.assign(drawOptions, basePalettes[schemaSelect.value]);
svgString = createSvg(grid0, cells, chain, drawOptions, {
frameDuration: 100,
} as AnimationOptions);
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
svgLink.href = svgImageUri;
if (schemaSelect.value.includes("dark"))
document.body.parentElement?.classList.add("dark-mode");
else document.body.parentElement?.classList.remove("dark-mode");
loop();
});
for (const name of Object.keys(basePalettes)) {
const option = document.createElement("option");
option.value = name;
option.innerText = name;
schemaSelect.appendChild(option);
}
document.body.append(schemaSelect);
//
// dark mode
const style = document.createElement("style");
style.innerText = `
html { transition:background-color 180ms }
a { transition:color 180ms }
html.dark-mode{ background-color:#0d1117 }
html.dark-mode a{ color:rgb(201, 209, 217) }
`;
document.head.append(style);
// //
// svg // svg
const svgLink = document.createElement("a"); const svgLink = document.createElement("a");
const svgString = createSvg(grid0, chain, drawOptions, { let svgString = createSvg(grid0, cells, chain, drawOptions, {
frameDuration: 100, frameDuration: 100,
}); } as AnimationOptions);
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`; const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
svgLink.href = svgImageUri; svgLink.href = svgImageUri;
svgLink.innerText = "github-user-contribution.svg"; svgLink.innerText = "github-user-contribution.svg";
@@ -199,9 +248,12 @@ const createViewer = ({
svgLink.addEventListener("click", (e) => { svgLink.addEventListener("click", (e) => {
const w = window.open("")!; const w = window.open("")!;
w.document.write( w.document.write(
`<a href="${svgImageUri}" download="github-user-contribution.svg">` + (document.body.parentElement?.classList.contains("dark-mode")
? "<style>html{ background-color:#0d1117 }</style>"
: "") +
`<a href="${svgLink.href}" download="github-user-contribution.svg">` +
svgString + svgString +
"<a/>" "<a/>",
); );
e.preventDefault(); e.preventDefault();
}); });
@@ -225,36 +277,25 @@ const createViewer = ({
const onSubmit = async (userName: string) => { const onSubmit = async (userName: string) => {
const res = await fetch( const res = await fetch(
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName,
); );
const { cells, colorScheme } = (await res.json()) as Res; const cells = (await res.json()) as Res;
const drawOptions = { const grid = userContributionToGrid(cells);
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: colorScheme as any,
colorEmpty: colorScheme[0],
colorSnake: "purple",
cells,
};
const grid = userContributionToGrid(cells, colorScheme);
const chain = await getChain(grid); const chain = await getChain(grid);
dispose(); dispose();
createViewer({ grid0: grid, chain, drawOptions }); createViewer({ grid0: grid, chain, cells });
}; };
const worker = new Worker( const worker = new Worker(
new URL( new URL(
"./demo.interactive.worker.ts", "./demo.interactive.worker.ts",
// @ts-ignore // @ts-ignore
import.meta.url import.meta.url,
) ),
); );
const { getChain } = createRpcClient<WorkerAPI>(worker); const { getChain } = createRpcClient<WorkerAPI>(worker);

View File

@@ -4,12 +4,15 @@ import { createSvg } from "@snk/svg-creator";
import { grid, snake } from "./sample"; import { grid, snake } from "./sample";
import { drawOptions } from "./canvas"; import { drawOptions } from "./canvas";
import { getPathToPose } from "@snk/solver/getPathToPose"; import { getPathToPose } from "@snk/solver/getPathToPose";
import type { AnimationOptions } from "@snk/gif-creator";
const chain = getBestRoute(grid, snake); const chain = getBestRoute(grid, snake);
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
(async () => { (async () => {
const svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 }); const svg = await createSvg(grid, null, chain, drawOptions, {
frameDuration: 200,
} as AnimationOptions);
const container = document.createElement("div"); const container = document.createElement("div");
container.innerHTML = svg; container.innerHTML = svg;

View File

@@ -25,7 +25,7 @@ const onChange = () => {
const url = new URL( const url = new URL(
config.demo + ".html?" + search, config.demo + ".html?" + search,
window.location.href window.location.href,
).toString(); ).toString();
window.location.href = url; window.location.href = url;

View File

@@ -10,14 +10,15 @@
"@snk/types": "1.0.0" "@snk/types": "1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dat.gui": "0.7.7", "@types/dat.gui": "0.7.13",
"dat.gui": "0.7.9", "dat.gui": "0.7.9",
"html-webpack-plugin": "5.5.0", "dotenv": "16.4.7",
"ts-loader": "9.2.8", "html-webpack-plugin": "5.6.3",
"ts-node": "10.7.0", "ts-loader": "9.5.2",
"webpack": "5.72.0", "ts-node": "10.9.2",
"webpack-cli": "4.9.2", "webpack": "5.98.0",
"webpack-dev-server": "4.8.1" "webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.0"
}, },
"scripts": { "scripts": {
"build": "webpack", "build": "webpack",

View File

@@ -15,7 +15,7 @@ const stepSpringOne = (
maxVelocity = Infinity, maxVelocity = Infinity,
}: { tension: number; friction: number; maxVelocity?: number }, }: { tension: number; friction: number; maxVelocity?: number },
target: number, target: number,
dt = 1 / 60 dt = 1 / 60,
) => { ) => {
const a = -tension * (s.x - target) - friction * s.v; const a = -tension * (s.x - target) - friction * s.v;
@@ -31,13 +31,13 @@ const stepSpringOne = (
export const isStable = ( export const isStable = (
s: { x: number; v: number }, s: { x: number; v: number },
target: number, target: number,
dt = 1 / 60 dt = 1 / 60,
) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon; ) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon;
export const isStableAndBound = ( export const isStableAndBound = (
s: { x: number; v: number }, s: { x: number; v: number },
target: number, target: number,
dt?: number dt?: number,
) => { ) => {
const stable = isStable(s, target, dt); const stable = isStable(s, target, dt);
if (stable) { if (stable) {
@@ -51,7 +51,7 @@ export const stepSpring = (
s: { x: number; v: number }, s: { x: number; v: number },
params: { tension: number; friction: number; maxVelocity?: number }, params: { tension: number; friction: number; maxVelocity?: number },
target: number, target: number,
dt = 1 / 60 dt = 1 / 60,
) => { ) => {
const interval = 1 / 60; const interval = 1 / 60;

View File

@@ -1,27 +1,40 @@
import path from "path"; import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin";
import type { Configuration as WebpackConfiguration } from "webpack";
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
import webpack from "webpack"; import webpack from "webpack";
import { getGithubUserContribution } from "@snk/github-user-contribution"; import { getGithubUserContribution } from "@snk/github-user-contribution";
import type { Configuration as WebpackConfiguration } from "webpack";
import {
ExpressRequestHandler,
type Configuration as WebpackDevServerConfiguration,
} from "webpack-dev-server";
import { config } from "dotenv";
config({ path: __dirname + "/../../.env" });
const demos: string[] = require("./demo.json"); const demos: string[] = require("./demo.json");
const webpackDevServerConfiguration: WebpackDevServerConfiguration = { const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
open: { target: demos[1] + ".html" }, open: { target: demos[1] + ".html" },
onAfterSetupMiddleware: ({ app }) => { setupMiddlewares: (ms) => [
app!.get("/api/github-user-contribution/:userName", async (req, res) => { ...ms,
const userName: string = req.params.userName; (async (req, res, next) => {
res.send(await getGithubUserContribution(userName)); const userName = req.url.match(
}); /\/api\/github-user-contribution\/(\w+)/,
}, )?.[1];
if (userName)
res.send(
await getGithubUserContribution(userName, {
githubToken: process.env.GITHUB_TOKEN!,
}),
);
else next();
}) as ExpressRequestHandler,
],
}; };
const webpackConfiguration: WebpackConfiguration = { const webpackConfiguration: WebpackConfiguration = {
mode: "development", mode: "development",
entry: Object.fromEntries( entry: Object.fromEntries(
demos.map((demo: string) => [demo, `./demo.${demo}`]) demos.map((demo: string) => [demo, `./demo.${demo}`]),
), ),
target: ["web", "es2019"], target: ["web", "es2019"],
resolve: { extensions: [".ts", ".js"] }, resolve: { extensions: [".ts", ".js"] },
@@ -52,7 +65,7 @@ const webpackConfiguration: WebpackConfiguration = {
title: "snk - " + demo, title: "snk - " + demo,
filename: `${demo}.html`, filename: `${demo}.html`,
chunks: [demo], chunks: [demo],
}) }),
), ),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
title: "snk - " + demos[0], title: "snk - " + demos[0],

View File

@@ -54,6 +54,6 @@ export const createRpcClient = <API_ extends API>(worker: Worker) => {
worker.addEventListener("terminate", onTerminate); worker.addEventListener("terminate", onTerminate);
worker.postMessage({ symbol, key, methodName, args }); worker.postMessage({ symbol, key, methodName, args });
}), }),
} },
); );
}; };

View File

@@ -59,7 +59,7 @@ export const getCircleSize = (n: number) => {
export const drawCircleStack = ( export const drawCircleStack = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
stack: Color[], stack: Color[],
o: Options o: Options,
) => { ) => {
for (let i = stack.length; i--; ) { for (let i = stack.length; i--; ) {
const { x, y } = cellPath[i]; const { x, y } = cellPath[i];
@@ -67,7 +67,7 @@ export const drawCircleStack = (
ctx.save(); ctx.save();
ctx.translate( ctx.translate(
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2 y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
); );
//@ts-ignore //@ts-ignore

View File

@@ -6,36 +6,36 @@ import type { Point } from "@snk/types/point";
type Options = { type Options = {
colorDots: Record<Color, string>; colorDots: Record<Color, string>;
colorEmpty: string; colorEmpty: string;
colorBorder: string; colorDotBorder: string;
sizeCell: number; sizeCell: number;
sizeDot: number; sizeDot: number;
sizeBorderRadius: number; sizeDotBorderRadius: number;
cells?: Point[];
}; };
export const drawGrid = ( export const drawGrid = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
grid: Grid, grid: Grid,
o: Options cells: Point[] | null,
o: Options,
) => { ) => {
for (let x = grid.width; x--; ) for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) { for (let y = grid.height; y--; ) {
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) { if (!cells || cells.some((c) => c.x === x && c.y === y)) {
const c = getColor(grid, x, y); const c = getColor(grid, x, y);
// @ts-ignore // @ts-ignore
const color = !c ? o.colorEmpty : o.colorDots[c]; const color = !c ? o.colorEmpty : o.colorDots[c];
ctx.save(); ctx.save();
ctx.translate( ctx.translate(
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2 y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
); );
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.strokeStyle = o.colorBorder; ctx.strokeStyle = o.colorDotBorder;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius); pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeDotBorderRadius);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();

View File

@@ -10,7 +10,7 @@ type Options = {
export const drawSnake = ( export const drawSnake = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
snake: Snake, snake: Snake,
o: Options o: Options,
) => { ) => {
const cells = snakeToCells(snake); const cells = snakeToCells(snake);
@@ -25,7 +25,7 @@ export const drawSnake = (
ctx, ctx,
o.sizeCell - u * 2, o.sizeCell - u * 2,
o.sizeCell - u * 2, o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25 (o.sizeCell - u * 2) * 0.25,
); );
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();
@@ -40,7 +40,7 @@ export const drawSnakeLerp = (
snake0: Snake, snake0: Snake,
snake1: Snake, snake1: Snake,
k: number, k: number,
o: Options o: Options,
) => { ) => {
const m = 0.8; const m = 0.8;
const n = snake0.length / 2; const n = snake0.length / 2;
@@ -61,7 +61,7 @@ export const drawSnakeLerp = (
ctx, ctx,
o.sizeCell - u * 2, o.sizeCell - u * 2,
o.sizeCell - u * 2, o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25 (o.sizeCell - u * 2) * 0.25,
); );
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();

View File

@@ -1,18 +1,17 @@
import { drawGrid } from "./drawGrid"; import { drawGrid } from "./drawGrid";
import { drawSnake, drawSnakeLerp } from "./drawSnake"; import { drawSnake, drawSnakeLerp } from "./drawSnake";
import type { Grid, Color } from "@snk/types/grid"; import type { Grid, Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
import type { Snake } from "@snk/types/snake"; import type { Snake } from "@snk/types/snake";
import type { Point } from "@snk/types/point";
export type Options = { export type Options = {
colorDots: Record<Color, string>; colorDots: Record<Color, string>;
colorEmpty: string; colorEmpty: string;
colorBorder: string; colorDotBorder: string;
colorSnake: string; colorSnake: string;
sizeCell: number; sizeCell: number;
sizeDot: number; sizeDot: number;
sizeBorderRadius: number; sizeDotBorderRadius: number;
cells?: Point[];
}; };
export const drawStack = ( export const drawStack = (
@@ -20,7 +19,7 @@ export const drawStack = (
stack: Color[], stack: Color[],
max: number, max: number,
width: number, width: number,
o: { colorDots: Record<Color, string> } o: { colorDots: Record<Color, string> },
) => { ) => {
ctx.save(); ctx.save();
@@ -37,14 +36,15 @@ export const drawStack = (
export const drawWorld = ( export const drawWorld = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
grid: Grid, grid: Grid,
cells: Point[] | null,
snake: Snake, snake: Snake,
stack: Color[], stack: Color[],
o: Options o: Options,
) => { ) => {
ctx.save(); ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, o); drawGrid(ctx, grid, cells, o);
drawSnake(ctx, snake, o); drawSnake(ctx, snake, o);
ctx.restore(); ctx.restore();
@@ -66,18 +66,19 @@ export const drawWorld = (
}; };
export const drawLerpWorld = ( export const drawLerpWorld = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D | CanvasRenderingContext2D,
grid: Grid, grid: Grid,
cells: Point[] | null,
snake0: Snake, snake0: Snake,
snake1: Snake, snake1: Snake,
stack: Color[], stack: Color[],
k: number, k: number,
o: Options o: Options,
) => { ) => {
ctx.save(); ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, o); drawGrid(ctx, grid, cells, o);
drawSnakeLerp(ctx, snake0, snake1, k, o); drawSnakeLerp(ctx, snake0, snake1, k, o);
ctx.translate(0, (grid.height + 2) * o.sizeCell); ctx.translate(0, (grid.height + 2) * o.sizeCell);

View File

@@ -2,7 +2,7 @@ export const pathRoundedRect = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
width: number, width: number,
height: number, height: number,
borderRadius: number borderRadius: number,
) => { ) => {
ctx.moveTo(borderRadius, 0); ctx.moveTo(borderRadius, 0);
ctx.arcTo(width, 0, width, height, borderRadius); ctx.arcTo(width, 0, width, height, borderRadius);

View File

@@ -2,12 +2,13 @@ import * as fs from "fs";
import { performance } from "perf_hooks"; import { performance } from "perf_hooks";
import { createSnakeFromCells } from "@snk/types/snake"; import { createSnakeFromCells } from "@snk/types/snake";
import { realistic as grid } from "@snk/types/__fixtures__/grid"; import { realistic as grid } from "@snk/types/__fixtures__/grid";
import { createGif } from ".."; import { AnimationOptions, createGif } from "..";
import { getBestRoute } from "@snk/solver/getBestRoute"; import { getBestRoute } from "@snk/solver/getBestRoute";
import { getPathToPose } from "@snk/solver/getPathToPose"; import { getPathToPose } from "@snk/solver/getPathToPose";
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
let snake = createSnakeFromCells( let snake = createSnakeFromCells(
Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 })) Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 })),
); );
// const chain = [snake]; // const chain = [snake];
@@ -24,17 +25,17 @@ let snake = createSnakeFromCells(
const chain = getBestRoute(grid, snake)!; const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
const drawOptions = { const drawOptions: DrawOptions = {
sizeBorderRadius: 2, sizeDotBorderRadius: 2,
sizeCell: 16, sizeCell: 16,
sizeDot: 12, sizeDot: 12,
colorBorder: "#1b1f230a", colorDotBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0", colorEmpty: "#ebedf0",
colorSnake: "purple", colorSnake: "purple",
}; };
const gifOptions = { frameDuration: 100, step: 1 }; const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 };
(async () => { (async () => {
for ( for (
@@ -44,12 +45,18 @@ const gifOptions = { frameDuration: 100, step: 1 };
) { ) {
const stats: number[] = []; const stats: number[] = [];
let buffer: Buffer; let buffer: Uint8Array;
const start = Date.now(); const start = Date.now();
const chainL = chain.slice(0, length); const chainL = chain.slice(0, length);
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) { for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
const s = performance.now(); const s = performance.now();
buffer = await createGif(grid, chainL, drawOptions, gifOptions); buffer = await createGif(
grid,
null,
chainL,
drawOptions,
animationOptions,
);
stats.push(performance.now() - s); stats.push(performance.now() - s);
} }
@@ -66,12 +73,12 @@ const gifOptions = { frameDuration: 100, step: 1 };
})}ms`, })}ms`,
"", "",
].join("\n"), ].join("\n"),
stats stats,
); );
fs.writeFileSync( fs.writeFileSync(
`__tests__/__snapshots__/benchmark-output-${length}.gif`, `__tests__/__snapshots__/benchmark-output-${length}.gif`,
buffer! buffer!,
); );
} }
})(); })();

View File

@@ -1,25 +1,25 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { createGif } from ".."; import { it, expect } from "bun:test";
import { AnimationOptions, createGif } from "..";
import * as grids from "@snk/types/__fixtures__/grid"; import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 as snake } from "@snk/types/__fixtures__/snake"; import { snake3 as snake } from "@snk/types/__fixtures__/snake";
import { createSnakeFromCells, nextSnake } from "@snk/types/snake"; import { createSnakeFromCells, nextSnake } from "@snk/types/snake";
import { getBestRoute } from "@snk/solver/getBestRoute"; import { getBestRoute } from "@snk/solver/getBestRoute";
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
jest.setTimeout(20 * 1000);
const upscale = 1; const upscale = 1;
const drawOptions = { const drawOptions: DrawOptions = {
sizeBorderRadius: 2 * upscale, sizeDotBorderRadius: 2 * upscale,
sizeCell: 16 * upscale, sizeCell: 16 * upscale,
sizeDot: 12 * upscale, sizeDot: 12 * upscale,
colorBorder: "#1b1f230a", colorDotBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0", colorEmpty: "#ebedf0",
colorSnake: "purple", colorSnake: "purple",
}; };
const gifOptions = { frameDuration: 200, step: 1 }; const animationOptions: AnimationOptions = { frameDuration: 200, step: 1 };
const dir = path.resolve(__dirname, "__snapshots__"); const dir = path.resolve(__dirname, "__snapshots__");
@@ -34,38 +34,58 @@ for (const key of [
"small", "small",
"smallPacked", "smallPacked",
] as const) ] as const)
it(`should generate ${key} gif`, async () => { it(
const grid = grids[key]; `should generate ${key} gif`,
async () => {
const grid = grids[key];
const chain = [snake, ...getBestRoute(grid, snake)!]; const chain = [snake, ...getBestRoute(grid, snake)!];
const gif = await createGif(grid, chain, drawOptions, gifOptions); const gif = await createGif(
grid,
null,
chain,
drawOptions,
animationOptions,
);
expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
},
{ timeout: 20 * 1000 },
);
it(
`should generate swipper`,
async () => {
const grid = grids.smallFull;
let snk = createSnakeFromCells(
Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 })),
);
const chain = [snk];
for (let y = -1; y < grid.height; y++) {
snk = nextSnake(snk, 0, 1);
chain.push(snk);
for (let x = grid.width - 1; x--; ) {
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
chain.push(snk);
}
}
const gif = await createGif(
grid,
null,
chain,
drawOptions,
animationOptions,
);
expect(gif).toBeDefined(); expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif); fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
}); },
{ timeout: 20 * 1000 },
it(`should generate swipper`, async () => { );
const grid = grids.smallFull;
let snk = createSnakeFromCells(
Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 }))
);
const chain = [snk];
for (let y = -1; y < grid.height; y++) {
snk = nextSnake(snk, 0, 1);
chain.push(snk);
for (let x = grid.width - 1; x--; ) {
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
chain.push(snk);
}
}
const gif = await createGif(grid, chain, drawOptions, gifOptions);
expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
});

View File

@@ -5,10 +5,11 @@ import { createCanvas } from "canvas";
import { Grid, copyGrid, Color } from "@snk/types/grid"; import { Grid, copyGrid, Color } from "@snk/types/grid";
import { Snake } from "@snk/types/snake"; import { Snake } from "@snk/types/snake";
import { import {
Options, Options as DrawOptions,
drawLerpWorld, drawLerpWorld,
getCanvasWorldSize, getCanvasWorldSize,
} from "@snk/draw/drawWorld"; } from "@snk/draw/drawWorld";
import type { Point } from "@snk/types/point";
import { step } from "@snk/solver/step"; import { step } from "@snk/solver/step";
import tmp from "tmp"; import tmp from "tmp";
import gifsicle from "gifsicle"; import gifsicle from "gifsicle";
@@ -16,7 +17,7 @@ import gifsicle from "gifsicle";
import GIFEncoder from "gif-encoder-2"; import GIFEncoder from "gif-encoder-2";
const withTmpDir = async <T>( const withTmpDir = async <T>(
handler: (dir: string) => Promise<T> handler: (dir: string) => Promise<T>,
): Promise<T> => { ): Promise<T> => {
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({ const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
unsafeCleanup: true, unsafeCleanup: true,
@@ -29,24 +30,27 @@ const withTmpDir = async <T>(
} }
}; };
export type AnimationOptions = { frameDuration: number; step: number };
export const createGif = async ( export const createGif = async (
grid0: Grid, grid0: Grid,
cells: Point[] | null,
chain: Snake[], chain: Snake[],
drawOptions: Options, drawOptions: DrawOptions,
gifOptions: { frameDuration: number; step: number } animationOptions: AnimationOptions,
) => ) =>
withTmpDir(async (dir) => { withTmpDir(async (dir) => {
const { width, height } = getCanvasWorldSize(grid0, drawOptions); const { width, height } = getCanvasWorldSize(grid0, drawOptions);
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d") as any as CanvasRenderingContext2D;
const grid = copyGrid(grid0); const grid = copyGrid(grid0);
const stack: Color[] = []; const stack: Color[] = [];
const encoder = new GIFEncoder(width, height, "neuquant", true); const encoder = new GIFEncoder(width, height, "neuquant", true);
encoder.setRepeat(0); encoder.setRepeat(0);
encoder.setDelay(gifOptions.frameDuration); encoder.setDelay(animationOptions.frameDuration);
encoder.start(); encoder.start();
for (let i = 0; i < chain.length; i += 1) { for (let i = 0; i < chain.length; i += 1) {
@@ -54,18 +58,19 @@ export const createGif = async (
const snake1 = chain[Math.min(chain.length - 1, i + 1)]; const snake1 = chain[Math.min(chain.length - 1, i + 1)];
step(grid, stack, snake0); step(grid, stack, snake0);
for (let k = 0; k < gifOptions.step; k++) { for (let k = 0; k < animationOptions.step; k++) {
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#fff"; ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
drawLerpWorld( drawLerpWorld(
ctx, ctx,
grid, grid,
cells,
snake0, snake0,
snake1, snake1,
stack, stack,
k / gifOptions.step, k / animationOptions.step,
drawOptions drawOptions,
); );
encoder.addFrame(ctx); encoder.addFrame(ctx);
@@ -87,8 +92,8 @@ export const createGif = async (
"--colors=18", "--colors=18",
outFileName, outFileName,
["--output", optimizedFileName], ["--output", optimizedFileName],
].flat() ].flat(),
); );
return fs.readFileSync(optimizedFileName); return new Uint8Array(fs.readFileSync(optimizedFileName));
}); });

View File

@@ -4,17 +4,16 @@
"dependencies": { "dependencies": {
"@snk/draw": "1.0.0", "@snk/draw": "1.0.0",
"@snk/solver": "1.0.0", "@snk/solver": "1.0.0",
"canvas": "2.9.1", "canvas": "3.1.0",
"gif-encoder-2": "1.0.5", "gif-encoder-2": "1.0.5",
"gifsicle": "5.3.0", "gifsicle": "5.3.0",
"tmp": "0.2.1" "tmp": "0.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/gifsicle": "5.2.0", "@types/gifsicle": "5.2.2",
"@types/tmp": "0.2.3", "@types/tmp": "0.2.6"
"@vercel/ncc": "0.24.1"
}, },
"scripts": { "scripts": {
"benchmark": "ncc run __tests__/benchmark.ts --quiet" "benchmark": "bun __tests__/benchmark.ts"
} }
} }

View File

@@ -1,3 +1,14 @@
# @snk/github-user-contribution-service # @snk/github-user-contribution-service
Expose github-user-contribution as an endpoint, using vercel.sh Expose github-user-contribution as an endpoint. hosted on cloudflare
```sh
# deploy
bunx wrangler deploy --branch=production
# change secret
bunx wrangler secret put GITHUB_TOKEN
```

View File

@@ -1,16 +0,0 @@
import { getGithubUserContribution } from "@snk/github-user-contribution";
import { NowRequest, NowResponse } from "@vercel/node";
export default async (req: NowRequest, res: NowResponse) => {
const { userName } = req.query;
try {
res.setHeader("Access-Control-Allow-Origin", "https://platane.github.io");
res.statusCode = 200;
res.json(await getGithubUserContribution(userName as string));
} catch (err) {
console.error(err);
res.statusCode = 500;
res.end();
}
};

View File

@@ -0,0 +1,52 @@
import { getGithubUserContribution } from "@snk/github-user-contribution";
const cors =
<
Req extends { headers: Headers },
Res extends { headers: Headers },
A extends Array<any>,
>(
f: (req: Req, ...args: A) => Res | Promise<Res>,
) =>
async (req: Req, ...args: A) => {
const res = await f(req, ...args);
const origin = req.headers.get("origin");
if (origin) {
const { host, hostname } = new URL(origin);
if (hostname === "localhost" || host === "platane.github.io")
res.headers.set("Access-Control-Allow-Origin", origin);
}
res.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
return res;
};
export default {
fetch: cors(async (req: Request, env: { GITHUB_TOKEN: string }) => {
const url = new URL(req.url);
const [, userName] =
url.pathname.match(/^\/github-user-contribution\/([^\/]*)\/?$/) ?? [];
if (req.method === "OPTIONS") return new Response();
if (!userName || req.method !== "GET")
return new Response("unknown route", { status: 404 });
const body = await getGithubUserContribution(userName, {
githubToken: env.GITHUB_TOKEN,
});
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Cache-Control": "max-age=21600, s-maxage=21600",
"Content-Type": "application/json",
},
});
}),
};

View File

@@ -2,7 +2,13 @@
"name": "@snk/github-user-contribution-service", "name": "@snk/github-user-contribution-service",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@snk/github-user-contribution": "1.0.0", "@snk/github-user-contribution": "1.0.0"
"@vercel/node": "1.14.0" },
"devDependencies": {
"wrangler": "3.109.2",
"@cloudflare/workers-types": "4.20250214.0"
},
"scripts": {
"deploy": "wrangler deploy"
} }
} }

View File

@@ -1,5 +0,0 @@
{
"github": {
"silent": true
}
}

View File

@@ -0,0 +1,9 @@
name = "github-user-contribution"
main = "index.ts"
compatibility_date = "2024-09-02"
account_id = "56268cde636c288343cb0767952ecf2e"
workers_dev = true
# [observability]
# enabled = true

View File

@@ -1,19 +0,0 @@
import { formatParams } from "../formatParams";
const params = [
//
[{}, ""],
[{ year: 2017 }, "from=2017-01-01&to=2017-12-31"],
[{ from: "2017-12-03" }, "from=2017-12-03"],
[{ to: "2017-12-03" }, "to=2017-12-03"],
] as const;
params.forEach(([params, res]) =>
it(`should format ${JSON.stringify(params)}`, () => {
expect(formatParams(params)).toBe(res);
})
);
it("should fail if the date is in the future", () => {
expect(() => formatParams({ to: "9999-01-01" })).toThrow(Error);
});

View File

@@ -1,54 +1,32 @@
import { getGithubUserContribution } from ".."; import { getGithubUserContribution } from "..";
import { describe, it, expect } from "bun:test";
describe("getGithubUserContribution", () => { describe("getGithubUserContribution", () => {
const promise = getGithubUserContribution("platane"); const promise = getGithubUserContribution("platane", {
githubToken: process.env.GITHUB_TOKEN!,
});
it("should resolve", async () => { it("should resolve", async () => {
await promise; await promise;
}); });
it("should get colorScheme", async () => {
const { colorScheme } = await promise;
expect(colorScheme).toEqual([
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
]);
});
it("should get around 365 cells", async () => { it("should get around 365 cells", async () => {
const { cells } = await promise; const cells = await promise;
expect(cells.length).toBeGreaterThanOrEqual(365); expect(cells.length).toBeGreaterThanOrEqual(365);
expect(cells.length).toBeLessThanOrEqual(365 + 7); expect(cells.length).toBeLessThanOrEqual(365 + 7);
}); });
it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => { it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => {
const { cells, colorScheme } = await promise; const cells = await promise;
expect(cells.length).toBeGreaterThan(300); expect(cells.length).toBeGreaterThan(300);
expect(colorScheme).toEqual([
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
]);
const undefinedDays = Array.from({ length: Math.floor(365 / 7) }) const undefinedDays = Array.from({ length: Math.floor(365 / 7) })
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y }))) .map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
.flat() .flat()
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y)); .filter(({ x, y }) => cells.some((c: any) => c.x === x && c.y === y));
expect(undefinedDays).toEqual([]); expect(undefinedDays).toEqual([]);
}); });
}); });
it("should match snapshot for year=2019", async () => {
expect(
await getGithubUserContribution("platane", { year: 2019 })
).toMatchSnapshot();
});

View File

@@ -1,38 +0,0 @@
export type Options = { from?: string; to?: string } | { year: number };
export const formatParams = (options: Options = {}) => {
const sp = new URLSearchParams();
const o: any = { ...options };
if ("year" in options) {
o.from = `${options.year}-01-01`;
o.to = `${options.year}-12-31`;
}
for (const s of ["from", "to"])
if (o[s]) {
const value = o[s];
if (value >= formatDate(new Date()))
throw new Error(
"Cannot get contribution for a date in the future.\nPlease limit your range to the current UTC day."
);
sp.set(s, value);
}
return sp.toString();
};
const formatDate = (d: Date) => {
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const date = d.getUTCDate();
return [
year,
month.toString().padStart(2, "0"),
date.toString().padStart(2, "0"),
].join("-");
};

View File

@@ -1,7 +1,3 @@
import fetch from "node-fetch";
import * as cheerio from "cheerio";
import { formatParams, Options } from "./formatParams";
/** /**
* get the contribution grid from a github user page * get the contribution grid from a github user page
* *
@@ -20,110 +16,86 @@ import { formatParams, Options } from "./formatParams";
*/ */
export const getGithubUserContribution = async ( export const getGithubUserContribution = async (
userName: string, userName: string,
options: Options = {} o: { githubToken: string },
) => { ) => {
// either use github.com/users/xxxx/contributions for previous years const query = /* GraphQL */ `
// or github.com/xxxx ( which gives the latest update to today result ) query ($login: String!) {
const url = user(login: $login) {
"year" in options || "from" in options || "to" in options contributionsCollection {
? `https://github.com/users/${userName}/contributions?` + contributionCalendar {
formatParams(options) weeks {
: `https://github.com/${userName}`; contributionDays {
contributionCount
contributionLevel
weekday
date
}
}
}
}
}
}
`;
const variables = { login: userName };
const res = await fetch(url); const res = await fetch("https://api.github.com/graphql", {
headers: {
if (!res.ok) throw new Error(res.statusText); Authorization: `bearer ${o.githubToken}`,
"Content-Type": "application/json",
const resText = await res.text(); "User-Agent": "me@platane.me",
},
return parseUserPage(resText); method: "POST",
}; body: JSON.stringify({ variables, query }),
const defaultColorScheme = [
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
const parseUserPage = (content: string) => {
const $ = cheerio.load(content);
//
// "parse" colorScheme
const colorScheme = [...defaultColorScheme];
//
// parse cells
const rawCells = $(".js-calendar-graph rect[data-count]")
.toArray()
.map((x) => {
const level = +x.attribs["data-level"];
const count = +x.attribs["data-count"];
const date = x.attribs["data-date"];
const color = colorScheme[level];
if (!color) throw new Error("could not determine the color of the cell");
return {
svgPosition: getSvgPosition(x),
color,
count,
date,
};
});
const xMap: Record<number, true> = {};
const yMap: Record<number, true> = {};
rawCells.forEach(({ svgPosition: { x, y } }) => {
xMap[x] = true;
yMap[y] = true;
}); });
const xRange = Object.keys(xMap) if (!res.ok) throw new Error(await res.text().catch(() => res.statusText));
.map((x) => +x)
.sort((a, b) => +a - +b);
const yRange = Object.keys(yMap)
.map((x) => +x)
.sort((a, b) => +a - +b);
const cells = rawCells.map(({ svgPosition, ...c }) => ({ const { data, errors } = (await res.json()) as {
...c, data: GraphQLRes;
x: xRange.indexOf(svgPosition.x), errors?: { message: string }[];
y: yRange.indexOf(svgPosition.y), };
}));
return { cells, colorScheme }; if (errors?.[0]) throw errors[0];
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(
({ contributionDays }, x) =>
contributionDays.map((d) => ({
x,
y: d.weekday,
date: d.date,
count: d.contributionCount,
level:
(d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
0,
})),
);
}; };
// returns the position of the svg elements, accounting for it's transform and it's parent transform type GraphQLRes = {
// ( only accounts for translate transform ) user: {
const getSvgPosition = ( contributionsCollection: {
e: cheerio.Element | null contributionCalendar: {
): { x: number; y: number } => { weeks: {
if (!e || e.tagName === "svg") return { x: 0, y: 0 }; contributionDays: {
contributionCount: number;
const p = getSvgPosition(e.parent as cheerio.Element); contributionLevel:
| "FOURTH_QUARTILE"
if (e.attribs.x) p.x += +e.attribs.x; | "THIRD_QUARTILE"
if (e.attribs.y) p.y += +e.attribs.y; | "SECOND_QUARTILE"
| "FIRST_QUARTILE"
if (e.attribs.transform) { | "NONE";
const m = e.attribs.transform.match( date: string;
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/ weekday: number;
); }[];
}[];
if (m) { };
p.x += +m[1]; };
p.y += +m[2]; };
}
}
return p;
}; };
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>; export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;
export type Cell = Res["cells"][number]; export type Cell = Res[number];

View File

@@ -1,11 +1,4 @@
{ {
"name": "@snk/github-user-contribution", "name": "@snk/github-user-contribution",
"version": "1.0.0", "version": "1.0.0"
"dependencies": {
"cheerio": "1.0.0-rc.10",
"node-fetch": "2.6.7"
},
"devDependencies": {
"@types/node-fetch": "2.6.1"
}
} }

View File

@@ -1,3 +1,4 @@
import { it, expect } from "bun:test";
import { getBestRoute } from "../getBestRoute"; import { getBestRoute } from "../getBestRoute";
import { snake3, snake4 } from "@snk/types/__fixtures__/snake"; import { snake3, snake4 } from "@snk/types/__fixtures__/snake";
import { import {
@@ -16,7 +17,7 @@ for (const { width, height, snake } of [
{ width: 5, height: 5, snake: snake4 }, { width: 5, height: 5, snake: snake4 },
]) ])
it(`should find solution for ${n} ${width}x${height} generated grids for ${getSnakeLength( it(`should find solution for ${n} ${width}x${height} generated grids for ${getSnakeLength(
snake snake,
)} length snake`, () => { )} length snake`, () => {
const results = Array.from({ length: n }, (_, seed) => { const results = Array.from({ length: n }, (_, seed) => {
const grid = createFromSeed(seed, width, height); const grid = createFromSeed(seed, width, height);

View File

@@ -1,3 +1,4 @@
import { it, expect } from "bun:test";
import { getBestRoute } from "../getBestRoute"; import { getBestRoute } from "../getBestRoute";
import { Color, createEmptyGrid, setColor } from "@snk/types/grid"; import { Color, createEmptyGrid, setColor } from "@snk/types/grid";
import { createSnakeFromCells, snakeToCells } from "@snk/types/snake"; import { createSnakeFromCells, snakeToCells } from "@snk/types/snake";

View File

@@ -1,3 +1,4 @@
import { it, expect } from "bun:test";
import { createEmptyGrid } from "@snk/types/grid"; import { createEmptyGrid } from "@snk/types/grid";
import { getHeadX, getHeadY } from "@snk/types/snake"; import { getHeadX, getHeadY } from "@snk/types/snake";
import { snake3 } from "@snk/types/__fixtures__/snake"; import { snake3 } from "@snk/types/__fixtures__/snake";

View File

@@ -1,3 +1,4 @@
import { it, expect } from "bun:test";
import { createSnakeFromCells } from "@snk/types/snake"; import { createSnakeFromCells } from "@snk/types/snake";
import { getPathToPose } from "../getPathToPose"; import { getPathToPose } from "../getPathToPose";

View File

@@ -1,3 +1,4 @@
import { it, expect, describe } from "bun:test";
import { sortPush } from "../utils/sortPush"; import { sortPush } from "../utils/sortPush";
const sortFn = (a: number, b: number) => a - b; const sortFn = (a: number, b: number) => a - b;

View File

@@ -24,7 +24,7 @@ export const clearCleanColoredLayer = (
grid: Grid, grid: Grid,
outside: Outside, outside: Outside,
snake0: Snake, snake0: Snake,
color: Color color: Color,
) => { ) => {
const snakeN = getSnakeLength(snake0); const snakeN = getSnakeLength(snake0);
@@ -55,7 +55,7 @@ const getPathToNextPoint = (
grid: Grid, grid: Grid,
snake0: Snake, snake0: Snake,
color: Color, color: Color,
points: Point[] points: Point[],
) => { ) => {
const closeList: Snake[] = []; const closeList: Snake[] = [];
const openList: M[] = [{ snake: snake0 } as any]; const openList: M[] = [{ snake: snake0 } as any];
@@ -96,7 +96,7 @@ export const getTunnellablePoints = (
grid: Grid, grid: Grid,
outside: Outside, outside: Outside,
snakeN: number, snakeN: number,
color: Color color: Color,
) => { ) => {
const points: Point[] = []; const points: Point[] = [];

View File

@@ -20,7 +20,7 @@ export const clearResidualColoredLayer = (
grid: Grid, grid: Grid,
outside: Outside, outside: Outside,
snake0: Snake, snake0: Snake,
color: Color color: Color,
) => { ) => {
const snakeN = getSnakeLength(snake0); const snakeN = getSnakeLength(snake0);
@@ -99,7 +99,7 @@ export const getTunnellablePoints = (
grid: Grid, grid: Grid,
outside: Outside, outside: Outside,
snakeN: number, snakeN: number,
color: Color color: Color,
) => { ) => {
const points: T[] = []; const points: T[] = [];

View File

@@ -13,7 +13,7 @@ export const getBestRoute = (grid0: Grid, snake0: Snake) => {
for (const color of extractColors(grid)) { for (const color of extractColors(grid)) {
if (color > 1) if (color > 1)
chain.unshift( chain.unshift(
...clearResidualColoredLayer(grid, outside, chain[0], color) ...clearResidualColoredLayer(grid, outside, chain[0], color),
); );
chain.unshift(...clearCleanColoredLayer(grid, outside, chain[0], color)); chain.unshift(...clearCleanColoredLayer(grid, outside, chain[0], color));
} }

View File

@@ -37,7 +37,7 @@ const getSnakeEscapePath = (
grid: Grid, grid: Grid,
outside: Outside, outside: Outside,
snake0: Snake, snake0: Snake,
color: Color color: Color,
) => { ) => {
const openList: M[] = [{ snake: snake0, w: 0 } as any]; const openList: M[] = [{ snake: snake0, w: 0 } as any];
const closeList: Snake[] = []; const closeList: Snake[] = [];
@@ -79,7 +79,7 @@ export const getBestTunnel = (
x: number, x: number,
y: number, y: number,
color: Color, color: Color,
snakeN: number snakeN: number,
) => { ) => {
const c = { x, y }; const c = { x, y };
const snake0 = createSnakeFromCells(Array.from({ length: snakeN }, () => c)); const snake0 = createSnakeFromCells(Array.from({ length: snakeN }, () => c));

View File

@@ -24,7 +24,7 @@ export const createOutside = (grid: Grid, color: Color = 0 as Color) => {
export const fillOutside = ( export const fillOutside = (
outside: Outside, outside: Outside,
grid: Grid, grid: Grid,
color: Color = 0 as Color color: Color = 0 as Color,
) => { ) => {
let changed = true; let changed = true;
while (changed) { while (changed) {

View File

@@ -27,7 +27,7 @@ export const getTunnelPath = (snake0: Snake, tunnel: Point[]) => {
export const updateTunnel = ( export const updateTunnel = (
grid: Grid, grid: Grid,
tunnel: Point[], tunnel: Point[],
toDelete: Point[] toDelete: Point[],
) => { ) => {
while (tunnel.length) { while (tunnel.length) {
const { x, y } = tunnel[0]; const { x, y } = tunnel[0];

View File

@@ -1,15 +1,17 @@
import { it, expect } from "bun:test";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { createSvg } from ".."; import { createSvg, DrawOptions as DrawOptions } from "..";
import * as grids from "@snk/types/__fixtures__/grid"; import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 as snake } from "@snk/types/__fixtures__/snake"; import { snake3 as snake } from "@snk/types/__fixtures__/snake";
import { getBestRoute } from "@snk/solver/getBestRoute"; import { getBestRoute } from "@snk/solver/getBestRoute";
import { AnimationOptions } from "@snk/gif-creator";
const drawOptions = { const drawOptions: DrawOptions = {
sizeBorderRadius: 2, sizeDotBorderRadius: 2,
sizeCell: 16, sizeCell: 16,
sizeDot: 12, sizeDot: 12,
colorBorder: "#1b1f230a", colorDotBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0", colorEmpty: "#ebedf0",
colorSnake: "purple", colorSnake: "purple",
@@ -19,7 +21,7 @@ const drawOptions = {
}, },
}; };
const gifOptions = { frameDuration: 100, step: 1 }; const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 };
const dir = path.resolve(__dirname, "__snapshots__"); const dir = path.resolve(__dirname, "__snapshots__");
@@ -31,7 +33,13 @@ for (const [key, grid] of Object.entries(grids))
it(`should generate ${key} svg`, async () => { it(`should generate ${key} svg`, async () => {
const chain = [snake, ...getBestRoute(grid, snake)!]; const chain = [snake, ...getBestRoute(grid, snake)!];
const svg = await createSvg(grid, chain, drawOptions, gifOptions); const svg = await createSvg(
grid,
null,
chain,
drawOptions,
animationOptions,
);
expect(svg).toBeDefined(); expect(svg).toBeDefined();

View File

@@ -0,0 +1,27 @@
import { it, expect } from "bun:test";
import { minifyCss } from "../css-utils";
it("should minify css", () => {
expect(
minifyCss(`
.c {
color : red ;
}
`),
).toBe(".c{color:red}");
expect(
minifyCss(`
.c {
top : 0;
color : red ;
}
# {
animation: linear 10;
}
`),
).toBe(".c{top:0;color:red}#{animation:linear 10}");
});

View File

@@ -0,0 +1,38 @@
const percent = (x: number) =>
parseFloat((x * 100).toFixed(2)).toString() + "%";
const mergeKeyFrames = (keyframes: { t: number; style: string }[]) => {
const s = new Map<string, number[]>();
for (const { t, style } of keyframes) {
s.set(style, [...(s.get(style) ?? []), t]);
}
return Array.from(s.entries())
.map(([style, ts]) => ({ style, ts }))
.sort((a, b) => a.ts[0] - b.ts[0]);
};
/**
* generate the keyframe animation from a list of keyframe
*/
export const createAnimation = (
name: string,
keyframes: { t: number; style: string }[],
) =>
`@keyframes ${name}{` +
mergeKeyFrames(keyframes)
.map(({ style, ts }) => ts.map(percent).join(",") + `{${style}}`)
.join("") +
"}";
/**
* remove white spaces
*/
export const minifyCss = (css: string) =>
css
.replace(/\s+/g, " ")
.replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, ""))
.replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, ""))
.replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, ""))
.replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, ""))
.replace(/\;\s*\}/g, "}")
.trim();

View File

@@ -1,22 +1,21 @@
import type { Color, Empty } from "@snk/types/grid"; import type { Color, Empty } from "@snk/types/grid";
import type { Point } from "@snk/types/point"; import type { Point } from "@snk/types/point";
import { h } from "./utils"; import { createAnimation } from "./css-utils";
import { h } from "./xml-utils";
export type Options = { export type Options = {
colorDots: Record<Color, string>; colorDots: Record<Color, string>;
colorEmpty: string; colorEmpty: string;
colorBorder: string; colorDotBorder: string;
sizeCell: number; sizeCell: number;
sizeDot: number; sizeDot: number;
sizeBorderRadius: number; sizeDotBorderRadius: number;
}; };
const percent = (x: number) => (x * 100).toFixed(2);
export const createGrid = ( export const createGrid = (
cells: (Point & { t: number | null; color: Color | Empty })[], cells: (Point & { t: number | null; color: Color | Empty })[],
{ sizeBorderRadius, sizeDot, sizeCell }: Options, { sizeDotBorderRadius, sizeDot, sizeCell }: Options,
duration: number duration: number,
) => { ) => {
const svgElements: string[] = []; const svgElements: string[] = [];
const styles = [ const styles = [
@@ -26,39 +25,41 @@ export const createGrid = (
stroke-width: 1px; stroke-width: 1px;
stroke: var(--cb); stroke: var(--cb);
animation: none ${duration}ms linear infinite; animation: none ${duration}ms linear infinite;
width: ${sizeDot}px;
height: ${sizeDot}px;
}`, }`,
]; ];
let i = 0; let i = 0;
for (const { x, y, color, t } of cells) { for (const { x, y, color, t } of cells) {
const id = t && "c" + (i++).toString(36); const id = t && "c" + (i++).toString(36);
const s = sizeCell; const m = (sizeCell - sizeDot) / 2;
const d = sizeDot;
const m = (s - d) / 2;
if (t !== null) { if (t !== null && id) {
const animationName = id; const animationName = id;
styles.push( styles.push(
`@keyframes ${animationName} {` + createAnimation(animationName, [
`${percent(t - 0.0001)}%{fill:var(--c${color})}` + { t: t - 0.0001, style: `fill:var(--c${color})` },
`${percent(t + 0.0001)}%,100%{fill:var(--ce)}` + { t: t + 0.0001, style: `fill:var(--ce)` },
"}", { t: 1, style: `fill:var(--ce)` },
]),
`.c.${id}{fill: var(--c${color}); animation-name: ${animationName}}` `.c.${id}{
fill: var(--c${color});
animation-name: ${animationName}
}`,
); );
} }
svgElements.push( svgElements.push(
h("rect", { h("rect", {
class: ["c", id].filter(Boolean).join(" "), class: ["c", id].filter(Boolean).join(" "),
x: x * s + m, x: x * sizeCell + m,
y: y * s + m, y: y * sizeCell + m,
rx: sizeBorderRadius, rx: sizeDotBorderRadius,
ry: sizeBorderRadius, ry: sizeDotBorderRadius,
width: d, }),
height: d,
})
); );
} }

View File

@@ -9,43 +9,43 @@ import { getHeadX, getHeadY } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake"; import type { Snake } from "@snk/types/snake";
import type { Grid, Color, Empty } from "@snk/types/grid"; import type { Grid, Color, Empty } from "@snk/types/grid";
import type { Point } from "@snk/types/point"; import type { Point } from "@snk/types/point";
import type { AnimationOptions } from "@snk/gif-creator";
import { createSnake } from "./snake"; import { createSnake } from "./snake";
import { createGrid } from "./grid"; import { createGrid } from "./grid";
import { createStack } from "./stack"; import { createStack } from "./stack";
import { h } from "./utils"; import { h } from "./xml-utils";
import * as csso from "csso"; import { minifyCss } from "./css-utils";
export type Options = { export type DrawOptions = {
colorDots: Record<Color, string>; colorDots: Record<Color, string>;
colorEmpty: string; colorEmpty: string;
colorBorder: string; colorDotBorder: string;
colorSnake: string; colorSnake: string;
sizeCell: number; sizeCell: number;
sizeDot: number; sizeDot: number;
sizeBorderRadius: number; sizeDotBorderRadius: number;
cells?: Point[];
dark?: { dark?: {
colorDots: Record<Color, string>; colorDots: Record<Color, string>;
colorEmpty: string; colorEmpty: string;
colorBorder?: string; colorDotBorder?: string;
colorSnake?: string; colorSnake?: string;
}; };
}; };
const getCellsFromGrid = ({ width, height }: Grid) => const getCellsFromGrid = ({ width, height }: Grid) =>
Array.from({ length: width }, (_, x) => Array.from({ length: width }, (_, x) =>
Array.from({ length: height }, (_, y) => ({ x, y })) Array.from({ length: height }, (_, y) => ({ x, y })),
).flat(); ).flat();
const createLivingCells = ( const createLivingCells = (
grid0: Grid, grid0: Grid,
chain: Snake[], chain: Snake[],
drawOptions: Options cells: Point[] | null,
) => { ) => {
const cells: (Point & { const livingCells: (Point & {
t: number | null; t: number | null;
color: Color | Empty; color: Color | Empty;
})[] = (drawOptions.cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({ })[] = (cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
x, x,
y, y,
t: null, t: null,
@@ -60,35 +60,36 @@ const createLivingCells = (
if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) { if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) {
setColorEmpty(grid, x, y); setColorEmpty(grid, x, y);
const cell = cells.find((c) => c.x === x && c.y === y)!; const cell = livingCells.find((c) => c.x === x && c.y === y)!;
cell.t = i / chain.length; cell.t = i / chain.length;
} }
} }
return cells; return livingCells;
}; };
export const createSvg = ( export const createSvg = (
grid: Grid, grid: Grid,
cells: Point[] | null,
chain: Snake[], chain: Snake[],
drawOptions: Options, drawOptions: DrawOptions,
gifOptions: { frameDuration: number } animationOptions: Pick<AnimationOptions, "frameDuration">,
) => { ) => {
const width = (grid.width + 2) * drawOptions.sizeCell; const width = (grid.width + 2) * drawOptions.sizeCell;
const height = (grid.height + 5) * drawOptions.sizeCell; const height = (grid.height + 5) * drawOptions.sizeCell;
const duration = gifOptions.frameDuration * chain.length; const duration = animationOptions.frameDuration * chain.length;
const cells = createLivingCells(grid, chain, drawOptions); const livingCells = createLivingCells(grid, chain, cells);
const elements = [ const elements = [
createGrid(cells, drawOptions, duration), createGrid(livingCells, drawOptions, duration),
createStack( createStack(
cells, livingCells,
drawOptions, drawOptions,
grid.width * drawOptions.sizeCell, grid.width * drawOptions.sizeCell,
(grid.height + 2) * drawOptions.sizeCell, (grid.height + 2) * drawOptions.sizeCell,
duration duration,
), ),
createSnake(chain, drawOptions, duration), createSnake(chain, drawOptions, duration),
]; ];
@@ -131,13 +132,13 @@ export const createSvg = (
return optimizeSvg(svg); return optimizeSvg(svg);
}; };
const optimizeCss = (css: string) => csso.minify(css).css; const optimizeCss = (css: string) => minifyCss(css);
const optimizeSvg = (svg: string) => svg; const optimizeSvg = (svg: string) => svg;
const generateColorVar = (drawOptions: Options) => const generateColorVar = (drawOptions: DrawOptions) =>
` `
:root { :root {
--cb: ${drawOptions.colorBorder}; --cb: ${drawOptions.colorDotBorder};
--cs: ${drawOptions.colorSnake}; --cs: ${drawOptions.colorSnake};
--ce: ${drawOptions.colorEmpty}; --ce: ${drawOptions.colorEmpty};
${Object.entries(drawOptions.colorDots) ${Object.entries(drawOptions.colorDots)
@@ -149,7 +150,7 @@ const generateColorVar = (drawOptions: Options) =>
? ` ? `
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--cb: ${drawOptions.dark.colorBorder || drawOptions.colorBorder}; --cb: ${drawOptions.dark.colorDotBorder || drawOptions.colorDotBorder};
--cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake}; --cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake};
--ce: ${drawOptions.dark.colorEmpty}; --ce: ${drawOptions.dark.colorEmpty};
${Object.entries(drawOptions.dark.colorDots) ${Object.entries(drawOptions.dark.colorDots)

View File

@@ -2,10 +2,6 @@
"name": "@snk/svg-creator", "name": "@snk/svg-creator",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@snk/solver": "1.0.0", "@snk/solver": "1.0.0"
"csso": "5.0.3"
},
"devDependencies": {
"@types/csso": "5.0.0"
} }
} }

View File

@@ -1,27 +1,21 @@
import { getSnakeLength, snakeToCells } from "@snk/types/snake"; import { getSnakeLength, snakeToCells } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake"; import type { Snake } from "@snk/types/snake";
import type { Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point"; import type { Point } from "@snk/types/point";
import { h } from "./utils"; import { h } from "./xml-utils";
import { createAnimation } from "./css-utils";
export type Options = { export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder: string;
colorSnake: string; colorSnake: string;
sizeCell: number; sizeCell: number;
sizeDot: number; sizeDot: number;
sizeBorderRadius: number;
}; };
const percent = (x: number) => (x * 100).toFixed(2);
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b; const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
export const createSnake = ( export const createSnake = (
chain: Snake[], chain: Snake[],
{ sizeCell, sizeDot }: Options, { sizeCell, sizeDot }: Options,
duration: number duration: number,
) => { ) => {
const snakeN = chain[0] ? getSnakeLength(chain[0]) : 0; const snakeN = chain[0] ? getSnakeLength(chain[0]) : 0;
@@ -60,8 +54,8 @@ export const createSnake = (
const styles = [ const styles = [
`.s{ `.s{
shape-rendering:geometricPrecision; shape-rendering: geometricPrecision;
fill:var(--cs); fill: var(--cs);
animation: none linear ${duration}ms infinite animation: none linear ${duration}ms infinite
}`, }`,
@@ -69,16 +63,17 @@ export const createSnake = (
const id = `s${i}`; const id = `s${i}`;
const animationName = id; const animationName = id;
return [ const keyframes = removeInterpolatedPositions(
`@keyframes ${animationName} {` + positions.map((tr, i, { length }) => ({ ...tr, t: i / length })),
removeInterpolatedPositions( ).map(({ t, ...p }) => ({ t, style: transform(p) }));
positions.map((tr, i, { length }) => ({ ...tr, t: i / length }))
)
.map((p) => `${percent(p.t)}%{${transform(p)}}`)
.join("") +
"}",
`.s.${id}{${transform(positions[0])};animation-name: ${animationName}}`, return [
createAnimation(animationName, keyframes),
`.s.${id}{
${transform(positions[0])};
animation-name: ${animationName}
}`,
]; ];
}), }),
].flat(); ].flat();

View File

@@ -1,18 +1,17 @@
import type { Color, Empty } from "@snk/types/grid"; import type { Color, Empty } from "@snk/types/grid";
import { h } from "./utils"; import { createAnimation } from "./css-utils";
import { h } from "./xml-utils";
export type Options = { export type Options = {
sizeDot: number; sizeDot: number;
}; };
const percent = (x: number) => (x * 100).toFixed(2);
export const createStack = ( export const createStack = (
cells: { t: number | null; color: Color | Empty }[], cells: { t: number | null; color: Color | Empty }[],
{ sizeDot }: Options, { sizeDot }: Options,
width: number, width: number,
y: number, y: number,
duration: number duration: number,
) => { ) => {
const svgElements: string[] = []; const svgElements: string[] = [];
const styles = [ const styles = [
@@ -52,27 +51,32 @@ export const createStack = (
width: (ts.length * m + 0.6).toFixed(1), width: (ts.length * m + 0.6).toFixed(1),
x, x,
y, y,
}) }),
); );
styles.push( styles.push(
`@keyframes ${animationName} {` + createAnimation(
animationName,
[ [
...ts.map((t, i, { length }) => [ ...ts
{ scale: i / length, t: t - 0.0001 }, .map((t, i, { length }) => [
{ scale: (i + 1) / length, t: t + 0.0001 }, { scale: i / length, t: t - 0.0001 },
]), { scale: (i + 1) / length, t: t + 0.0001 },
[{ scale: 1, t: 1 }], ])
] .flat(),
.flat() { scale: 1, t: 1 },
.map( ].map(({ scale, t }) => ({
({ scale, t }) => t,
`${percent(t)}%{transform:scale(${scale.toFixed(2)},1)}` style: `transform:scale(${scale.toFixed(3)},1)`,
) })),
.join("\n") + ),
"}",
`.u.${id}{fill:var(--c${color});animation-name:${animationName};transform-origin:${x}px 0}` `.u.${id} {
fill: var(--c${color});
animation-name: ${animationName};
transform-origin: ${x}px 0
}
`,
); );
} }

View File

@@ -1,13 +1,14 @@
import { it, expect, test } from "bun:test";
import { createEmptyGrid, setColor, getColor, isInside, Color } from "../grid"; import { createEmptyGrid, setColor, getColor, isInside, Color } from "../grid";
it("should set / get cell", () => { it("should set / get cell", () => {
const grid = createEmptyGrid(2, 3); const grid = createEmptyGrid(2, 3);
expect(getColor(grid, 0, 1)).toBe(0); expect(getColor(grid, 0, 1)).toBe(0 as any);
setColor(grid, 0, 1, 1 as Color); setColor(grid, 0, 1, 1 as Color);
expect(getColor(grid, 0, 1)).toBe(1); expect(getColor(grid, 0, 1)).toBe(1 as any);
}); });
test.each([ test.each([

View File

@@ -1,3 +1,4 @@
import { it, expect } from "bun:test";
import { import {
createSnakeFromCells, createSnakeFromCells,
nextSnake, nextSnake,
@@ -29,7 +30,7 @@ it("should return next snake", () => {
]; ];
expect(snakeToCells(nextSnake(createSnakeFromCells(snk0), 1, 0))).toEqual( expect(snakeToCells(nextSnake(createSnakeFromCells(snk0), 1, 0))).toEqual(
snk1 snk1,
); );
}); });

View File

@@ -30,7 +30,7 @@ export const setColor = (
grid: Grid, grid: Grid,
x: number, x: number,
y: number, y: number,
color: Color | Empty color: Color | Empty,
) => { ) => {
grid.data[getIndex(grid, x, y)] = color || 0; grid.data[getIndex(grid, x, y)] = color || 0;
}; };

View File

@@ -9,7 +9,7 @@ export const randomlyFillGrid = (
colors = [1, 2, 3] as Color[], colors = [1, 2, 3] as Color[],
emptyP = 2, emptyP = 2,
}: { colors?: Color[]; emptyP?: number } = {}, }: { colors?: Color[]; emptyP?: number } = {},
rand = defaultRand rand = defaultRand,
) => { ) => {
for (let x = grid.width; x--; ) for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) { for (let y = grid.height; y--; ) {

View File

@@ -3,18 +3,33 @@ description: "Generates a snake game from a github user contributions grid. Outp
author: "platane" author: "platane"
runs: runs:
using: node16 using: node20
main: dist/index.js main: dist/index.js
inputs: inputs:
github_user_name: github_user_name:
description: "github user name" description: "github user name"
required: true required: true
svg_out_path: github_token:
description: "path of the generated svg file. If left empty, the svg file will not be generated." description: "github token used to fetch the contribution calendar. Default to the action token if empty."
required: false required: false
default: null default: ${{ github.token }}
outputs:
required: false
description: |
list of files to generate.
one file per line. Each output can be customized with options as query string.
outputs: supported query string options:
svg_out_path:
description: "path of the generated svg" - palette: A preset of color, one of [github, github-dark, github-light]
- color_snake: Color of the snake
- color_dots: Coma separated list of dots color.
The first one is 0 contribution, then it goes from the low contribution to the highest.
Exactly 5 colors are expected.
example:
outputs: |
dark.svg?palette=github-dark&color_snake=blue
light.svg?color_snake=#7845ab
ocean.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9

2129
svg-only/dist/155.index.js vendored Normal file

File diff suppressed because it is too large Load Diff

768
svg-only/dist/324.index.js vendored Normal file
View File

@@ -0,0 +1,768 @@
"use strict";
exports.id = 324;
exports.ids = [324];
exports.modules = {
/***/ 324:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
// EXPORTS
__webpack_require__.d(__webpack_exports__, {
generateContributionSnake: () => (/* binding */ generateContributionSnake)
});
;// CONCATENATED MODULE: ../github-user-contribution/index.ts
/**
* get the contribution grid from a github user page
*
* use options.from=YYYY-MM-DD options.to=YYYY-MM-DD to get the contribution grid for a specific time range
* or year=2019 as an alias for from=2019-01-01 to=2019-12-31
*
* otherwise return use the time range from today minus one year to today ( as seen in github profile page )
*
* @param userName github user name
* @param options
*
* @example
* getGithubUserContribution("platane", { from: "2019-01-01", to: "2019-12-31" })
* getGithubUserContribution("platane", { year: 2019 })
*
*/
const getGithubUserContribution = async (userName, o) => {
const query = /* GraphQL */ `
query ($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar {
weeks {
contributionDays {
contributionCount
contributionLevel
weekday
date
}
}
}
}
}
}
`;
const variables = { login: userName };
const res = await fetch("https://api.github.com/graphql", {
headers: {
Authorization: `bearer ${o.githubToken}`,
"Content-Type": "application/json",
"User-Agent": "me@platane.me",
},
method: "POST",
body: JSON.stringify({ variables, query }),
});
if (!res.ok)
throw new Error(await res.text().catch(() => res.statusText));
const { data, errors } = (await res.json());
if (errors?.[0])
throw errors[0];
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(({ contributionDays }, x) => contributionDays.map((d) => ({
x,
y: d.weekday,
date: d.date,
count: d.contributionCount,
level: (d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
0,
})));
};
// EXTERNAL MODULE: ../types/grid.ts
var types_grid = __webpack_require__(105);
;// CONCATENATED MODULE: ./userContributionToGrid.ts
const userContributionToGrid = (cells) => {
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
const grid = (0,types_grid/* createEmptyGrid */.Kb)(width, height);
for (const c of cells) {
if (c.level > 0)
(0,types_grid/* setColor */.wW)(grid, c.x, c.y, c.level);
else
(0,types_grid/* setColorEmpty */.l$)(grid, c.x, c.y);
}
return grid;
};
;// CONCATENATED MODULE: ../types/point.ts
const around4 = [
{ x: 1, y: 0 },
{ x: 0, y: -1 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
];
const pointEquals = (a, b) => a.x === b.x && a.y === b.y;
;// CONCATENATED MODULE: ../solver/outside.ts
const createOutside = (grid, color = 0) => {
const outside = (0,types_grid/* createEmptyGrid */.Kb)(grid.width, grid.height);
for (let x = outside.width; x--;)
for (let y = outside.height; y--;)
(0,types_grid/* setColor */.wW)(outside, x, y, 1);
fillOutside(outside, grid, color);
return outside;
};
const fillOutside = (outside, grid, color = 0) => {
let changed = true;
while (changed) {
changed = false;
for (let x = outside.width; x--;)
for (let y = outside.height; y--;)
if ((0,types_grid/* getColor */.oU)(grid, x, y) <= color &&
!isOutside(outside, x, y) &&
around4.some((a) => isOutside(outside, x + a.x, y + a.y))) {
changed = true;
(0,types_grid/* setColorEmpty */.l$)(outside, x, y);
}
}
return outside;
};
const isOutside = (outside, x, y) => !(0,types_grid/* isInside */.FK)(outside, x, y) || (0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(outside, x, y));
// EXTERNAL MODULE: ../types/snake.ts
var types_snake = __webpack_require__(777);
;// CONCATENATED MODULE: ../solver/utils/sortPush.ts
const sortPush = (arr, x, sortFn) => {
let a = 0;
let b = arr.length;
if (arr.length === 0 || sortFn(x, arr[a]) <= 0) {
arr.unshift(x);
return;
}
while (b - a > 1) {
const e = Math.ceil((a + b) / 2);
const s = sortFn(x, arr[e]);
if (s === 0)
a = b = e;
else if (s > 0)
a = e;
else
b = e;
}
const e = Math.ceil((a + b) / 2);
arr.splice(e, 0, x);
};
;// CONCATENATED MODULE: ../solver/tunnel.ts
/**
* get the sequence of snake to cross the tunnel
*/
const getTunnelPath = (snake0, tunnel) => {
const chain = [];
let snake = snake0;
for (let i = 1; i < tunnel.length; i++) {
const dx = tunnel[i].x - (0,types_snake/* getHeadX */.tN)(snake);
const dy = tunnel[i].y - (0,types_snake/* getHeadY */.Ap)(snake);
snake = (0,types_snake/* nextSnake */.Sc)(snake, dx, dy);
chain.unshift(snake);
}
return chain;
};
/**
* assuming the grid change and the colors got deleted, update the tunnel
*/
const updateTunnel = (grid, tunnel, toDelete) => {
while (tunnel.length) {
const { x, y } = tunnel[0];
if (isEmptySafe(grid, x, y) ||
toDelete.some((p) => p.x === x && p.y === y)) {
tunnel.shift();
}
else
break;
}
while (tunnel.length) {
const { x, y } = tunnel[tunnel.length - 1];
if (isEmptySafe(grid, x, y) ||
toDelete.some((p) => p.x === x && p.y === y)) {
tunnel.pop();
}
else
break;
}
};
const isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.FK)(grid, x, y) || (0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, x, y));
/**
* remove empty cell from start
*/
const trimTunnelStart = (grid, tunnel) => {
while (tunnel.length) {
const { x, y } = tunnel[0];
if (isEmptySafe(grid, x, y))
tunnel.shift();
else
break;
}
};
/**
* remove empty cell from end
*/
const trimTunnelEnd = (grid, tunnel) => {
while (tunnel.length) {
const i = tunnel.length - 1;
const { x, y } = tunnel[i];
if (isEmptySafe(grid, x, y) ||
tunnel.findIndex((p) => p.x === x && p.y === y) < i)
tunnel.pop();
else
break;
}
};
;// CONCATENATED MODULE: ../solver/getBestTunnel.ts
const getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.FK)(grid, x, y) ? (0,types_grid/* getColor */.oU)(grid, x, y) : 0;
const setEmptySafe = (grid, x, y) => {
if ((0,types_grid/* isInside */.FK)(grid, x, y))
(0,types_grid/* setColorEmpty */.l$)(grid, x, y);
};
const unwrap = (m) => !m
? []
: [...unwrap(m.parent), { x: (0,types_snake/* getHeadX */.tN)(m.snake), y: (0,types_snake/* getHeadY */.Ap)(m.snake) }];
/**
* returns the path to reach the outside which contains the least color cell
*/
const getSnakeEscapePath = (grid, outside, snake0, color) => {
const openList = [{ snake: snake0, w: 0 }];
const closeList = [];
while (openList[0]) {
const o = openList.shift();
const x = (0,types_snake/* getHeadX */.tN)(o.snake);
const y = (0,types_snake/* getHeadY */.Ap)(o.snake);
if (isOutside(outside, x, y))
return unwrap(o);
for (const a of around4) {
const c = getColorSafe(grid, x + a.x, y + a.y);
if (c <= color && !(0,types_snake/* snakeWillSelfCollide */.J)(o.snake, a.x, a.y)) {
const snake = (0,types_snake/* nextSnake */.Sc)(o.snake, a.x, a.y);
if (!closeList.some((s0) => (0,types_snake/* snakeEquals */.sW)(s0, snake))) {
const w = o.w + 1 + +(c === color) * 1000;
sortPush(openList, { snake, w, parent: o }, (a, b) => a.w - b.w);
closeList.push(snake);
}
}
}
}
return null;
};
/**
* compute the best tunnel to get to the cell and back to the outside ( best = less usage of <color> )
*
* notice that it's one of the best tunnels, more with the same score could exist
*/
const getBestTunnel = (grid, outside, x, y, color, snakeN) => {
const c = { x, y };
const snake0 = (0,types_snake/* createSnakeFromCells */.yS)(Array.from({ length: snakeN }, () => c));
const one = getSnakeEscapePath(grid, outside, snake0, color);
if (!one)
return null;
// get the position of the snake if it was going to leave the x,y cell
const snakeICells = one.slice(0, snakeN);
while (snakeICells.length < snakeN)
snakeICells.push(snakeICells[snakeICells.length - 1]);
const snakeI = (0,types_snake/* createSnakeFromCells */.yS)(snakeICells);
// remove from the grid the colors that one eat
const gridI = (0,types_grid/* copyGrid */.mi)(grid);
for (const { x, y } of one)
setEmptySafe(gridI, x, y);
const two = getSnakeEscapePath(gridI, outside, snakeI, color);
if (!two)
return null;
one.shift();
one.reverse();
one.push(...two);
trimTunnelStart(grid, one);
trimTunnelEnd(grid, one);
return one;
};
;// CONCATENATED MODULE: ../solver/getPathTo.ts
/**
* starting from snake0, get to the cell x,y
* return the snake chain (reversed)
*/
const getPathTo = (grid, snake0, x, y) => {
const openList = [{ snake: snake0, w: 0 }];
const closeList = [];
while (openList.length) {
const c = openList.shift();
const cx = (0,types_snake/* getHeadX */.tN)(c.snake);
const cy = (0,types_snake/* getHeadY */.Ap)(c.snake);
for (let i = 0; i < around4.length; i++) {
const { x: dx, y: dy } = around4[i];
const nx = cx + dx;
const ny = cy + dy;
if (nx === x && ny === y) {
// unwrap
const path = [(0,types_snake/* nextSnake */.Sc)(c.snake, dx, dy)];
let e = c;
while (e.parent) {
path.push(e.snake);
e = e.parent;
}
return path;
}
if ((0,types_grid/* isInsideLarge */.Yd)(grid, 2, nx, ny) &&
!(0,types_snake/* snakeWillSelfCollide */.J)(c.snake, dx, dy) &&
(!(0,types_grid/* isInside */.FK)(grid, nx, ny) || (0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, nx, ny)))) {
const nsnake = (0,types_snake/* nextSnake */.Sc)(c.snake, dx, dy);
if (!closeList.some((s) => (0,types_snake/* snakeEquals */.sW)(nsnake, s))) {
const w = c.w + 1;
const h = Math.abs(nx - x) + Math.abs(ny - y);
const f = w + h;
const o = { snake: nsnake, parent: c, w, h, f };
sortPush(openList, o, (a, b) => a.f - b.f);
closeList.push(nsnake);
}
}
}
}
};
;// CONCATENATED MODULE: ../solver/clearResidualColoredLayer.ts
const clearResidualColoredLayer = (grid, outside, snake0, color) => {
const snakeN = (0,types_snake/* getSnakeLength */.T$)(snake0);
const tunnels = getTunnellablePoints(grid, outside, snakeN, color);
// sort
tunnels.sort((a, b) => b.priority - a.priority);
const chain = [snake0];
while (tunnels.length) {
// get the best next tunnel
let t = getNextTunnel(tunnels, chain[0]);
// goes to the start of the tunnel
chain.unshift(...getPathTo(grid, chain[0], t[0].x, t[0].y));
// goes to the end of the tunnel
chain.unshift(...getTunnelPath(chain[0], t));
// update grid
for (const { x, y } of t)
clearResidualColoredLayer_setEmptySafe(grid, x, y);
// update outside
fillOutside(outside, grid);
// update tunnels
for (let i = tunnels.length; i--;)
if ((0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, tunnels[i].x, tunnels[i].y)))
tunnels.splice(i, 1);
else {
const t = tunnels[i];
const tunnel = getBestTunnel(grid, outside, t.x, t.y, color, snakeN);
if (!tunnel)
tunnels.splice(i, 1);
else {
t.tunnel = tunnel;
t.priority = getPriority(grid, color, tunnel);
}
}
// re-sort
tunnels.sort((a, b) => b.priority - a.priority);
}
chain.pop();
return chain;
};
const getNextTunnel = (ts, snake) => {
let minDistance = Infinity;
let closestTunnel = null;
const x = (0,types_snake/* getHeadX */.tN)(snake);
const y = (0,types_snake/* getHeadY */.Ap)(snake);
const priority = ts[0].priority;
for (let i = 0; ts[i] && ts[i].priority === priority; i++) {
const t = ts[i].tunnel;
const d = distanceSq(t[0].x, t[0].y, x, y);
if (d < minDistance) {
minDistance = d;
closestTunnel = t;
}
}
return closestTunnel;
};
/**
* get all the tunnels for all the cells accessible
*/
const getTunnellablePoints = (grid, outside, snakeN, color) => {
const points = [];
for (let x = grid.width; x--;)
for (let y = grid.height; y--;) {
const c = (0,types_grid/* getColor */.oU)(grid, x, y);
if (!(0,types_grid/* isEmpty */.Im)(c) && c < color) {
const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN);
if (tunnel) {
const priority = getPriority(grid, color, tunnel);
points.push({ x, y, priority, tunnel });
}
}
}
return points;
};
/**
* get the score of the tunnel
* prioritize tunnel with maximum color smaller than <color> and with minimum <color>
* with some tweaks
*/
const getPriority = (grid, color, tunnel) => {
let nColor = 0;
let nLess = 0;
for (let i = 0; i < tunnel.length; i++) {
const { x, y } = tunnel[i];
const c = clearResidualColoredLayer_getColorSafe(grid, x, y);
if (!(0,types_grid/* isEmpty */.Im)(c) && i === tunnel.findIndex((p) => p.x === x && p.y === y)) {
if (c === color)
nColor += 1;
else
nLess += color - c;
}
}
if (nColor === 0)
return 99999;
return nLess / nColor;
};
const distanceSq = (ax, ay, bx, by) => (ax - bx) ** 2 + (ay - by) ** 2;
const clearResidualColoredLayer_getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.FK)(grid, x, y) ? (0,types_grid/* getColor */.oU)(grid, x, y) : 0;
const clearResidualColoredLayer_setEmptySafe = (grid, x, y) => {
if ((0,types_grid/* isInside */.FK)(grid, x, y))
(0,types_grid/* setColorEmpty */.l$)(grid, x, y);
};
;// CONCATENATED MODULE: ../solver/clearCleanColoredLayer.ts
const clearCleanColoredLayer = (grid, outside, snake0, color) => {
const snakeN = (0,types_snake/* getSnakeLength */.T$)(snake0);
const points = clearCleanColoredLayer_getTunnellablePoints(grid, outside, snakeN, color);
const chain = [snake0];
while (points.length) {
const path = getPathToNextPoint(grid, chain[0], color, points);
path.pop();
for (const snake of path)
clearCleanColoredLayer_setEmptySafe(grid, (0,types_snake/* getHeadX */.tN)(snake), (0,types_snake/* getHeadY */.Ap)(snake));
chain.unshift(...path);
}
fillOutside(outside, grid);
chain.pop();
return chain;
};
const clearCleanColoredLayer_unwrap = (m) => !m ? [] : [m.snake, ...clearCleanColoredLayer_unwrap(m.parent)];
const getPathToNextPoint = (grid, snake0, color, points) => {
const closeList = [];
const openList = [{ snake: snake0 }];
while (openList.length) {
const o = openList.shift();
const x = (0,types_snake/* getHeadX */.tN)(o.snake);
const y = (0,types_snake/* getHeadY */.Ap)(o.snake);
const i = points.findIndex((p) => p.x === x && p.y === y);
if (i >= 0) {
points.splice(i, 1);
return clearCleanColoredLayer_unwrap(o);
}
for (const { x: dx, y: dy } of around4) {
if ((0,types_grid/* isInsideLarge */.Yd)(grid, 2, x + dx, y + dy) &&
!(0,types_snake/* snakeWillSelfCollide */.J)(o.snake, dx, dy) &&
clearCleanColoredLayer_getColorSafe(grid, x + dx, y + dy) <= color) {
const snake = (0,types_snake/* nextSnake */.Sc)(o.snake, dx, dy);
if (!closeList.some((s0) => (0,types_snake/* snakeEquals */.sW)(s0, snake))) {
closeList.push(snake);
openList.push({ snake, parent: o });
}
}
}
}
};
/**
* get all cells that are tunnellable
*/
const clearCleanColoredLayer_getTunnellablePoints = (grid, outside, snakeN, color) => {
const points = [];
for (let x = grid.width; x--;)
for (let y = grid.height; y--;) {
const c = (0,types_grid/* getColor */.oU)(grid, x, y);
if (!(0,types_grid/* isEmpty */.Im)(c) &&
c <= color &&
!points.some((p) => p.x === x && p.y === y)) {
const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN);
if (tunnel)
for (const p of tunnel)
if (!clearCleanColoredLayer_isEmptySafe(grid, p.x, p.y))
points.push(p);
}
}
return points;
};
const clearCleanColoredLayer_getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.FK)(grid, x, y) ? (0,types_grid/* getColor */.oU)(grid, x, y) : 0;
const clearCleanColoredLayer_setEmptySafe = (grid, x, y) => {
if ((0,types_grid/* isInside */.FK)(grid, x, y))
(0,types_grid/* setColorEmpty */.l$)(grid, x, y);
};
const clearCleanColoredLayer_isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.FK)(grid, x, y) && (0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, x, y));
;// CONCATENATED MODULE: ../solver/getBestRoute.ts
const getBestRoute = (grid0, snake0) => {
const grid = (0,types_grid/* copyGrid */.mi)(grid0);
const outside = createOutside(grid);
const chain = [snake0];
for (const color of extractColors(grid)) {
if (color > 1)
chain.unshift(...clearResidualColoredLayer(grid, outside, chain[0], color));
chain.unshift(...clearCleanColoredLayer(grid, outside, chain[0], color));
}
return chain.reverse();
};
const extractColors = (grid) => {
// @ts-ignore
let maxColor = Math.max(...grid.data);
return Array.from({ length: maxColor }, (_, i) => (i + 1));
};
;// CONCATENATED MODULE: ../types/__fixtures__/snake.ts
const create = (length) => (0,types_snake/* createSnakeFromCells */.yS)(Array.from({ length }, (_, i) => ({ x: i, y: -1 })));
const snake1 = create(1);
const snake3 = create(3);
const snake4 = create(4);
const snake5 = create(5);
const snake9 = create(9);
;// CONCATENATED MODULE: ../solver/getPathToPose.ts
const getPathToPose_isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.FK)(grid, x, y) || (0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, x, y));
const getPathToPose = (snake0, target, grid) => {
if ((0,types_snake/* snakeEquals */.sW)(snake0, target))
return [];
const targetCells = (0,types_snake/* snakeToCells */.HU)(target).reverse();
const snakeN = (0,types_snake/* getSnakeLength */.T$)(snake0);
const box = {
min: {
x: Math.min((0,types_snake/* getHeadX */.tN)(snake0), (0,types_snake/* getHeadX */.tN)(target)) - snakeN - 1,
y: Math.min((0,types_snake/* getHeadY */.Ap)(snake0), (0,types_snake/* getHeadY */.Ap)(target)) - snakeN - 1,
},
max: {
x: Math.max((0,types_snake/* getHeadX */.tN)(snake0), (0,types_snake/* getHeadX */.tN)(target)) + snakeN + 1,
y: Math.max((0,types_snake/* getHeadY */.Ap)(snake0), (0,types_snake/* getHeadY */.Ap)(target)) + snakeN + 1,
},
};
const [t0, ...forbidden] = targetCells;
forbidden.slice(0, 3);
const openList = [{ snake: snake0, w: 0 }];
const closeList = [];
while (openList.length) {
const o = openList.shift();
const x = (0,types_snake/* getHeadX */.tN)(o.snake);
const y = (0,types_snake/* getHeadY */.Ap)(o.snake);
if (x === t0.x && y === t0.y) {
const path = [];
let e = o;
while (e) {
path.push(e.snake);
e = e.parent;
}
path.unshift(...getTunnelPath(path[0], targetCells));
path.pop();
path.reverse();
return path;
}
for (let i = 0; i < around4.length; i++) {
const { x: dx, y: dy } = around4[i];
const nx = x + dx;
const ny = y + dy;
if (!(0,types_snake/* snakeWillSelfCollide */.J)(o.snake, dx, dy) &&
(!grid || getPathToPose_isEmptySafe(grid, nx, ny)) &&
(grid
? (0,types_grid/* isInsideLarge */.Yd)(grid, 2, nx, ny)
: box.min.x <= nx &&
nx <= box.max.x &&
box.min.y <= ny &&
ny <= box.max.y) &&
!forbidden.some((p) => p.x === nx && p.y === ny)) {
const snake = (0,types_snake/* nextSnake */.Sc)(o.snake, dx, dy);
if (!closeList.some((s) => (0,types_snake/* snakeEquals */.sW)(snake, s))) {
const w = o.w + 1;
const h = Math.abs(nx - x) + Math.abs(ny - y);
const f = w + h;
sortPush(openList, { f, w, snake, parent: o }, (a, b) => a.f - b.f);
closeList.push(snake);
}
}
}
}
};
;// CONCATENATED MODULE: ./generateContributionSnake.ts
const generateContributionSnake = async (userName, outputs, options) => {
console.log("🎣 fetching github user contribution");
const cells = await getGithubUserContribution(userName, options);
const grid = userContributionToGrid(cells);
const snake = snake4;
console.log("📡 computing best route");
const chain = getBestRoute(grid, snake);
chain.push(...getPathToPose(chain.slice(-1)[0], snake));
return Promise.all(outputs.map(async (out, i) => {
if (!out)
return;
const { format, drawOptions, animationOptions } = out;
switch (format) {
case "svg": {
console.log(`🖌 creating svg (outputs[${i}])`);
const { createSvg } = await __webpack_require__.e(/* import() */ 578).then(__webpack_require__.bind(__webpack_require__, 4578));
return createSvg(grid, cells, chain, drawOptions, animationOptions);
}
case "gif": {
console.log(`📹 creating gif (outputs[${i}])`);
const { createGif } = await Promise.all(/* import() */[__webpack_require__.e(155), __webpack_require__.e(642)]).then(__webpack_require__.bind(__webpack_require__, 3642));
return await createGif(grid, cells, chain, drawOptions, animationOptions);
}
}
}));
};
/***/ }),
/***/ 105:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ FK: () => (/* binding */ isInside),
/* harmony export */ Im: () => (/* binding */ isEmpty),
/* harmony export */ Kb: () => (/* binding */ createEmptyGrid),
/* harmony export */ Yd: () => (/* binding */ isInsideLarge),
/* harmony export */ l$: () => (/* binding */ setColorEmpty),
/* harmony export */ mi: () => (/* binding */ copyGrid),
/* harmony export */ oU: () => (/* binding */ getColor),
/* harmony export */ wW: () => (/* binding */ setColor)
/* harmony export */ });
/* unused harmony exports isGridEmpty, gridEquals */
const isInside = (grid, x, y) => x >= 0 && y >= 0 && x < grid.width && y < grid.height;
const isInsideLarge = (grid, m, x, y) => x >= -m && y >= -m && x < grid.width + m && y < grid.height + m;
const copyGrid = ({ width, height, data }) => ({
width,
height,
data: Uint8Array.from(data),
});
const getIndex = (grid, x, y) => x * grid.height + y;
const getColor = (grid, x, y) => grid.data[getIndex(grid, x, y)];
const isEmpty = (color) => color === 0;
const setColor = (grid, x, y, color) => {
grid.data[getIndex(grid, x, y)] = color || 0;
};
const setColorEmpty = (grid, x, y) => {
setColor(grid, x, y, 0);
};
/**
* return true if the grid is empty
*/
const isGridEmpty = (grid) => grid.data.every((x) => x === 0);
const gridEquals = (a, b) => a.data.every((_, i) => a.data[i] === b.data[i]);
const createEmptyGrid = (width, height) => ({
width,
height,
data: new Uint8Array(width * height),
});
/***/ }),
/***/ 777:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ Ap: () => (/* binding */ getHeadY),
/* harmony export */ HU: () => (/* binding */ snakeToCells),
/* harmony export */ J: () => (/* binding */ snakeWillSelfCollide),
/* harmony export */ Sc: () => (/* binding */ nextSnake),
/* harmony export */ T$: () => (/* binding */ getSnakeLength),
/* harmony export */ sW: () => (/* binding */ snakeEquals),
/* harmony export */ tN: () => (/* binding */ getHeadX),
/* harmony export */ yS: () => (/* binding */ createSnakeFromCells)
/* harmony export */ });
/* unused harmony export copySnake */
const getHeadX = (snake) => snake[0] - 2;
const getHeadY = (snake) => snake[1] - 2;
const getSnakeLength = (snake) => snake.length / 2;
const copySnake = (snake) => snake.slice();
const snakeEquals = (a, b) => {
for (let i = 0; i < a.length; i++)
if (a[i] !== b[i])
return false;
return true;
};
/**
* return a copy of the next snake, considering that dx, dy is the direction
*/
const nextSnake = (snake, dx, dy) => {
const copy = new Uint8Array(snake.length);
for (let i = 2; i < snake.length; i++)
copy[i] = snake[i - 2];
copy[0] = snake[0] + dx;
copy[1] = snake[1] + dy;
return copy;
};
/**
* return true if the next snake will collide with itself
*/
const snakeWillSelfCollide = (snake, dx, dy) => {
const nx = snake[0] + dx;
const ny = snake[1] + dy;
for (let i = 2; i < snake.length - 2; i += 2)
if (snake[i + 0] === nx && snake[i + 1] === ny)
return true;
return false;
};
const snakeToCells = (snake) => Array.from({ length: snake.length / 2 }, (_, i) => ({
x: snake[i * 2 + 0] - 2,
y: snake[i * 2 + 1] - 2,
}));
const createSnakeFromCells = (points) => {
const snake = new Uint8Array(points.length * 2);
for (let i = points.length; i--;) {
snake[i * 2 + 0] = points[i].x + 2;
snake[i * 2 + 1] = points[i].y + 2;
}
return snake;
};
/***/ })
};
;

323
svg-only/dist/578.index.js vendored Normal file
View File

@@ -0,0 +1,323 @@
"use strict";
exports.id = 578;
exports.ids = [578];
exports.modules = {
/***/ 4578:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
// EXPORTS
__webpack_require__.d(__webpack_exports__, {
createSvg: () => (/* binding */ createSvg)
});
// EXTERNAL MODULE: ../types/grid.ts
var types_grid = __webpack_require__(105);
// EXTERNAL MODULE: ../types/snake.ts
var types_snake = __webpack_require__(777);
;// CONCATENATED MODULE: ../svg-creator/xml-utils.ts
const h = (element, attributes) => `<${element} ${toAttribute(attributes)}/>`;
const toAttribute = (o) => Object.entries(o)
.filter(([, value]) => value !== null)
.map(([name, value]) => `${name}="${value}"`)
.join(" ");
;// CONCATENATED MODULE: ../svg-creator/css-utils.ts
const percent = (x) => parseFloat((x * 100).toFixed(2)).toString() + "%";
const mergeKeyFrames = (keyframes) => {
const s = new Map();
for (const { t, style } of keyframes) {
s.set(style, [...(s.get(style) ?? []), t]);
}
return Array.from(s.entries())
.map(([style, ts]) => ({ style, ts }))
.sort((a, b) => a.ts[0] - b.ts[0]);
};
/**
* generate the keyframe animation from a list of keyframe
*/
const createAnimation = (name, keyframes) => `@keyframes ${name}{` +
mergeKeyFrames(keyframes)
.map(({ style, ts }) => ts.map(percent).join(",") + `{${style}}`)
.join("") +
"}";
/**
* remove white spaces
*/
const minifyCss = (css) => css
.replace(/\s+/g, " ")
.replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, ""))
.replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, ""))
.replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, ""))
.replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, ""))
.replace(/\;\s*\}/g, "}")
.trim();
;// CONCATENATED MODULE: ../svg-creator/snake.ts
const lerp = (k, a, b) => (1 - k) * a + k * b;
const createSnake = (chain, { sizeCell, sizeDot }, duration) => {
const snakeN = chain[0] ? (0,types_snake/* getSnakeLength */.T$)(chain[0]) : 0;
const snakeParts = Array.from({ length: snakeN }, () => []);
for (const snake of chain) {
const cells = (0,types_snake/* snakeToCells */.HU)(snake);
for (let i = cells.length; i--;)
snakeParts[i].push(cells[i]);
}
const svgElements = snakeParts.map((_, i, { length }) => {
// compute snake part size
const dMin = sizeDot * 0.8;
const dMax = sizeCell * 0.9;
const iMax = Math.min(4, length);
const u = (1 - Math.min(i, iMax) / iMax) ** 2;
const s = lerp(u, dMin, dMax);
const m = (sizeCell - s) / 2;
const r = Math.min(4.5, (4 * s) / sizeDot);
return h("rect", {
class: `s s${i}`,
x: m.toFixed(1),
y: m.toFixed(1),
width: s.toFixed(1),
height: s.toFixed(1),
rx: r.toFixed(1),
ry: r.toFixed(1),
});
});
const transform = ({ x, y }) => `transform:translate(${x * sizeCell}px,${y * sizeCell}px)`;
const styles = [
`.s{
shape-rendering: geometricPrecision;
fill: var(--cs);
animation: none linear ${duration}ms infinite
}`,
...snakeParts.map((positions, i) => {
const id = `s${i}`;
const animationName = id;
const keyframes = removeInterpolatedPositions(positions.map((tr, i, { length }) => ({ ...tr, t: i / length }))).map(({ t, ...p }) => ({ t, style: transform(p) }));
return [
createAnimation(animationName, keyframes),
`.s.${id}{
${transform(positions[0])};
animation-name: ${animationName}
}`,
];
}),
].flat();
return { svgElements, styles };
};
const removeInterpolatedPositions = (arr) => arr.filter((u, i, arr) => {
if (i - 1 < 0 || i + 1 >= arr.length)
return true;
const a = arr[i - 1];
const b = arr[i + 1];
const ex = (a.x + b.x) / 2;
const ey = (a.y + b.y) / 2;
// return true;
return !(Math.abs(ex - u.x) < 0.01 && Math.abs(ey - u.y) < 0.01);
});
;// CONCATENATED MODULE: ../svg-creator/grid.ts
const createGrid = (cells, { sizeDotBorderRadius, sizeDot, sizeCell }, duration) => {
const svgElements = [];
const styles = [
`.c{
shape-rendering: geometricPrecision;
fill: var(--ce);
stroke-width: 1px;
stroke: var(--cb);
animation: none ${duration}ms linear infinite;
width: ${sizeDot}px;
height: ${sizeDot}px;
}`,
];
let i = 0;
for (const { x, y, color, t } of cells) {
const id = t && "c" + (i++).toString(36);
const m = (sizeCell - sizeDot) / 2;
if (t !== null && id) {
const animationName = id;
styles.push(createAnimation(animationName, [
{ t: t - 0.0001, style: `fill:var(--c${color})` },
{ t: t + 0.0001, style: `fill:var(--ce)` },
{ t: 1, style: `fill:var(--ce)` },
]), `.c.${id}{
fill: var(--c${color});
animation-name: ${animationName}
}`);
}
svgElements.push(h("rect", {
class: ["c", id].filter(Boolean).join(" "),
x: x * sizeCell + m,
y: y * sizeCell + m,
rx: sizeDotBorderRadius,
ry: sizeDotBorderRadius,
}));
}
return { svgElements, styles };
};
;// CONCATENATED MODULE: ../svg-creator/stack.ts
const createStack = (cells, { sizeDot }, width, y, duration) => {
const svgElements = [];
const styles = [
`.u{
transform-origin: 0 0;
transform: scale(0,1);
animation: none linear ${duration}ms infinite;
}`,
];
const stack = cells
.slice()
.filter((a) => a.t !== null)
.sort((a, b) => a.t - b.t);
const blocks = [];
stack.forEach(({ color, t }) => {
const latest = blocks[blocks.length - 1];
if (latest?.color === color)
latest.ts.push(t);
else
blocks.push({ color, ts: [t] });
});
const m = width / stack.length;
let i = 0;
let nx = 0;
for (const { color, ts } of blocks) {
const id = "u" + (i++).toString(36);
const animationName = id;
const x = (nx * m).toFixed(1);
nx += ts.length;
svgElements.push(h("rect", {
class: `u ${id}`,
height: sizeDot,
width: (ts.length * m + 0.6).toFixed(1),
x,
y,
}));
styles.push(createAnimation(animationName, [
...ts
.map((t, i, { length }) => [
{ scale: i / length, t: t - 0.0001 },
{ scale: (i + 1) / length, t: t + 0.0001 },
])
.flat(),
{ scale: 1, t: 1 },
].map(({ scale, t }) => ({
t,
style: `transform:scale(${scale.toFixed(3)},1)`,
}))), `.u.${id} {
fill: var(--c${color});
animation-name: ${animationName};
transform-origin: ${x}px 0
}
`);
}
return { svgElements, styles };
};
;// CONCATENATED MODULE: ../svg-creator/index.ts
const getCellsFromGrid = ({ width, height }) => Array.from({ length: width }, (_, x) => Array.from({ length: height }, (_, y) => ({ x, y }))).flat();
const createLivingCells = (grid0, chain, cells) => {
const livingCells = (cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
x,
y,
t: null,
color: (0,types_grid/* getColor */.oU)(grid0, x, y),
}));
const grid = (0,types_grid/* copyGrid */.mi)(grid0);
for (let i = 0; i < chain.length; i++) {
const snake = chain[i];
const x = (0,types_snake/* getHeadX */.tN)(snake);
const y = (0,types_snake/* getHeadY */.Ap)(snake);
if ((0,types_grid/* isInside */.FK)(grid, x, y) && !(0,types_grid/* isEmpty */.Im)((0,types_grid/* getColor */.oU)(grid, x, y))) {
(0,types_grid/* setColorEmpty */.l$)(grid, x, y);
const cell = livingCells.find((c) => c.x === x && c.y === y);
cell.t = i / chain.length;
}
}
return livingCells;
};
const createSvg = (grid, cells, chain, drawOptions, animationOptions) => {
const width = (grid.width + 2) * drawOptions.sizeCell;
const height = (grid.height + 5) * drawOptions.sizeCell;
const duration = animationOptions.frameDuration * chain.length;
const livingCells = createLivingCells(grid, chain, cells);
const elements = [
createGrid(livingCells, drawOptions, duration),
createStack(livingCells, drawOptions, grid.width * drawOptions.sizeCell, (grid.height + 2) * drawOptions.sizeCell, duration),
createSnake(chain, drawOptions, duration),
];
const viewBox = [
-drawOptions.sizeCell,
-drawOptions.sizeCell * 2,
width,
height,
].join(" ");
const style = generateColorVar(drawOptions) +
elements
.map((e) => e.styles)
.flat()
.join("\n");
const svg = [
h("svg", {
viewBox,
width,
height,
xmlns: "http://www.w3.org/2000/svg",
}).replace("/>", ">"),
"<desc>",
"Generated with https://github.com/Platane/snk",
"</desc>",
"<style>",
optimizeCss(style),
"</style>",
...elements.map((e) => e.svgElements).flat(),
"</svg>",
].join("");
return optimizeSvg(svg);
};
const optimizeCss = (css) => minifyCss(css);
const optimizeSvg = (svg) => svg;
const generateColorVar = (drawOptions) => `
:root {
--cb: ${drawOptions.colorDotBorder};
--cs: ${drawOptions.colorSnake};
--ce: ${drawOptions.colorEmpty};
${Object.entries(drawOptions.colorDots)
.map(([i, color]) => `--c${i}:${color};`)
.join("")}
}
` +
(drawOptions.dark
? `
@media (prefers-color-scheme: dark) {
:root {
--cb: ${drawOptions.dark.colorDotBorder || drawOptions.colorDotBorder};
--cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake};
--ce: ${drawOptions.dark.colorEmpty};
${Object.entries(drawOptions.dark.colorDots)
.map(([i, color]) => `--c${i}:${color};`)
.join("")}
}
}
`
: "");
/***/ })
};
;

231
svg-only/dist/642.index.js vendored Normal file
View File

@@ -0,0 +1,231 @@
"use strict";
exports.id = 642;
exports.ids = [642];
exports.modules = {
/***/ 3642:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
// EXPORTS
__webpack_require__.d(__webpack_exports__, {
createGif: () => (/* binding */ createGif)
});
// EXTERNAL MODULE: external "fs"
var external_fs_ = __webpack_require__(9896);
var external_fs_default = /*#__PURE__*/__webpack_require__.n(external_fs_);
// EXTERNAL MODULE: external "path"
var external_path_ = __webpack_require__(6928);
var external_path_default = /*#__PURE__*/__webpack_require__.n(external_path_);
// EXTERNAL MODULE: external "child_process"
var external_child_process_ = __webpack_require__(5317);
// EXTERNAL MODULE: external "canvas"
var external_canvas_ = __webpack_require__(9919);
// EXTERNAL MODULE: ../types/grid.ts
var types_grid = __webpack_require__(105);
;// CONCATENATED MODULE: ../draw/pathRoundedRect.ts
const pathRoundedRect_pathRoundedRect = (ctx, width, height, borderRadius) => {
ctx.moveTo(borderRadius, 0);
ctx.arcTo(width, 0, width, height, borderRadius);
ctx.arcTo(width, height, 0, height, borderRadius);
ctx.arcTo(0, height, 0, 0, borderRadius);
ctx.arcTo(0, 0, width, 0, borderRadius);
};
;// CONCATENATED MODULE: ../draw/drawGrid.ts
const drawGrid_drawGrid = (ctx, grid, cells, o) => {
for (let x = grid.width; x--;)
for (let y = grid.height; y--;) {
if (!cells || cells.some((c) => c.x === x && c.y === y)) {
const c = (0,types_grid/* getColor */.oU)(grid, x, y);
// @ts-ignore
const color = !c ? o.colorEmpty : o.colorDots[c];
ctx.save();
ctx.translate(x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2);
ctx.fillStyle = color;
ctx.strokeStyle = o.colorDotBorder;
ctx.lineWidth = 1;
ctx.beginPath();
pathRoundedRect_pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeDotBorderRadius);
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.restore();
}
}
};
;// CONCATENATED MODULE: ../draw/drawSnake.ts
const drawSnake_drawSnake = (ctx, snake, o) => {
const cells = snakeToCells(snake);
for (let i = 0; i < cells.length; i++) {
const u = (i + 1) * 0.6;
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u);
ctx.beginPath();
pathRoundedRect(ctx, o.sizeCell - u * 2, o.sizeCell - u * 2, (o.sizeCell - u * 2) * 0.25);
ctx.fill();
ctx.restore();
}
};
const lerp = (k, a, b) => (1 - k) * a + k * b;
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const drawSnakeLerp = (ctx, snake0, snake1, k, o) => {
const m = 0.8;
const n = snake0.length / 2;
for (let i = 0; i < n; i++) {
const u = (i + 1) * 0.6 * (o.sizeCell / 16);
const a = (1 - m) * (i / Math.max(n - 1, 1));
const ki = clamp((k - a) / m, 0, 1);
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
ctx.beginPath();
pathRoundedRect_pathRoundedRect(ctx, o.sizeCell - u * 2, o.sizeCell - u * 2, (o.sizeCell - u * 2) * 0.25);
ctx.fill();
ctx.restore();
}
};
;// CONCATENATED MODULE: ../draw/drawWorld.ts
const drawStack = (ctx, stack, max, width, o) => {
ctx.save();
const m = width / max;
for (let i = 0; i < stack.length; i++) {
// @ts-ignore
ctx.fillStyle = o.colorDots[stack[i]];
ctx.fillRect(i * m, 0, m + width * 0.005, 10);
}
ctx.restore();
};
const drawWorld = (ctx, grid, cells, snake, stack, o) => {
ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, cells, o);
drawSnake(ctx, snake, o);
ctx.restore();
ctx.save();
ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell);
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
ctx.restore();
// ctx.save();
// ctx.translate(o.sizeCell + 100, (grid.height + 4) * o.sizeCell + 100);
// ctx.scale(0.6, 0.6);
// drawCircleStack(ctx, stack, o);
// ctx.restore();
};
const drawLerpWorld = (ctx, grid, cells, snake0, snake1, stack, k, o) => {
ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid_drawGrid(ctx, grid, cells, o);
drawSnakeLerp(ctx, snake0, snake1, k, o);
ctx.translate(0, (grid.height + 2) * o.sizeCell);
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
ctx.restore();
};
const getCanvasWorldSize = (grid, o) => {
const width = o.sizeCell * (grid.width + 2);
const height = o.sizeCell * (grid.height + 4) + 30;
return { width, height };
};
// EXTERNAL MODULE: ../types/snake.ts
var types_snake = __webpack_require__(777);
;// CONCATENATED MODULE: ../solver/step.ts
const step = (grid, stack, snake) => {
const x = (0,types_snake/* getHeadX */.tN)(snake);
const y = (0,types_snake/* getHeadY */.Ap)(snake);
const color = (0,types_grid/* getColor */.oU)(grid, x, y);
if ((0,types_grid/* isInside */.FK)(grid, x, y) && !(0,types_grid/* isEmpty */.Im)(color)) {
stack.push(color);
(0,types_grid/* setColorEmpty */.l$)(grid, x, y);
}
};
// EXTERNAL MODULE: ../../node_modules/tmp/lib/tmp.js
var tmp = __webpack_require__(2644);
// EXTERNAL MODULE: external "gifsicle"
var external_gifsicle_ = __webpack_require__(5667);
var external_gifsicle_default = /*#__PURE__*/__webpack_require__.n(external_gifsicle_);
// EXTERNAL MODULE: ../../node_modules/gif-encoder-2/index.js
var gif_encoder_2 = __webpack_require__(1680);
var gif_encoder_2_default = /*#__PURE__*/__webpack_require__.n(gif_encoder_2);
;// CONCATENATED MODULE: ../gif-creator/index.ts
// @ts-ignore
const withTmpDir = async (handler) => {
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
unsafeCleanup: true,
});
try {
return await handler(dir);
}
finally {
cleanUp();
}
};
const createGif = async (grid0, cells, chain, drawOptions, animationOptions) => withTmpDir(async (dir) => {
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
const canvas = (0,external_canvas_.createCanvas)(width, height);
const ctx = canvas.getContext("2d");
const grid = (0,types_grid/* copyGrid */.mi)(grid0);
const stack = [];
const encoder = new (gif_encoder_2_default())(width, height, "neuquant", true);
encoder.setRepeat(0);
encoder.setDelay(animationOptions.frameDuration);
encoder.start();
for (let i = 0; i < chain.length; i += 1) {
const snake0 = chain[i];
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
step(grid, stack, snake0);
for (let k = 0; k < animationOptions.step; k++) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k / animationOptions.step, drawOptions);
encoder.addFrame(ctx);
}
}
const outFileName = external_path_default().join(dir, "out.gif");
const optimizedFileName = external_path_default().join(dir, "out.optimized.gif");
encoder.finish();
external_fs_default().writeFileSync(outFileName, encoder.out.getData());
(0,external_child_process_.execFileSync)((external_gifsicle_default()), [
//
"--optimize=3",
"--color-method=diversity",
"--colors=18",
outFileName,
["--output", optimizedFileName],
].flat());
return new Uint8Array(external_fs_default().readFileSync(optimizedFileName));
});
/***/ })
};
;

74804
svg-only/dist/index.js vendored

File diff suppressed because one or more lines are too long

6584
yarn.lock

File diff suppressed because it is too large Load Diff