Compare commits

...

162 Commits
v0.0.11 ... v1

Author SHA1 Message Date
release bot
1018f7a937 📦 1.1.3 2022-04-08 23:18:20 +00:00
platane
4edf90f41b 👷 2022-04-09 01:16:14 +02:00
platane
faf76e6eb6 👷 2022-04-09 00:23:17 +02:00
platane
5bede02e06 ⬆️ bump tooling dependencies 2022-04-09 00:03:30 +02:00
platane
4f7ff9bc90 📓 2022-04-08 23:25:09 +02:00
platane
b0d592375a 👷 2022-04-08 23:25:09 +02:00
platane
672fe6bf0e ⬆️ bump node-fetch 2022-04-08 23:02:26 +02:00
platane
829a59da98 🚀 demo page workerize load 2022-03-25 10:37:49 +01:00
platane
58176f658e ♻️ use fancy new typescript utils 2022-03-25 10:32:08 +01:00
platane
9c881735b7 🚀 add <desc> metadata to svg 2022-03-25 08:56:05 +01:00
platane
3c697c687e ♻️ clean up 2022-03-24 14:54:28 +01:00
Platane
825e58e5fd 📦 1.1.2 2022-03-24 12:14:48 +00:00
platane
9232c14971 👷 fix release script 2022-03-24 13:11:04 +01:00
Platane
cd3320efff 📦 1.1.1 2022-03-24 12:05:22 +00:00
platane
553d8d8efa 📓 2022-03-24 13:00:13 +01:00
platane
e80a44ca5f 🔨 fix svg rounded square 2022-03-24 12:56:26 +01:00
platane
4ced502e11 📓 update readme 2022-03-24 12:43:09 +01:00
Platane
0374e20a50 📦 1.1.0 2022-03-24 11:25:21 +00:00
platane
7ba88d1fbd 📓 2022-03-24 12:21:27 +01:00
Platane
909a9c7fce 📦 1.0.2-rc.6 2022-03-24 11:03:14 +00:00
platane
e1dcae75b9 👷 2022-03-24 11:58:31 +01:00
Platane
5df41911e6 📦 1.0.2-rc.5 2022-03-24 10:53:45 +00:00
platane
c9b130d9da 🔨 try async import 2022-03-24 11:51:16 +01:00
Platane
05df7cb642 📦 1.0.2-rc.4 2022-03-24 10:35:37 +00:00
Platane
309795a2a5 📦 v1.0.2-rc.4 2022-03-24 10:28:00 +00:00
platane
e79b3bb634 👷 2022-03-24 11:25:50 +01:00
Platane
7c0522bfa8 📦 1.0.2-rc.3 2022-03-24 10:19:14 +00:00
platane
be91c43c71 👷 2022-03-24 11:13:40 +01:00
platane
67c66ac8ae 👷 2022-03-24 11:04:01 +01:00
platane
c97378f175 👷 2022-03-24 10:59:43 +01:00
platane
b7298f7ff7 ⬆️ bump dependencies 2022-03-23 16:57:51 +01:00
platane
b4e8fc83ef 🚿 clena up and bump dependencies 2022-02-07 13:28:45 +01:00
platane
4e9c1ff670 ⬆️ bump dependencies 2021-11-18 17:47:00 +01:00
platane
c409c8cf1e 🚀 tweak gif creation 2021-10-05 09:40:45 +02:00
platane
c2e503311a 👷 update action 2021-10-05 09:40:45 +02:00
platane
a9a9e29cf2 🚀 update Dockerfile 2021-10-05 09:40:45 +02:00
platane
2844b095f3 🚀 use gifsicle binaries and node gif encoder 2021-10-05 09:40:45 +02:00
platane
1da950d886 🚀 tweak benchmark 2021-10-05 09:27:01 +02:00
platane
74418879a4 🚀 improve gif creation benchmark 2021-10-05 08:58:18 +02:00
platane
bedc8d0e31 🔨 remove unsafe date parsing options from getGithubUserContribution ( thanks @Sutil ) 2021-10-04 23:21:30 +02:00
platane
859fd7a695 👷 bump action dependencies 2021-10-04 23:04:39 +02:00
platane
45fc325241 🚀 use @actions/core to output values 2021-10-04 15:51:14 +02:00
platane
aa6a4782ee ⬆️ bump dependencies 2021-10-04 09:41:55 +02:00
Platane
6823a283fd Update README.md 2021-07-30 11:41:09 +02:00
fz6m
4ea2ed94b8 docs: dynamic get user name 2021-07-12 16:40:55 +02:00
platane
81d9d01a78 📓update readme 2021-07-08 09:52:45 +02:00
platane
37e9dde1a3 📓update readme 2021-07-08 09:41:48 +02:00
platane
bd1472c5f4 📓update readme 2021-07-08 09:23:09 +02:00
platane
10050246e9 📓update readme 2021-07-08 09:19:39 +02:00
platane
e3edbc05d5 ⬆️ bump node to 16, bump actions 2021-07-08 09:18:45 +02:00
platane
4e2826c095 ⬆️ bump dependencies 2021-07-03 14:56:35 +02:00
platane
dfa1298fe4 📓update readme 2021-06-15 18:17:02 +02:00
platane
5eafc13f47 ⬆️bump webpack 2021-06-13 08:46:09 +02:00
platane
3ac539cf13 ⬆️bump typescript 2021-06-13 08:32:58 +02:00
platane
244b2fe6d4 ⬆️bump dependencies 2021-06-13 08:29:12 +02:00
platane
5299f99928 ⬆️ bump prettier 2021-06-13 08:20:44 +02:00
platane
5f9f03e248 📓update readme 2021-06-13 08:20:44 +02:00
platane
4ea8673034 ⬆️ bump dependencies 2021-02-05 07:40:49 +01:00
platane
17b852aab5 🔨fix github contribution chart crawler 2021-02-04 19:09:31 +01:00
platane
9b0776b203 ⬆️ bump dependencies 2021-01-12 01:04:31 +01:00
platane
1ebe73cf90 🚀svg dark mode 2021-01-12 00:56:50 +01:00
platane
a3f79b9ca4 🔨 rename package compute -> solver 2021-01-11 23:50:00 +01:00
platane
fd7202c05e 🚀 download link 2020-12-02 22:17:32 +01:00
platane
17db3fff68 🚀 add svg generation for interactive demo 2020-11-30 17:57:49 +01:00
platane
9e15fb3633 🔨 improve github user contribution test 2020-11-30 17:57:27 +01:00
platane
e5c3fef1ff 🔨 fix algorithm priority 2020-11-30 17:57:03 +01:00
platane
55758d606c 🚀 github user contribution api for dev 2020-11-30 17:56:34 +01:00
platane
15fbf4bff6 🚀 create output directory 2020-11-30 11:26:40 +01:00
platane
fef280dceb ⬆️ bump dependencies 2020-11-30 11:00:44 +01:00
platane
485b70d30b 🚀 refactor getgithubcontribution 2020-11-30 10:56:18 +01:00
platane
57a7e7cf36 🔨 fix github contribution 2020-11-27 09:58:23 +01:00
platane
55feaa46bc 🚀 silent console.log for test 2020-11-27 09:46:08 +01:00
platane
f52b295206 🔨 fix github user contribution 2020-11-27 09:46:08 +01:00
platane
cbb4ebd010 🚀 improve svg stack generation 2020-11-05 11:03:14 +01:00
platane
b71cd68bac 🚀 optimize svg with csso 2020-11-04 10:19:19 +01:00
platane
817362d1dd 🚀 improve svg generation 2020-11-04 09:17:19 +01:00
platane
24e7a1ceec ⬆️ bump dependencies 2020-11-03 23:59:40 +01:00
platane
e61a38f66a 📓 add readme s 2020-11-03 23:39:44 +01:00
platane
cd458e61d3 🚀 add test for path to pose + fix path to pose 2020-11-03 22:33:24 +01:00
platane
2d1d70a10c 🔨 fix test 2020-11-01 14:40:52 +01:00
platane
bd2e350c23 🔨 fix getPathToPose 2020-11-01 14:36:44 +01:00
platane
bb3d2bce11 🚿 split svg creator file 2020-11-01 14:06:17 +01:00
platane
686f61d725 🚀 go back to first position 2020-11-01 13:44:09 +01:00
platane
bfd53d721d 🚀 remove interpolated svg keyframes 2020-11-01 13:05:54 +01:00
platane
af5f93140e 🚀 add svg generation option to the github action 2020-11-01 01:17:12 +01:00
platane
ab861f6be5 🚀 svg creator 2020-11-01 00:52:09 +01:00
platane
cd68afe29f 🚀 svg export 2020-10-31 17:45:52 +01:00
platane
b595e7de53 🚀 imrpove algorithm 2020-10-31 17:23:19 +01:00
platane
d81ecec836 🚀 refactor algorithm 2020-10-29 23:27:08 +01:00
platane
1c6814c2fa 🔨 fix user contribution parsing 2020-10-29 20:35:28 +01:00
platane
d6c79a0e47 🔨 fix demo layout 2020-10-26 00:23:05 +01:00
platane
5740293865 🔨 prevent github contribution to fails on Mondays 2020-10-26 00:22:45 +01:00
platane
a3f590a7d2 :roclet: improve algorithm, add an intermediate phase to clean up residual cell from previous layer, before grabing the free ones 2020-10-26 00:18:50 +01:00
platane
69c3551cc5 🔨 remove debug statement 2020-10-24 14:59:05 +02:00
platane
9889966e29 🤫 vercel please stop 2020-10-24 11:58:32 +02:00
platane
3e32c45cb6 🔨 fix type issue 2020-10-24 11:55:41 +02:00
platane
4d5abad76e 🚀 improve interactive demo 2020-10-24 11:52:51 +02:00
platane
d7b90195da 🚿 clean up 2020-10-24 11:20:13 +02:00
platane
b9c67baa6a 🚀 improve clean layer 2020-10-24 11:19:49 +02:00
platane
4f9ff10741 🚀 add fuzz test 2020-10-24 11:16:19 +02:00
platane
242a28959f 🔨 fix isFree function 2020-10-24 11:16:00 +02:00
platane
b2ac63d6ef 🚀 add test on samples 2020-10-24 10:48:49 +02:00
platane
a9c2cbc763 🚀 improve demos 2020-10-24 10:48:18 +02:00
platane
64b04e9eba 🚀 report error in demo 2020-10-24 00:15:47 +02:00
platane
43aa3022af 🔨 fix typing, add default interop 2020-10-24 00:06:21 +02:00
platane
00e0c54b80 🚀 improve algorithm for enclaves 2020-10-24 00:04:18 +02:00
platane
1d24bc8a0f 🚀 improve algorithm for enclaved cell 2020-10-23 21:46:32 +02:00
platane
87766811ad 🔨 downgrade jest to fix tests 2020-10-23 21:46:32 +02:00
platane
59c83249e5 ⬆️ update dependencies 2020-10-23 20:13:05 +02:00
platane
2b403e3772 🚀 update readme 2020-10-21 01:31:05 +02:00
platane
43cee13f25 🚀 github contribution service cors 2020-10-21 01:25:33 +02:00
platane
5958a006b7 🔨 vercel 2020-10-21 01:15:43 +02:00
platane
87f9d50bb5 🚀 interactive demo 2020-10-21 01:09:34 +02:00
platane
d75d3d76e7 🚀 refactor github contribution 2020-10-20 19:43:05 +02:00
platane
0fc64a0dab 🔨 avoid using es2020 for vercel 2020-10-20 18:10:36 +02:00
platane
c4889362d3 🔨 avoid using es2020 for vercel 2020-10-20 18:04:28 +02:00
platane
4c1de148f9 🚀 add service with vercel 2020-10-20 17:22:52 +02:00
platane
a5c9eed6cc 🚿 refactor + clean up 2020-10-20 16:53:42 +02:00
platane
2e818ce425 🚀 avoid drawing cell that are not in the original contribution grid 2020-10-19 23:06:55 +02:00
platane
89e2630eec 🚀 use ts node 2020-10-16 18:54:09 +02:00
platane
5243a665b1 🚀 enlarge gif 2020-10-15 02:12:31 +02:00
platane
7e5dcb345d 🔨 fix dev script 2020-10-15 02:12:21 +02:00
platane
ee08150eff 👷 wait for docker image to be pushed before testing action 2020-10-15 02:12:04 +02:00
platane
42083b4250 🚀 tune gif margin 2020-10-15 01:51:37 +02:00
platane
99ae4e3863 👷 2020-10-15 01:08:23 +02:00
platane
f90fd34b7b 👷 2020-10-15 01:07:18 +02:00
platane
ddcb1ae97c 🚀 add emojis 2020-10-15 01:04:31 +02:00
platane
335757dc9d 👷 2020-10-15 00:32:31 +02:00
platane
40c6caa805 🔨 fix image 2020-10-15 00:25:04 +02:00
platane
6db574c4ba 🚀 less step in the gif 2020-10-15 00:23:03 +02:00
platane
523aebc4d5 👷 push on docker hub 2020-10-15 00:22:02 +02:00
platane
1e1967ef61 ⬆️ upgrade github action dependencies 2020-10-14 23:03:23 +02:00
platane
3d16c675bd 🔨 fix typing 2020-10-14 23:03:23 +02:00
platane
8f5c1969a6 🚀 faster solution 2020-10-14 23:03:23 +02:00
platane
fe821f6251 🚀 prune layer algorithm 2020-10-14 23:03:23 +02:00
platane
d7423423f8 👷 upload gif as artifact 2020-10-09 18:44:25 +02:00
platane
03396bae31 🔨 fix benchmark script 2020-10-09 14:23:56 +02:00
platane
b63a1191b4 🔨 fix demo 2020-10-09 14:21:26 +02:00
platane
a9555b092a 🚀 refactor gif creator ( a bit ) 2020-10-09 14:20:12 +02:00
platane
1f9dda0ca6 🔨 fix demo page 2020-10-09 12:44:15 +02:00
platane
202bd7cacb 🚀 gif creator benchmark 2020-10-09 12:44:15 +02:00
platane
bc18120a98 🚀 use webpack.config.ts 2020-10-09 12:23:50 +02:00
platane
bb0750e8ba 🚀 demo + spring 2020-10-09 12:23:50 +02:00
platane
16a47349be 🚀 benchmark ? 2020-10-09 12:23:50 +02:00
platane
b0784fbaca 👷 fix script 2020-10-09 12:23:50 +02:00
platane
d5bdc84680 ⬆️ bump dependencies 2020-10-09 12:23:50 +02:00
platane
9c758febe7 👷 fix script 2020-10-09 12:23:50 +02:00
platane
2125640716 👷 fix script 2020-10-09 12:23:50 +02:00
platane
64f0b872aa 🚀 refactor demo 2020-10-09 12:23:50 +02:00
platane
9b92697ef9 🚀 refactor 2020-10-09 12:23:50 +02:00
platane
8d8956229c 🚀 refactor get available routes 2020-10-09 12:23:50 +02:00
platane
2499529b1d 🚀 optimize stuff I guess 2020-08-03 22:55:14 +02:00
platane
3625bdb819 🚀 refactor getBestRoute 2020-07-30 18:33:42 +02:00
platane
9ab55aaad6 🚀 getAvailableRoute 2020-07-30 16:27:04 +02:00
platane
48d89528d5 🚀 upscale demo resolution 2020-07-21 18:16:01 +02:00
platane
e637604df1 🚀 benchmark 2020-07-21 18:15:41 +02:00
platane
1898ec16e4 🚀 improve command computation 2020-07-21 01:03:29 +02:00
platane
fd9d7dadf6 🚀 smarter snake 2020-07-21 00:34:22 +02:00
platane
73bfce908e 🔨 fix demo 2020-07-20 23:02:23 +02:00
platane
dd23c1630e 🚿 clean up 2020-07-20 22:37:58 +02:00
platane
7377068a9a 📓 add readme 2020-07-20 10:23:32 +02:00
platane
8a06b668cd 🚀 fix action 2020-07-20 10:18:24 +02:00
114 changed files with 60741 additions and 4735 deletions

View File

@@ -1,29 +0,0 @@
name: deploy
on:
push:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 14
- uses: bahmutov/npm-install@v1
- run: yarn build:demo
env:
BASE_PATHNAME: "snk"
- uses: crazy-max/ghaction-github-pages@068e494
with:
target_branch: gh-pages
build_dir: packages/demo/dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}

View File

@@ -1,20 +1,84 @@
name: main
on: [push, pull_request]
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 14
- uses: bahmutov/npm-install@v1
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: yarn type
- run: yarn lint
- run: yarn test --ci
test-benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- 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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: update action.yml to use image from local Dockerfile
run: |
sed -i "s/image: .*/image: Dockerfile/" action.yml
- name: generate-snake-game-from-github-contribution-grid
id: generate-snake
uses: ./
with:
github_user_name: platane
gif_out_path: dist/github-contribution-grid-snake.gif
svg_out_path: dist/github-contribution-grid-snake.svg
- name: ensure the generated file exists
run: |
ls dist
test -f ${{ steps.generate-snake.outputs.gif_out_path }}
test -f ${{ steps.generate-snake.outputs.svg_out_path }}
- uses: crazy-max/ghaction-github-pages@v2.5.0
with:
target_branch: output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy-ghpages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: yarn build:demo
env:
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
- uses: crazy-max/ghaction-github-pages@v2.6.0
if: success() && github.ref == 'refs/heads/master'
with:
target_branch: gh-pages
build_dir: packages/demo/dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}

85
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: release
on:
workflow_dispatch:
inputs:
version:
description: "Version"
default: "0.0.1"
required: true
type: string
description:
description: "Version description"
type: string
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build and publish the docker image
uses: docker/build-push-action@v2
id: docker-build
with:
push: true
tags: |
platane/snk:${{ github.sha }}
platane/snk:${{ github.event.inputs.version }}
- name: update action.yml to point to the newly created docker image
run: |
sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- name: build svg-only action
run: |
yarn install --frozen-lockfile
yarn build:action
rm -r svg-only/dist
mv packages/action/dist svg-only/dist
- name: bump package version
run: yarn version --no-git-tag-version --new-version ${{ github.event.inputs.version }}
- name: push new build, tag version and push
id: push-tags
run: |
VERSION=${{ github.event.inputs.version }}
git config --global user.email "bot@platane.me"
git config --global user.name "release bot"
git add package.json svg-only/dist action.yml
git commit -m "📦 $VERSION"
git tag v$VERSION
git push origin master --tags
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-2 )
git push origin --tags --force
echo ::set-output name=prerelease::false
else
echo ::set-output name=prerelease::true
fi
- uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ github.event.inputs.version }}
body: ${{ github.event.inputs.description }}
prerelease: ${{ steps.push-tags.outputs.prerelease }}

1
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
npm-debug.log*
yarn-error.log*
dist
!svg-only/dist
build

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
16

View File

@@ -1,10 +1,32 @@
FROM node:14-slim
FROM node:16-slim as builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY packages/action/dist/* ./github-contribution-grid-snake
COPY package.json yarn.lock ./
CMD ["node", "github-contribution-grid-snake/index.js"]
COPY tsconfig.json ./
COPY packages packages
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
&& yarn install --frozen-lockfile \
&& rm -r "$YARN_CACHE_FOLDER"
RUN yarn build:action
FROM node:16-slim
WORKDIR /action-release
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
&& 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/
CMD ["node", "/action-release/index.js"]

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# snk
[![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)
![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square)
![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)
Generates a snake game from a github user contributions graph
![](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg)
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.
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)
## Usage
**github action**
```yaml
- uses: Platane/snk@v1.1.0
with:
# github user name to read the contribution graph from (**required**)
# using action context var `github.repository_owner` or specified user
github_user_name: ${{ github.repository_owner }}
# path of the generated gif file
# If left empty, the gif file will not be generated
gif_out_path: dist/github-snake.gif
# path of the generated svg file
# If left empty, the svg file will not be generated
svg_out_path: dist/github-snake.svg
```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
If you are only interested in generating a svg, you can use this other faster action: `uses: Platane/snk/svg-only@v1.1.0`
**interactive demo**
<a href="https://platane.github.io/snk">
<img height="300px" src="https://user-images.githubusercontent.com/1659820/121798244-7c86d700-cc25-11eb-8c1c-b8e65556ac0d.gif" ></img>
</a>
[platane.github.io/snk](https://platane.github.io/snk)
**local**
```
npm install
npm run dev:demo
```
## Implementation
[solver algorithm](./packages/solver/README.md)

View File

@@ -1,23 +1,26 @@
name: "github-contribution-grid-snake"
description: ""
name: "generate-snake-game-from-github-contribution-grid"
description: "Generates a snake game from a github user contributions grid. Output the animation as gif or svg"
author: "platane"
outputs:
gif_out_path:
description: ""
runs:
using: "docker"
image: "Dockerfile"
args:
- ${{ inputs.github_user_name }}
- ${{ inputs.gif_out_path }}
using: docker
image: docker://platane/snk@sha256:74d02183a9a4adb8e00d9f50e6eb5035a5b6ef02644d848363ef3301235ebd1d
inputs:
github_user_name:
description: ""
description: "github user name"
required: true
gif_out_path:
description: ""
description: "path of the generated gif file. If left empty, the gif file will not be generated."
required: false
default: "./github-contribution-grid-snake.gif"
default: null
svg_out_path:
description: "path of the generated svg file. If left empty, the svg file will not be generated."
required: false
default: null
outputs:
gif_out_path:
description: "path of the generated gif"
svg_out_path:
description: "path of the generated svg"

View File

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

View File

@@ -1,3 +0,0 @@
!dist
!dist/build
out.gif

15
packages/action/README.md Normal file
View File

@@ -0,0 +1,15 @@
# @snk/action
Contains the github action code.
## Implementation
### Docker
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).
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

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1,5 +1,19 @@
import * as fs from "fs";
import * as path from "path";
import { generateContributionSnake } from "../generateContributionSnake";
generateContributionSnake("platane").then((buffer) => {
process.stdout.write(buffer);
});
(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

@@ -0,0 +1,38 @@
import * as fs from "fs";
import * as path from "path";
import { generateContributionSnake } from "../generateContributionSnake";
jest.setTimeout(2 * 60 * 1000);
const silent = (handler: () => void | Promise<void>) => async () => {
const originalConsoleLog = console.log;
console.log = () => undefined;
try {
return await handler();
} finally {
console.log = originalConsoleLog;
}
};
it(
"should generate contribution snake",
silent(async () => {
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
console.log = () => undefined;
const buffer = await generateContributionSnake("platane", {
svg: true,
gif: true,
});
expect(buffer.svg).toBeDefined();
expect(buffer.gif).toBeDefined();
console.log("💾 writing to", outputSvg);
fs.writeFileSync(outputSvg, buffer.svg);
console.log("💾 writing to", outputGif);
fs.writeFileSync(outputGif, buffer.gif);
})
);

View File

@@ -1,56 +1,53 @@
import { getGithubUserContribution, Cell } from "@snk/github-user-contribution";
import { generateEmptyGrid } from "@snk/compute/generateGrid";
import { setColor } from "@snk/compute/grid";
import { computeBestRun } from "@snk/compute";
import { createGif } from "../gif-creator";
import { getGithubUserContribution } from "@snk/github-user-contribution";
import { userContributionToGrid } from "./userContributionToGrid";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { snake4 } from "@snk/types/__fixtures__/snake";
import { getPathToPose } from "@snk/solver/getPathToPose";
export const userContributionToGrid = (cells: Cell[]) => {
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
const grid = generateEmptyGrid(width, height);
for (const c of cells) setColor(grid, c.x, c.y, c.k === 0 ? null : c.k);
return grid;
};
export const generateContributionSnake = async (userName: string) => {
export const generateContributionSnake = async (
userName: string,
format: { svg?: boolean; gif?: boolean }
) => {
console.log("🎣 fetching github user contribution");
const { cells, colorScheme } = await getGithubUserContribution(userName);
const grid0 = userContributionToGrid(cells);
const snake0 = [
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
const grid = userContributionToGrid(cells, colorScheme);
const snake = snake4;
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: colorScheme,
colorDots: colorScheme as any,
colorEmpty: colorScheme[0],
colorSnake: "purple",
cells,
dark: {
colorEmpty: "#161b22",
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
},
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { frameDuration: 100, step: 1 };
const gifOptions = { delay: 10 };
console.log("📡 computing best route");
const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
const commands = computeBestRun(grid0, snake0, gameOptions);
const output: Record<string, Buffer | string> = {};
const buffer = await createGif(
grid0,
snake0,
commands,
drawOptions,
gameOptions,
gifOptions
);
if (format.gif) {
console.log("📹 creating gif");
const { createGif } = await import("@snk/gif-creator");
output.gif = await createGif(grid, chain, drawOptions, gifOptions);
}
return buffer;
if (format.svg) {
console.log("🖌 creating svg");
const { createSvg } = await import("@snk/svg-creator");
output.svg = createSvg(grid, chain, drawOptions, gifOptions);
}
return output;
};

View File

@@ -1,19 +1,32 @@
import * as fs from "fs";
import * as path from "path";
import * as core from "@actions/core";
import { generateContributionSnake } from "./generateContributionSnake";
(async () => {
try {
console.log(core.getInput("user_name"));
console.log(core.getInput("gif_out_path"));
console.log("--");
console.log(process.cwd());
console.log("--");
console.log(fs.readdirSync(process.cwd()));
const userName = core.getInput("github_user_name");
const format = {
svg: core.getInput("svg_out_path"),
gif: core.getInput("gif_out_path"),
};
const buffer = await generateContributionSnake(core.getInput("user_name"));
fs.writeFileSync(core.getInput("gif_out_path"), buffer);
} catch (e) {
const { svg, gif } = await generateContributionSnake(
userName,
format as any
);
if (svg) {
fs.mkdirSync(path.dirname(format.svg), { recursive: true });
fs.writeFileSync(format.svg, svg);
core.setOutput("svg_out_path", format.svg);
}
if (gif) {
fs.mkdirSync(path.dirname(format.gif), { recursive: true });
fs.writeFileSync(format.gif, gif);
core.setOutput("gif_out_path", format.gif);
}
} catch (e: any) {
core.setFailed(`Action failed with "${e.message}"`);
}
})();

View File

@@ -2,15 +2,19 @@
"name": "@snk/action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "1.2.4",
"@actions/core": "1.6.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/svg-creator": "1.0.0",
"@snk/types": "1.0.0"
},
"devDependencies": {
"@zeit/ncc": "0.22.3"
"@vercel/ncc": "0.24.1",
"ts-node": "10.7.0"
},
"scripts": {
"build": "ncc build ./index.ts --out dist",
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts",
"dev": "ts-node __tests__/dev.ts"
}
}

View File

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

View File

@@ -1,24 +0,0 @@
import { snakeSelfCollide } from "../snake";
test.each([
[[{ x: 0, y: 0 }], false],
[
[
{ x: 0, y: 0 },
{ x: 0, y: 0 },
],
true,
],
[
[
{ x: 1, y: 7 },
{ x: 0, y: 6 },
{ x: 2, y: 8 },
{ x: 1, y: 7 },
{ x: 3, y: 9 },
],
true,
],
])("should report snake collision", (snake, collide) => {
expect(snakeSelfCollide(snake)).toBe(collide);
});

View File

@@ -1,94 +0,0 @@
import { step } from "../step";
import { generateEmptyGrid } from "../generateGrid";
import { around4 } from "../point";
import { setColor, getColor } from "../grid";
it("should move snake", () => {
const grid = generateEmptyGrid(4, 3);
const snake = [{ x: 1, y: 1 }];
const direction = around4[0];
const stack: number[] = [];
const options = { maxSnakeLength: 5 };
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 2, y: 1 },
{ x: 1, y: 1 },
]);
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 3, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 1 },
]);
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 4, y: 1 },
{ x: 3, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 1 },
]);
});
it("should move short snake", () => {
const grid = generateEmptyGrid(8, 3);
const snake = [{ x: 1, y: 1 }];
const direction = around4[0];
const stack: number[] = [];
const options = { maxSnakeLength: 3 };
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 2, y: 1 },
{ x: 1, y: 1 },
]);
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 3, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 1 },
]);
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 4, y: 1 },
{ x: 3, y: 1 },
{ x: 2, y: 1 },
]);
step(grid, snake, stack, direction, options);
expect(snake).toEqual([
{ x: 5, y: 1 },
{ x: 4, y: 1 },
{ x: 3, y: 1 },
]);
});
it("should pick up fruit", () => {
const grid = generateEmptyGrid(4, 3);
const snake = [{ x: 1, y: 1 }];
const direction = around4[0];
const stack: number[] = [];
const options = { maxSnakeLength: 2 };
setColor(grid, 3, 1, 9);
step(grid, snake, stack, direction, options);
expect(getColor(grid, 3, 1)).toBe(9);
expect(stack).toEqual([]);
step(grid, snake, stack, direction, options);
expect(getColor(grid, 3, 1)).toBe(null);
expect(stack).toEqual([9]);
});

View File

@@ -1,27 +0,0 @@
import { Grid, Color } from "./grid";
const rand = (a: number, b: number) => Math.floor(Math.random() * (b - a)) + a;
export const generateEmptyGrid = (width: number, height: number) =>
generateGrid(width, height, { colors: [], emptyP: 1 });
export const generateGrid = (
width: number,
height: number,
options: { colors: Color[]; emptyP: number } = {
colors: [1, 2, 3],
emptyP: 2,
}
): Grid => {
const g = {
width,
height,
data: Array.from({ length: width * height }, () => {
const x = rand(-options.emptyP, options.colors.length);
return x < 0 ? null : options.colors[x];
}),
};
return g;
};

View File

@@ -1,30 +0,0 @@
export type Color = number;
export type Grid = {
width: number;
height: number;
data: (Color | null)[];
};
export const getIndex = (grid: Grid, x: number, y: number) =>
x * grid.height + y;
export const isInside = (grid: Grid, x: number, y: number) =>
x >= 0 && y >= 0 && x < grid.width && y < grid.height;
export const isInsideLarge = (grid: Grid, m: number, x: number, y: number) =>
x >= -m && y >= -m && x < grid.width + m && y < grid.height + m;
export const getColor = (grid: Grid, x: number, y: number) =>
grid.data[getIndex(grid, x, y)];
export const copyGrid = (grid: Grid) => ({ ...grid, data: grid.data.slice() });
export const setColor = (
grid: Grid,
x: number,
y: number,
color: Color | null
) => {
grid.data[getIndex(grid, x, y)] = color;
};

View File

@@ -1,44 +0,0 @@
import { Grid, Color, copyGrid, isInsideLarge } from "./grid";
import { Point, around4 } from "./point";
import { stepSnake, step } from "./step";
import { copySnake, snakeSelfCollide } from "./snake";
const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === null);
export const computeBestRun = (
grid: Grid,
snake: Point[],
options: { maxSnakeLength: number }
) => {
const g = copyGrid(grid);
const s = copySnake(snake);
const q: Color[] = [];
const commands: Point[] = [];
let u = 500;
while (!isGridEmpty(g) && u-- > 0) {
let direction;
for (let k = 10; k--; ) {
direction = around4[Math.floor(Math.random() * around4.length)];
const sn = copySnake(s);
stepSnake(sn, direction, options);
if (isInsideLarge(g, 1, sn[0].x, sn[0].y) && !snakeSelfCollide(sn)) {
break;
} else {
direction = undefined;
}
}
if (direction !== undefined) {
step(g, s, q, direction, options);
commands.push(direction);
}
}
return commands;
};

View File

@@ -1,4 +0,0 @@
{
"name": "@snk/compute",
"version": "1.0.0"
}

View File

@@ -1,24 +0,0 @@
import { Point } from "./point";
export const snakeSelfCollideNext = (
snake: Point[],
direction: Point,
options: { maxSnakeLength: number }
) => {
const hx = snake[0].x + direction.x;
const hy = snake[0].y + direction.y;
for (let i = 0; i < Math.min(options.maxSnakeLength, snake.length); i++)
if (snake[i].x === hx && snake[i].y === hy) return true;
return false;
};
export const snakeSelfCollide = (snake: Point[]) => {
for (let i = 1; i < snake.length; i++)
if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true;
return false;
};
export const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));

View File

@@ -1,48 +0,0 @@
import { Grid, Color, getColor, isInside, setColor } from "./grid";
import { Point } from "./point";
const moveSnake = (snake: Point[], headx: number, heady: number) => {
for (let k = snake.length - 1; k > 0; k--) {
snake[k].x = snake[k - 1].x;
snake[k].y = snake[k - 1].y;
}
snake[0].x = headx;
snake[0].y = heady;
};
export const stepSnake = (
snake: Point[],
direction: Point,
options: { maxSnakeLength: number }
) => {
const headx = snake[0].x + direction.x;
const heady = snake[0].y + direction.y;
if (snake.length === options.maxSnakeLength) {
moveSnake(snake, headx, heady);
} else {
snake.unshift({ x: headx, y: heady });
}
};
export const stepPicking = (grid: Grid, snake: Point[], stack: Color[]) => {
if (isInside(grid, snake[0].x, snake[0].y)) {
const c = getColor(grid, snake[0].x, snake[0].y);
if (c) {
setColor(grid, snake[0].x, snake[0].y, null);
stack.push(c);
}
}
};
export const step = (
grid: Grid,
snake: Point[],
stack: Color[],
direction: Point,
options: { maxSnakeLength: number }
) => {
stepSnake(snake, direction, options);
stepPicking(grid, snake, stack);
};

View File

@@ -1 +0,0 @@
webpack.config.js

3
packages/demo/README.md Normal file
View File

@@ -0,0 +1,3 @@
# @snk/demo
Contains various demo to test and validate some pieces of the algorithm.

98
packages/demo/canvas.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Color, Grid } from "@snk/types/grid";
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
import { Snake } from "@snk/types/snake";
export const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: {
1: "#9be9a8",
2: "#40c463",
3: "#30a14e",
4: "#216e39",
},
colorEmpty: "#ebedf0",
colorSnake: "purple",
dark: {
colorEmpty: "#161b22",
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
},
};
const getPointedCell =
(canvas: HTMLCanvasElement) =>
({ pageX, pageY }: MouseEvent) => {
const { left, top } = canvas.getBoundingClientRect();
const x = Math.floor((pageX - left) / drawOptions.sizeCell) - 1;
const y = Math.floor((pageY - top) / drawOptions.sizeCell) - 2;
return { x, y };
};
export const createCanvas = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const canvas = document.createElement("canvas");
const upscale = 2;
const w = drawOptions.sizeCell * (width + 4);
const h = drawOptions.sizeCell * (height + 4) + 200;
canvas.width = w * upscale;
canvas.height = h * upscale;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.style.display = "block";
// canvas.style.pointerEvents = "none";
const cellInfo = document.createElement("div");
cellInfo.style.height = "20px";
document.body.appendChild(cellInfo);
document.body.appendChild(canvas);
canvas.addEventListener("mousemove", (e) => {
const { x, y } = getPointedCell(canvas)(e);
cellInfo.innerText = [x, y]
.map((u) => u.toString().padStart(2, " "))
.join(" / ");
});
const ctx = canvas.getContext("2d")!;
ctx.scale(upscale, upscale);
const draw = (grid: Grid, snake: Snake, stack: Color[]) => {
ctx.clearRect(0, 0, 9999, 9999);
drawWorld(ctx, grid, snake, stack, drawOptions);
};
const drawLerp = (
grid: Grid,
snake0: Snake,
snake1: Snake,
stack: Color[],
k: number
) => {
ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
};
const highlightCell = (x: number, y: number, color = "orange") => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
};
return {
draw,
drawLerp,
highlightCell,
canvas,
getPointedCell: getPointedCell(canvas),
ctx,
};
};

View File

@@ -0,0 +1,41 @@
import "./menu";
import { createCanvas } from "./canvas";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { Color, copyGrid } from "@snk/types/grid";
import { grid, snake } from "./sample";
import { step } from "@snk/solver/step";
const chain = getBestRoute(grid, snake)!;
//
// draw
let k = 0;
const { canvas, draw } = createCanvas(grid);
document.body.appendChild(canvas);
const onChange = () => {
const gridN = copyGrid(grid);
const stack: Color[] = [];
for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]);
draw(gridN, chain[k], stack);
};
onChange();
const input = document.createElement("input") as any;
input.type = "range";
input.value = 0;
input.step = 1;
input.min = 0;
input.max = chain.length - 1;
input.style.width = "90%";
input.addEventListener("input", () => {
k = +input.value;
onChange();
});
document.body.append(input);
window.addEventListener("click", (e) => {
if (e.target === document.body || e.target === document.body.parentElement)
input.focus();
});

View File

@@ -0,0 +1,80 @@
import "./menu";
import { createCanvas } from "./canvas";
import { getSnakeLength } from "@snk/types/snake";
import { grid, snake } from "./sample";
import { getColor } from "@snk/types/grid";
import { getBestTunnel } from "@snk/solver/getBestTunnel";
import { createOutside } from "@snk/solver/outside";
import type { Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
document.body.appendChild(canvas);
const ones: Point[] = [];
for (let x = 0; x < grid.width; x++)
for (let y = 0; y < grid.height; y++)
if (getColor(grid, x, y) === 1) ones.push({ x, y });
const tunnels = ones.map(({ x, y }) => ({
x,
y,
tunnel: getBestTunnel(
grid,
createOutside(grid),
x,
y,
3 as Color,
getSnakeLength(snake)
),
}));
const onChange = () => {
const k = +inputK.value;
const i = +inputI.value;
ctx.clearRect(0, 0, 9999, 9999);
if (!tunnels[k]) return;
const { x, y, tunnel } = tunnels[k]!;
draw(grid, snake, []);
highlightCell(x, y, "red");
if (tunnel) {
tunnel.forEach(({ x, y }) => highlightCell(x, y));
highlightCell(x, y, "red");
highlightCell(tunnel[i].x, tunnel[i].y, "blue");
}
};
const inputK = document.createElement("input") as any;
inputK.type = "range";
inputK.value = 0;
inputK.step = 1;
inputK.min = 0;
inputK.max = tunnels ? tunnels.length - 1 : 0;
inputK.style.width = "90%";
inputK.style.padding = "20px 0";
inputK.addEventListener("input", () => {
inputI.value = 0;
inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1;
onChange();
});
document.body.append(inputK);
const inputI = document.createElement("input") as any;
inputI.type = "range";
inputI.value = 0;
inputI.step = 1;
inputI.min = 0;
inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1;
inputI.style.width = "90%";
inputI.style.padding = "20px 0";
inputI.addEventListener("input", onChange);
document.body.append(inputI);
onChange();

View File

@@ -0,0 +1,59 @@
import "./menu";
import { createCanvas } from "./canvas";
import { copySnake, snakeToCells } from "@snk/types/snake";
import { grid, snake as snake0 } from "./sample";
import { getPathTo } from "@snk/solver/getPathTo";
const { canvas, ctx, draw, getPointedCell, highlightCell } = createCanvas(grid);
canvas.style.pointerEvents = "auto";
let snake = copySnake(snake0);
let chain = [snake];
canvas.addEventListener("mousemove", (e) => {
const { x, y } = getPointedCell(e);
chain = [...(getPathTo(grid, snake, x, y) || []), snake].reverse();
inputI.max = chain.length - 1;
i = inputI.value = chain.length - 1;
onChange();
});
canvas.addEventListener("click", () => {
snake = chain.slice(-1)[0];
chain = [snake];
inputI.max = chain.length - 1;
i = inputI.value = chain.length - 1;
onChange();
});
let i = 0;
const onChange = () => {
ctx.clearRect(0, 0, 9999, 9999);
draw(grid, chain[i], []);
chain
.map(snakeToCells)
.flat()
.forEach(({ x, y }) => highlightCell(x, y));
};
onChange();
const inputI = document.createElement("input") as any;
inputI.type = "range";
inputI.value = 0;
inputI.max = chain ? chain.length - 1 : 0;
inputI.step = 1;
inputI.min = 0;
inputI.style.width = "90%";
inputI.style.padding = "20px 0";
inputI.addEventListener("input", () => {
i = +inputI.value;
onChange();
});
document.body.append(inputI);

View File

@@ -0,0 +1,41 @@
import "./menu";
import { createCanvas } from "./canvas";
import { createSnakeFromCells, snakeToCells } from "@snk/types/snake";
import { grid, snake } from "./sample";
import { getPathToPose } from "@snk/solver/getPathToPose";
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
canvas.style.pointerEvents = "auto";
const target = createSnakeFromCells(
snakeToCells(snake).map((p) => ({ ...p, x: p.x - 1 }))
);
let chain = [snake, ...getPathToPose(snake, target)!];
let i = 0;
const onChange = () => {
ctx.clearRect(0, 0, 9999, 9999);
draw(grid, chain[i], []);
chain
.map(snakeToCells)
.flat()
.forEach(({ x, y }) => highlightCell(x, y));
};
onChange();
const inputI = document.createElement("input") as any;
inputI.type = "range";
inputI.value = 0;
inputI.max = chain ? chain.length - 1 : 0;
inputI.step = 1;
inputI.min = 0;
inputI.style.width = "90%";
inputI.style.padding = "20px 0";
inputI.addEventListener("input", () => {
i = +inputI.value;
onChange();
});
document.body.append(inputI);

View File

@@ -0,0 +1,275 @@
import { Color, copyGrid, Grid } from "@snk/types/grid";
import { step } from "@snk/solver/step";
import { isStableAndBound, stepSpring } from "./springUtils";
import type { Res } from "@snk/github-user-contribution";
import type { Snake } from "@snk/types/snake";
import {
drawLerpWorld,
getCanvasWorldSize,
Options,
} from "@snk/draw/drawWorld";
import { userContributionToGrid } from "@snk/action/userContributionToGrid";
import { createSvg } from "@snk/svg-creator";
import { createRpcClient } from "./worker-utils";
import type { API as WorkerAPI } from "./demo.interactive.worker";
const createForm = ({
onSubmit,
onChangeUserName,
}: {
onSubmit: (s: string) => Promise<void>;
onChangeUserName: (s: string) => void;
}) => {
const form = document.createElement("form");
form.style.position = "relative";
form.style.display = "flex";
form.style.flexDirection = "row";
const input = document.createElement("input");
input.addEventListener("input", () => onChangeUserName(input.value));
input.style.padding = "16px";
input.placeholder = "github user";
const submit = document.createElement("button");
submit.style.padding = "16px";
submit.type = "submit";
submit.innerText = "ok";
const label = document.createElement("label");
label.style.position = "absolute";
label.style.textAlign = "center";
label.style.top = "60px";
label.style.left = "0";
label.style.right = "0";
form.appendChild(input);
form.appendChild(submit);
document.body.appendChild(form);
form.addEventListener("submit", (event) => {
event.preventDefault();
onSubmit(input.value)
.finally(() => {
clearTimeout(timeout);
})
.catch((err) => {
label.innerText = "error :(";
throw err;
});
input.disabled = true;
submit.disabled = true;
form.appendChild(label);
label.innerText = "loading ...";
const timeout = setTimeout(() => {
label.innerText = "loading ( it might take a while ) ... ";
}, 5000);
});
//
// dispose
const dispose = () => {
document.body.removeChild(form);
};
return { dispose };
};
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
const createGithubProfile = () => {
const container = document.createElement("div");
container.style.padding = "20px";
container.style.opacity = "0";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.height = "120px";
container.style.alignItems = "flex-start";
const image = document.createElement("img");
image.style.width = "100px";
image.style.height = "100px";
image.style.borderRadius = "50px";
const name = document.createElement("a");
name.style.padding = "4px 0 0 0";
document.body.appendChild(container);
container.appendChild(image);
container.appendChild(name);
image.addEventListener("load", () => {
container.style.opacity = "1";
});
const onChangeUser = (userName: string) => {
container.style.opacity = "0";
name.innerText = userName;
name.href = `https://github.com/${userName}`;
image.src = `https://github.com/${userName}.png`;
};
const dispose = () => {
document.body.removeChild(container);
};
return { dispose, onChangeUser };
};
const createViewer = ({
grid0,
chain,
drawOptions,
}: {
grid0: Grid;
chain: Snake[];
drawOptions: Options;
}) => {
//
// canvas
const canvas = document.createElement("canvas");
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
canvas.width = width;
canvas.height = height;
const w = Math.min(width, window.innerWidth);
const h = (height / width) * w;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.style.pointerEvents = "none";
document.body.appendChild(canvas);
//
// draw
let animationFrame: number;
const spring = { x: 0, v: 0, target: 0 };
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
const ctx = canvas.getContext("2d")!;
const loop = () => {
cancelAnimationFrame(animationFrame);
stepSpring(spring, springParams, spring.target);
const stable = isStableAndBound(spring, spring.target);
const grid = copyGrid(grid0);
const stack: Color[] = [];
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
step(grid, stack, chain[i]);
const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)];
const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)];
const k = spring.x % 1;
ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
if (!stable) animationFrame = requestAnimationFrame(loop);
};
loop();
//
// controls
const input = document.createElement("input") as any;
input.type = "range";
input.value = 0;
input.step = 1;
input.min = 0;
input.max = chain.length;
input.style.width = "calc( 100% - 20px )";
input.addEventListener("input", () => {
spring.target = +input.value;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(loop);
});
const onClickBackground = (e: MouseEvent) => {
if (e.target === document.body || e.target === document.body.parentElement)
input.focus();
};
window.addEventListener("click", onClickBackground);
document.body.append(input);
//
// svg
const svgLink = document.createElement("a");
const svgString = createSvg(grid0, chain, drawOptions, {
frameDuration: 100,
});
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
svgLink.href = svgImageUri;
svgLink.innerText = "github-user-contribution.svg";
svgLink.download = "github-user-contribution.svg";
svgLink.addEventListener("click", (e) => {
const w = window.open("")!;
w.document.write(
`<a href="${svgImageUri}" download="github-user-contribution.svg">` +
svgString +
"<a/>"
);
e.preventDefault();
});
svgLink.style.padding = "20px";
svgLink.style.paddingTop = "60px";
svgLink.style.alignSelf = "flex-start";
document.body.append(svgLink);
//
// dispose
const dispose = () => {
window.removeEventListener("click", onClickBackground);
cancelAnimationFrame(animationFrame);
document.body.removeChild(canvas);
document.body.removeChild(input);
document.body.removeChild(svgLink);
};
return { dispose };
};
const onSubmit = async (userName: string) => {
const res = await fetch(
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName
);
const { cells, colorScheme } = (await res.json()) as Res;
const drawOptions = {
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);
dispose();
createViewer({ grid0: grid, chain, drawOptions });
};
const worker = new Worker(
new URL(
"./demo.interactive.worker.ts",
// @ts-ignore
import.meta.url
)
);
const { getChain } = createRpcClient<WorkerAPI>(worker);
const profile = createGithubProfile();
const { dispose } = createForm({
onSubmit,
onChangeUserName: profile.onChangeUser,
});
document.body.style.margin = "0";
document.body.style.display = "flex";
document.body.style.flexDirection = "column";
document.body.style.alignItems = "center";
document.body.style.justifyContent = "center";
document.body.style.height = "100%";
document.body.style.width = "100%";
document.body.style.position = "absolute";

View File

@@ -0,0 +1,17 @@
import { getBestRoute } from "@snk/solver/getBestRoute";
import { getPathToPose } from "@snk/solver/getPathToPose";
import { snake4 as snake } from "@snk/types/__fixtures__/snake";
import type { Grid } from "@snk/types/grid";
import { createRpcServer } from "./worker-utils";
const getChain = (grid: Grid) => {
const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
return chain;
};
const api = { getChain };
export type API = typeof api;
createRpcServer(api);

9
packages/demo/demo.json Normal file
View File

@@ -0,0 +1,9 @@
[
"interactive",
"getBestRoute",
"getBestTunnel",
"outside",
"getPathToPose",
"getPathTo",
"svg"
]

View File

@@ -0,0 +1,42 @@
import "./menu";
import { createCanvas } from "./canvas";
import { grid } from "./sample";
import type { Color } from "@snk/types/grid";
import { createOutside, isOutside } from "@snk/solver/outside";
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
document.body.appendChild(canvas);
let k = 0;
const onChange = () => {
ctx.clearRect(0, 0, 9999, 9999);
draw(grid, [] as any, []);
const outside = createOutside(grid, k as Color);
for (let x = outside.width; x--; )
for (let y = outside.height; y--; )
if (isOutside(outside, x, y)) highlightCell(x, y);
};
onChange();
const inputK = document.createElement("input") as any;
inputK.type = "range";
inputK.value = 0;
inputK.step = 1;
inputK.min = 0;
inputK.max = 4;
inputK.style.width = "90%";
inputK.style.padding = "20px 0";
inputK.addEventListener("input", () => {
k = +inputK.value;
onChange();
});
document.body.append(inputK);
window.addEventListener("click", (e) => {
if (e.target === document.body || e.target === document.body.parentElement)
inputK.focus();
});

17
packages/demo/demo.svg.ts Normal file
View File

@@ -0,0 +1,17 @@
import "./menu";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { createSvg } from "@snk/svg-creator";
import { grid, snake } from "./sample";
import { drawOptions } from "./canvas";
import { getPathToPose } from "@snk/solver/getPathToPose";
const chain = getBestRoute(grid, snake);
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
(async () => {
const svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 });
const container = document.createElement("div");
container.innerHTML = svg;
document.body.appendChild(container);
})();

View File

@@ -1,81 +0,0 @@
// import { generateGrid } from "@snk/compute/generateGrid";
import { generateGrid } from "@snk/compute/generateGrid";
import { Color, copyGrid } from "@snk/compute/grid";
import { computeBestRun } from "@snk/compute";
import { step } from "@snk/compute/step";
import { drawWorld } from "@snk/draw/drawWorld";
import { Point } from "@snk/compute/point";
const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
export const run = async () => {
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const grid0 = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
const snake0 = [
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
const stack0: Color[] = [];
const chain = computeBestRun(grid0, snake0, gameOptions);
const canvas = document.createElement("canvas");
canvas.width = drawOptions.sizeCell * (grid0.width + 4);
canvas.height = drawOptions.sizeCell * (grid0.height + 4) + 100;
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
const update = (n: number) => {
const snake = copySnake(snake0);
const stack = stack0.slice();
const grid = copyGrid(grid0);
for (let i = 0; i < n; i++) step(grid, snake, stack, chain[i], gameOptions);
ctx.clearRect(0, 0, 9999, 9999);
drawWorld(ctx, grid, snake, stack, drawOptions);
};
const input: any = document.createElement("input");
input.type = "range";
input.style.width = "100%";
input.min = 0;
input.max = chain.length;
input.step = 1;
input.value = 0;
input.addEventListener("input", () => update(+input.value));
document.addEventListener("click", () => input.focus());
document.body.appendChild(input);
update(+input.value);
// while (chain.length) {
// await wait(100);
// step(grid, snake, stack, chain.shift()!, gameOptions);
// ctx.clearRect(0, 0, 9999, 9999);
// drawWorld(ctx, grid, snake, stack, options);
// }
// const wait = (delay = 0) => new Promise((r) => setTimeout(r, delay));
};
run();

36
packages/demo/menu.ts Normal file
View File

@@ -0,0 +1,36 @@
import { GUI } from "dat.gui";
import * as grids from "@snk/types/__fixtures__/grid";
import * as snakes from "@snk/types/__fixtures__/snake";
import { grid, snake } from "./sample";
const demos: string[] = require("./demo.json");
export const gui = new GUI();
const config = {
snake: Object.entries(snakes).find(([_, s]) => s === snake)![0],
grid: Object.entries(grids).find(([_, s]) => s === grid)![0],
demo: demos[0],
};
{
const d = window.location.pathname.match(/(\w+)\.html/);
if (d && demos.includes(d[1])) config.demo = d[1];
}
const onChange = () => {
const search = new URLSearchParams({
snake: config.snake,
grid: config.grid,
}).toString();
const url = new URL(
config.demo + ".html?" + search,
window.location.href
).toString();
window.location.href = url;
};
gui.add(config, "demo", demos).onChange(onChange);
gui.add(config, "grid", Object.keys(grids)).onChange(onChange);
gui.add(config, "snake", Object.keys(snakes)).onChange(onChange);

View File

@@ -2,18 +2,25 @@
"name": "@snk/demo",
"version": "1.0.0",
"dependencies": {
"@snk/compute": "1.0.0"
"@snk/action": "1.0.0",
"@snk/draw": "1.0.0",
"@snk/github-user-contribution": "1.0.0",
"@snk/solver": "1.0.0",
"@snk/svg-creator": "1.0.0",
"@snk/types": "1.0.0"
},
"devDependencies": {
"webpack": "4.43.0",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.0",
"ts-loader": "8.0.1",
"html-webpack-plugin": "4.3.0"
"@types/dat.gui": "0.7.7",
"dat.gui": "0.7.9",
"html-webpack-plugin": "5.5.0",
"ts-loader": "9.2.8",
"ts-node": "10.7.0",
"webpack": "5.72.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.8.1"
},
"scripts": {
"prepare": "tsc webpack.config.ts",
"build": "yarn prepare ; webpack",
"dev": "yarn prepare ; webpack-dev-server --port ${PORT-3000}"
"build": "webpack",
"dev": "webpack serve"
}
}

14
packages/demo/sample.ts Normal file
View File

@@ -0,0 +1,14 @@
import * as grids from "@snk/types/__fixtures__/grid";
import * as snakes from "@snk/types/__fixtures__/snake";
import type { Snake } from "@snk/types/snake";
import type { Grid } from "@snk/types/grid";
const sp = new URLSearchParams(window.location.search);
const gLabel = sp.get("grid") || "simple";
const sLabel = sp.get("snake") || "snake3";
//@ts-ignore
export const grid: Grid = grids[gLabel] || grids.simple;
//@ts-ignore
export const snake: Snake = snakes[sLabel] || snakes.snake3;

View File

@@ -0,0 +1,63 @@
const epsilon = 0.01;
export const clamp = (a: number, b: number) => (x: number) =>
Math.max(a, Math.min(b, x));
/**
* step the spring, mutate the state to reflect the state at t+dt
*
*/
const stepSpringOne = (
s: { x: number; v: number },
{
tension,
friction,
maxVelocity = Infinity,
}: { tension: number; friction: number; maxVelocity?: number },
target: number,
dt = 1 / 60
) => {
const a = -tension * (s.x - target) - friction * s.v;
s.v += a * dt;
s.v = clamp(-maxVelocity / dt, maxVelocity / dt)(s.v);
s.x += s.v * dt;
};
/**
* return true if the spring is to be considered in a stable state
* ( close enough to the target and with a small enough velocity )
*/
export const isStable = (
s: { x: number; v: number },
target: number,
dt = 1 / 60
) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon;
export const isStableAndBound = (
s: { x: number; v: number },
target: number,
dt?: number
) => {
const stable = isStable(s, target, dt);
if (stable) {
s.x = target;
s.v = 0;
}
return stable;
};
export const stepSpring = (
s: { x: number; v: number },
params: { tension: number; friction: number; maxVelocity?: number },
target: number,
dt = 1 / 60
) => {
const interval = 1 / 60;
while (dt > 0) {
stepSpringOne(s, params, target, Math.min(interval, dt));
// eslint-disable-next-line no-param-reassign
dt -= interval;
}
};

View File

@@ -1,47 +1,75 @@
import * as path from "path";
import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin";
// @ts-ignore
import * as HtmlWebpackPlugin from "html-webpack-plugin";
import type { Configuration } from "webpack";
import type { Configuration as WebpackConfiguration } from "webpack";
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
import webpack from "webpack";
import { getGithubUserContribution } from "@snk/github-user-contribution";
const basePathname = (process.env.BASE_PATHNAME || "")
.split("/")
.filter(Boolean);
const demos: string[] = require("./demo.json");
const config: Configuration = {
const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
open: { target: demos[1] + ".html" },
onAfterSetupMiddleware: ({ app }) => {
app!.get("/api/github-user-contribution/:userName", async (req, res) => {
const userName: string = req.params.userName;
res.send(await getGithubUserContribution(userName));
});
},
};
const webpackConfiguration: WebpackConfiguration = {
mode: "development",
entry: "./index",
entry: Object.fromEntries(
demos.map((demo: string) => [demo, `./demo.${demo}`])
),
target: ["web", "es2019"],
resolve: { extensions: [".ts", ".js"] },
output: {
path: path.join(__dirname, "dist"),
filename: "[contenthash].js",
publicPath: "/" + basePathname.map((x) => x + "/").join(""),
},
module: {
rules: [
{
exclude: /node_modules/,
test: /\.(js|ts)$/,
test: /\.ts$/,
loader: "ts-loader",
options: {
transpileOnly: true,
compilerOptions: {
lib: ["dom", "es2020"],
target: "es2019",
},
},
},
],
},
plugins: [
// game
...demos.map(
(demo) =>
new HtmlWebpackPlugin({
title: "snk - " + demo,
filename: `${demo}.html`,
chunks: [demo],
})
),
new HtmlWebpackPlugin({
title: "demo",
filename: "index.html",
meta: {
viewport: "width=device-width, initial-scale=1, shrink-to-fit=no",
},
title: "snk - " + demos[0],
filename: `index.html`,
chunks: [demos[0]],
}),
new webpack.EnvironmentPlugin({
GITHUB_USER_CONTRIBUTION_API_ENDPOINT:
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT ??
"/api/github-user-contribution/",
}),
],
devtool: false,
stats: "errors-only",
// @ts-ignore
devServer: {},
};
export default config;
export default {
...webpackConfiguration,
devServer: webpackDevServerConfiguration,
};

View File

@@ -0,0 +1,59 @@
type API = Record<string, (...args: any[]) => any>;
const symbol = "worker-rpc__";
export const createRpcServer = (api: API) =>
self.addEventListener("message", async (event) => {
if (event.data?.symbol === symbol) {
try {
const res = await api[event.data.methodName](...event.data.args);
self.postMessage({ symbol, key: event.data.key, res });
} catch (error: any) {
postMessage({ symbol, key: event.data.key, error: error.message });
}
}
});
export const createRpcClient = <API_ extends API>(worker: Worker) => {
const originalTerminate = worker.terminate;
worker.terminate = () => {
worker.dispatchEvent(new Event("terminate"));
originalTerminate.call(worker);
};
return new Proxy(
{} as {
[K in keyof API_]: (
...args: Parameters<API_[K]>
) => Promise<Awaited<ReturnType<API_[K]>>>;
},
{
get:
(_, methodName) =>
(...args: any[]) =>
new Promise((resolve, reject) => {
const key = Math.random().toString();
const onTerminate = () => {
worker.removeEventListener("terminate", onTerminate);
worker.removeEventListener("message", onMessageHandler);
reject(new Error("worker terminated"));
};
const onMessageHandler = (event: MessageEvent) => {
if (event.data?.symbol === symbol && event.data.key === key) {
if (event.data.error) reject(event.data.error);
else if (event.data.res) resolve(event.data.res);
worker.removeEventListener("terminate", onTerminate);
worker.removeEventListener("message", onMessageHandler);
}
};
worker.addEventListener("message", onMessageHandler);
worker.addEventListener("terminate", onTerminate);
worker.postMessage({ symbol, key, methodName, args });
}),
}
);
};

3
packages/draw/README.md Normal file
View File

@@ -0,0 +1,3 @@
# @snk/draw
Draw grids and snakes on a canvas.

View File

@@ -0,0 +1,86 @@
import { pathRoundedRect } from "./pathRoundedRect";
import type { Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
type Options = {
colorDots: Record<Color, string>;
colorBorder: string;
sizeCell: number;
sizeDot: number;
sizeBorderRadius: number;
};
const isInsideCircle = (x: number, y: number, r: number) => {
const l = 6;
let k = 0;
for (let dx = 0; dx < l; dx++)
for (let dy = 0; dy < l; dy++) {
const ux = x + (dx + 0.5) / l;
const uy = y + (dy + 0.5) / l;
if (ux * ux + uy * uy < r * r) k++;
}
return k > l * l * 0.6;
};
export const getCellPath = (n: number): Point[] => {
const l = Math.ceil(Math.sqrt(n));
const cells = [];
for (let x = -l; x <= l; x++)
for (let y = -l; y <= l; y++) {
const a = (Math.atan2(y, x) + (5 * Math.PI) / 2) % (Math.PI * 2);
let r = 0;
while (!isInsideCircle(x, y, r + 0.5)) r++;
cells.push({ x, y, f: r * 100 + a });
}
return cells.sort((a, b) => a.f - b.f).slice(0, n);
};
export const cellPath = getCellPath(52 * 7 + 5);
export const getCircleSize = (n: number) => {
const c = cellPath.slice(0, n);
const xs = c.map((p) => p.x);
const ys = c.map((p) => p.y);
return {
max: { x: Math.max(0, ...xs), y: Math.max(0, ...ys) },
min: { x: Math.min(0, ...xs), y: Math.min(0, ...ys) },
};
};
export const drawCircleStack = (
ctx: CanvasRenderingContext2D,
stack: Color[],
o: Options
) => {
for (let i = stack.length; i--; ) {
const { x, y } = cellPath[i];
ctx.save();
ctx.translate(
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
);
//@ts-ignore
ctx.fillStyle = o.colorDots[stack[i]];
ctx.strokeStyle = o.colorBorder;
ctx.lineWidth = 1;
ctx.beginPath();
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.restore();
}
};

View File

@@ -1,5 +1,7 @@
import { Grid, getColor, Color } from "@snk/compute/grid";
import { getColor } from "@snk/types/grid";
import { pathRoundedRect } from "./pathRoundedRect";
import type { Grid, Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
type Options = {
colorDots: Record<Color, string>;
@@ -8,6 +10,7 @@ type Options = {
sizeCell: number;
sizeDot: number;
sizeBorderRadius: number;
cells?: Point[];
};
export const drawGrid = (
@@ -17,26 +20,28 @@ export const drawGrid = (
) => {
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
// @ts-ignore
const color = c === null ? 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
);
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) {
const c = getColor(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.colorBorder;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.fillStyle = color;
ctx.strokeStyle = o.colorBorder;
ctx.lineWidth = 1;
ctx.beginPath();
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.restore();
ctx.restore();
}
}
};

View File

@@ -0,0 +1,69 @@
import { pathRoundedRect } from "./pathRoundedRect";
import { snakeToCells } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake";
type Options = {
colorSnake: string;
sizeCell: number;
};
export const drawSnake = (
ctx: CanvasRenderingContext2D,
snake: Snake,
o: Options
) => {
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: number, a: number, b: number) => (1 - k) * a + k * b;
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
export const drawSnakeLerp = (
ctx: CanvasRenderingContext2D,
snake0: Snake,
snake1: Snake,
k: number,
o: Options
) => {
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(
ctx,
o.sizeCell - u * 2,
o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25
);
ctx.fill();
ctx.restore();
}
};

View File

@@ -1,9 +1,10 @@
import { Grid, Color } from "@snk/compute/grid";
import { pathRoundedRect } from "./pathRoundedRect";
import { Point } from "@snk/compute/point";
import { drawGrid } from "./drawGrid";
import { drawSnake, drawSnakeLerp } from "./drawSnake";
import type { Grid, Color } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
import type { Snake } from "@snk/types/snake";
type Options = {
export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder: string;
@@ -11,53 +12,85 @@ type Options = {
sizeCell: number;
sizeDot: number;
sizeBorderRadius: number;
cells?: Point[];
};
export const drawSnake = (
export const drawStack = (
ctx: CanvasRenderingContext2D,
snake: Point[],
o: Options
stack: Color[],
max: number,
width: number,
o: { colorDots: Record<Color, string> }
) => {
for (let i = 0; i < snake.length; i++) {
const u = (i + 1) * 0.6;
ctx.save();
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(snake[i].x * o.sizeCell + u, snake[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 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();
};
export const drawWorld = (
ctx: CanvasRenderingContext2D,
grid: Grid,
snake: Point[],
snake: Snake,
stack: Color[],
o: Options
) => {
ctx.save();
ctx.translate(2 * o.sizeCell, 2 * o.sizeCell);
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, o);
drawSnake(ctx, snake, o);
ctx.restore();
const m = 5;
ctx.save();
ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell);
for (let i = 0; i < stack.length; i++) {
ctx.fillStyle = o.colorDots[stack[i]];
ctx.fillRect(i * m, 0, m, 10);
}
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();
};
export const drawLerpWorld = (
ctx: CanvasRenderingContext2D,
grid: Grid,
snake0: Snake,
snake1: Snake,
stack: Color[],
k: number,
o: Options
) => {
ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, 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();
};
export const getCanvasWorldSize = (grid: Grid, o: { sizeCell: number }) => {
const width = o.sizeCell * (grid.width + 2);
const height = o.sizeCell * (grid.height + 4) + 30;
return { width, height };
};

View File

@@ -2,6 +2,6 @@
"name": "@snk/draw",
"version": "1.0.0",
"dependencies": {
"@snk/compute": "1.0.0"
"@snk/solver": "1.0.0"
}
}

View File

@@ -1 +0,0 @@
out.gif

View File

@@ -0,0 +1,5 @@
# @snk/gif-creator
Generate a gif file from the grid and snake path.
Relies on graphics magic and gifsicle binaries.

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,77 @@
import * as fs from "fs";
import { performance } from "perf_hooks";
import { createSnakeFromCells } from "@snk/types/snake";
import { realistic as grid } from "@snk/types/__fixtures__/grid";
import { createGif } from "..";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { getPathToPose } from "@snk/solver/getPathToPose";
let snake = createSnakeFromCells(
Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 }))
);
// const chain = [snake];
// for (let y = -1; y < grid.height; y++) {
// snake = nextSnake(snake, 0, 1);
// chain.push(snake);
// for (let x = grid.width - 1; x--; ) {
// snake = nextSnake(snake, (y + 100) % 2 ? 1 : -1, 0);
// chain.push(snake);
// }
// }
const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
const gifOptions = { frameDuration: 100, step: 1 };
(async () => {
for (
let length = 10;
length < chain.length;
length += Math.floor((chain.length - 10) / 3 / 10) * 10
) {
const stats: number[] = [];
let buffer: Buffer;
const start = Date.now();
const chainL = chain.slice(0, length);
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
const s = performance.now();
buffer = await createGif(grid, chainL, drawOptions, gifOptions);
stats.push(performance.now() - s);
}
console.log(
[
"---",
`grid dimension: ${grid.width}x${grid.height}`,
`chain length: ${length}`,
`resulting size: ${(buffer!.length / 1024).toFixed(1)}ko`,
`generation duration (mean): ${(
stats.reduce((s, x) => x + s) / stats.length
).toLocaleString(undefined, {
maximumFractionDigits: 0,
})}ms`,
"",
].join("\n"),
stats
);
fs.writeFileSync(
`__tests__/__snapshots__/benchmark-output-${length}.gif`,
buffer!
);
}
})();

View File

@@ -1,42 +1,71 @@
import * as fs from "fs";
import * as path from "path";
import { createGif } from "..";
import { generateGrid } from "@snk/compute/generateGrid";
import { computeBestRun } from "@snk/compute";
import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
import { createSnakeFromCells, nextSnake } from "@snk/types/snake";
import { getBestRoute } from "@snk/solver/getBestRoute";
jest.setTimeout(20 * 1000);
const upscale = 1;
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
sizeBorderRadius: 2 * upscale,
sizeCell: 16 * upscale,
sizeDot: 12 * upscale,
colorBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { frameDuration: 200, step: 1 };
const gifOptions = { delay: 200 };
const dir = path.resolve(__dirname, "__snapshots__");
it("should generate gif", async () => {
const grid = generateGrid(14, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
try {
fs.mkdirSync(dir);
} catch (err) {}
const snake = [
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
for (const key of [
"empty",
"simple",
"corner",
"small",
"smallPacked",
] as const)
it(`should generate ${key} gif`, async () => {
const grid = grids[key];
const commands = computeBestRun(grid, snake, gameOptions).slice(0, 9);
const chain = [snake, ...getBestRoute(grid, snake)!];
const gif = await createGif(
grid,
snake,
commands,
drawOptions,
gameOptions,
gifOptions
const gif = await createGif(grid, chain, drawOptions, gifOptions);
expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
});
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

@@ -1,35 +0,0 @@
import { createGif } from "..";
import { generateGrid } from "@snk/compute/generateGrid";
import { computeBestRun } from "@snk/compute";
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { delay: 20 };
const grid = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
const snake = [
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
const commands = computeBestRun(grid, snake, gameOptions);
createGif(grid, snake, commands, drawOptions, gameOptions, gifOptions).then(
(buffer) => {
process.stdout.write(buffer);
}
);

View File

@@ -1,92 +1,94 @@
import * as fs from "fs";
import * as path from "path";
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import { createCanvas } from "canvas";
import { Grid, copyGrid, Color } from "@snk/compute/grid";
import { Point } from "@snk/compute/point";
import { copySnake } from "@snk/compute/snake";
import { drawWorld } from "@snk/draw/drawWorld";
import { step } from "@snk/compute/step";
import * as tmp from "tmp";
import { Grid, copyGrid, Color } from "@snk/types/grid";
import { Snake } from "@snk/types/snake";
import {
Options,
drawLerpWorld,
getCanvasWorldSize,
} from "@snk/draw/drawWorld";
import { step } from "@snk/solver/step";
import tmp from "tmp";
import gifsicle from "gifsicle";
// @ts-ignore
import * as execa from "execa";
export const createGif = async (
grid0: Grid,
snake0: Point[],
commands: Point[],
drawOptions: Parameters<typeof drawWorld>[4],
gameOptions: Parameters<typeof step>[4],
gifOptions: { delay: number }
) => {
const grid = copyGrid(grid0);
const snake = copySnake(snake0);
const stack: Color[] = [];
const width = drawOptions.sizeCell * (grid.width + 4);
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
import GIFEncoder from "gif-encoder-2";
const withTmpDir = async <T>(
handler: (dir: string) => Promise<T>
): Promise<T> => {
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
unsafeCleanup: true,
});
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!;
const writeImage = (i: number) => {
ctx.clearRect(0, 0, 99999, 99999);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 99999, 99999);
drawWorld(ctx, grid, snake, stack, drawOptions);
const buffer = canvas.toBuffer("image/png", {
compressionLevel: 0,
filters: canvas.PNG_FILTER_NONE,
});
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
fs.writeFileSync(fileName, buffer);
};
try {
writeImage(0);
return await handler(dir);
} finally {
cleanUp();
}
};
for (let i = 0; i < commands.length; i++) {
step(grid, snake, stack, commands[i], gameOptions);
writeImage(i + 1);
export const createGif = async (
grid0: Grid,
chain: Snake[],
drawOptions: Options,
gifOptions: { frameDuration: number; step: number }
) =>
withTmpDir(async (dir) => {
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!;
const grid = copyGrid(grid0);
const stack: Color[] = [];
const encoder = new GIFEncoder(width, height, "neuquant", true);
encoder.setRepeat(0);
encoder.setDelay(gifOptions.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 < gifOptions.step; k++) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
drawLerpWorld(
ctx,
grid,
snake0,
snake1,
stack,
k / gifOptions.step,
drawOptions
);
encoder.addFrame(ctx);
}
}
const outFileName = path.join(dir, "out.gif");
const optimizedFileName = path.join(dir, "out.optimized.gif");
await execa(
"gm",
[
"convert",
["-loop", "0"],
["-delay", gifOptions.delay.toString()],
["-dispose", "2"],
// ["-layers", "OptimizeFrame"],
["-compress", "LZW"],
["-strip"],
encoder.finish();
fs.writeFileSync(outFileName, encoder.out.getData());
path.join(dir, "*.png"),
outFileName,
].flat()
);
await execa(
"gifsicle",
execFileSync(
gifsicle,
[
//
"--optimize=3",
"--color-method=diversity",
"--colors=18",
outFileName,
["--output", optimizedFileName],
].flat()
);
return fs.readFileSync(optimizedFileName);
} finally {
cleanUp();
}
};
});

View File

@@ -2,18 +2,19 @@
"name": "@snk/gif-creator",
"version": "1.0.0",
"dependencies": {
"@snk/compute": "1.0.0",
"@snk/draw": "1.0.0",
"canvas": "2.6.1",
"execa": "4.0.3",
"@snk/solver": "1.0.0",
"canvas": "2.9.1",
"gif-encoder-2": "1.0.5",
"gifsicle": "5.3.0",
"tmp": "0.2.1"
},
"devDependencies": {
"@types/execa": "2.0.0",
"@types/tmp": "0.2.0",
"@zeit/ncc": "0.22.3"
"@types/gifsicle": "5.2.0",
"@types/tmp": "0.2.3",
"@vercel/ncc": "0.24.1"
},
"scripts": {
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
}
}

View File

@@ -0,0 +1,3 @@
# @snk/github-user-contribution-service
Expose github-user-contribution as an endpoint, using vercel.sh

View File

@@ -0,0 +1,16 @@
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,8 @@
{
"name": "@snk/github-user-contribution-service",
"version": "1.0.0",
"dependencies": {
"@snk/github-user-contribution": "1.0.0",
"@vercel/node": "1.14.0"
}
}

View File

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

View File

@@ -0,0 +1,29 @@
# @snk/github-user-contribution
Get the github user contribution graph
## Usage
```js
const { cells, colorScheme } = await getGithubUserContribution("platane");
// colorScheme = [
// "#ebedf0",
// "#9be9a8",
// ...
// ]
// cells = [
// {
// x: 3,
// y: 0,
// count: 3,
// color: '#ebedf0',
// date:'2019-01-18'
// },
// ...
// ]
```
## Implementation
Based on the html page. Which is very unstable. We might switch to using github api but afaik it's a bit complex.

View File

@@ -0,0 +1,19 @@
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,14 +1,54 @@
import { getGithubUserContribution } from "..";
it("should get user contribution", async () => {
const { cells, colorScheme } = await getGithubUserContribution("platane");
describe("getGithubUserContribution", () => {
const promise = getGithubUserContribution("platane");
expect(cells).toBeDefined();
expect(colorScheme).toEqual([
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
]);
it("should resolve", async () => {
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 () => {
const { cells } = await promise;
expect(cells.length).toBeGreaterThanOrEqual(365);
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 () => {
const { cells, colorScheme } = await promise;
expect(cells.length).toBeGreaterThan(300);
expect(colorScheme).toEqual([
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
]);
const undefinedDays = Array.from({ length: Math.floor(365 / 7) })
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
.flat()
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y));
expect(undefinedDays).toEqual([]);
});
});
it("should match snapshot for year=2019", async () => {
expect(
await getGithubUserContribution("platane", { year: 2019 })
).toMatchSnapshot();
});

View File

@@ -0,0 +1,38 @@
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,61 +1,129 @@
// import * as https from "https";
import fetch from "node-fetch";
import * as cheerio from "cheerio";
import { formatParams, Options } from "./formatParams";
// @ts-ignore
// import * as cheerio from "cheerio";
/**
* 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 })
*
*/
export const getGithubUserContribution = async (
userName: string,
options: Options = {}
) => {
// either use github.com/users/xxxx/contributions for previous years
// or github.com/xxxx ( which gives the latest update to today result )
const url =
"year" in options || "from" in options || "to" in options
? `https://github.com/users/${userName}/contributions?` +
formatParams(options)
: `https://github.com/${userName}`;
import { JSDOM } from "jsdom";
const res = await fetch(url);
export const getGithubUserContribution = async (userName: string) => {
// const content: string = await new Promise((resolve, reject) => {
// const req = https.request(`https://github.com/${userName}`, (res) => {
// let data = "";
if (!res.ok) throw new Error(res.statusText);
// res.on("error", reject);
// res.on("data", (chunk) => (data += chunk));
// res.on("end", () => resolve(data));
// });
const resText = await res.text();
// req.on("error", reject);
// req.end();
// });
// const dom = new JSDOM(content);
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
const colorScheme = Array.from(
dom.window.document.querySelectorAll(".legend > li")
).map(
(element) =>
element.getAttribute("style")?.match(/background\-color: +(#\w+)/)?.[1]!
);
const cells = Array.from(
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
)
.map((column, x) =>
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
x,
y,
count: element.getAttribute("data-count"),
date: element.getAttribute("data-date"),
color: element.getAttribute("fill"),
k: colorScheme.indexOf(element.getAttribute("fill")!),
}))
)
.flat();
return { colorScheme, cells };
return parseUserPage(resText);
};
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
const defaultColorScheme = [
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
export type Cell = ThenArg<
ReturnType<typeof getGithubUserContribution>
>["cells"][number];
const parseUserPage = (content: string) => {
const $ = cheerio.load(content);
// "#ebedf0";
// "#9be9a8";
// "#40c463";
// "#30a14e";
// "#216e39";
//
// "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)
.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 }) => ({
...c,
x: xRange.indexOf(svgPosition.x),
y: yRange.indexOf(svgPosition.y),
}));
return { cells, colorScheme };
};
// returns the position of the svg elements, accounting for it's transform and it's parent transform
// ( only accounts for translate transform )
const getSvgPosition = (
e: cheerio.Element | null
): { x: number; y: number } => {
if (!e || e.tagName === "svg") return { x: 0, y: 0 };
const p = getSvgPosition(e.parent as cheerio.Element);
if (e.attribs.x) p.x += +e.attribs.x;
if (e.attribs.y) p.y += +e.attribs.y;
if (e.attribs.transform) {
const m = e.attribs.transform.match(
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/
);
if (m) {
p.x += +m[1];
p.y += +m[2];
}
}
return p;
};
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;
export type Cell = Res["cells"][number];

View File

@@ -2,9 +2,10 @@
"name": "@snk/github-user-contribution",
"version": "1.0.0",
"dependencies": {
"jsdom": "16.3.0"
"cheerio": "1.0.0-rc.10",
"node-fetch": "2.6.7"
},
"devDependencies": {
"@types/jsdom": "16.2.3"
"@types/node-fetch": "2.6.1"
}
}

33
packages/solver/README.md Normal file
View File

@@ -0,0 +1,33 @@
# @snk/solver
Contains the algorithm to compute the best route given a grid and a starting position for the snake.
## Implementation
- for each color in the grid
- 1\ **clear residual color** phase
- find all the cells of a previous color that are "tunnel-able" ( ie: the snake can find a path from the outside of the grid to the cell, and can go back to the outside without colliding ). The snake is allowed to pass thought current and previous color. Higher colors are walls
- sort the "tunnel-able" cell, there is penalty for passing through current color, as previous color should be eliminated as soon as possible.
- for cells with the same score, take the closest one ( determined with a quick mathematic distance, which is not accurate but fast at least )
- navigate to the cell, and through the tunnel.
- re-compute the list of tunnel-able cells ( as eating cells might have freed better tunnel ) as well as the score
- iterate
- 2\ **clear clean color** phase
- find all the cells of the current color that are "tunnel-able"
- no need to consider scoring here. In order to improve efficiency, get the closest cell by doing a tree search ( instead of a simple mathematic distance like in the previous phase )
- navigate to the cell, and through the tunnel.
- iterate
- go back to the starting point

View File

@@ -0,0 +1,48 @@
import { getBestRoute } from "../getBestRoute";
import { snake3, snake4 } from "@snk/types/__fixtures__/snake";
import {
getHeadX,
getHeadY,
getSnakeLength,
Snake,
snakeWillSelfCollide,
} from "@snk/types/snake";
import { createFromSeed } from "@snk/types/__fixtures__/createFromSeed";
const n = 1000;
for (const { width, height, snake } of [
{ width: 5, height: 5, snake: snake3 },
{ width: 5, height: 5, snake: snake4 },
])
it(`should find solution for ${n} ${width}x${height} generated grids for ${getSnakeLength(
snake
)} length snake`, () => {
const results = Array.from({ length: n }, (_, seed) => {
const grid = createFromSeed(seed, width, height);
try {
const chain = getBestRoute(grid, snake);
assertValidPath(chain);
return { seed };
} catch (error) {
return { seed, error };
}
});
expect(results.filter((x) => x.error)).toEqual([]);
});
const assertValidPath = (chain: Snake[]) => {
for (let i = 0; i < chain.length - 1; i++) {
const dx = getHeadX(chain[i + 1]) - getHeadX(chain[i]);
const dy = getHeadY(chain[i + 1]) - getHeadY(chain[i]);
if (!((Math.abs(dx) === 1 && dy == 0) || (Math.abs(dy) === 1 && dx == 0)))
throw new Error(`unexpected direction ${dx},${dy}`);
if (snakeWillSelfCollide(chain[i], dx, dy)) throw new Error(`self collide`);
}
};

View File

@@ -0,0 +1,26 @@
import { getBestRoute } from "../getBestRoute";
import { Color, createEmptyGrid, setColor } from "@snk/types/grid";
import { createSnakeFromCells, snakeToCells } from "@snk/types/snake";
import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 } from "@snk/types/__fixtures__/snake";
it("should find best route", () => {
const snk0 = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
];
const grid = createEmptyGrid(5, 5);
setColor(grid, 3, 3, 1 as Color);
const chain = getBestRoute(grid, createSnakeFromCells(snk0))!;
expect(snakeToCells(chain[1])[1]).toEqual({ x: 0, y: 0 });
expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 });
});
for (const [gridName, grid] of Object.entries(grids))
it(`should find a solution for ${gridName}`, () => {
getBestRoute(grid, snake3);
});

View File

@@ -0,0 +1,12 @@
import { createEmptyGrid } from "@snk/types/grid";
import { getHeadX, getHeadY } from "@snk/types/snake";
import { snake3 } from "@snk/types/__fixtures__/snake";
import { getPathTo } from "../getPathTo";
it("should find it's way in vaccum", () => {
const grid = createEmptyGrid(5, 0);
const path = getPathTo(grid, snake3, 5, -1)!;
expect([getHeadX(path[0]), getHeadY(path[0])]).toEqual([5, -1]);
});

View File

@@ -0,0 +1,19 @@
import { createSnakeFromCells } from "@snk/types/snake";
import { getPathToPose } from "../getPathToPose";
it("should fing path to pose", () => {
const snake0 = createSnakeFromCells([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
]);
const target = createSnakeFromCells([
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
]);
const path = getPathToPose(snake0, target);
expect(path).toBeDefined();
});

View File

@@ -0,0 +1,86 @@
import { sortPush } from "../utils/sortPush";
const sortFn = (a: number, b: number) => a - b;
it("should sort push length=0", () => {
const a: any[] = [];
const x = -1;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
it("should sort push under", () => {
const a = [1, 2, 3, 4, 5];
const x = -1;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
it("should sort push 0", () => {
const a = [1, 2, 3, 4, 5];
const x = 1;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
it("should sort push end", () => {
const a = [1, 2, 3, 4, 5];
const x = 5;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
it("should sort push over", () => {
const a = [1, 2, 3, 4, 5];
const x = 10;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
it("should sort push inside", () => {
const a = [1, 2, 3, 4, 5];
const x = 1.5;
const res = [...a, x].sort(sortFn);
sortPush(a, x, sortFn);
expect(a).toEqual(res);
});
describe("benchmark", () => {
const n = 200;
const samples = Array.from({ length: 5000 }, () => [
Math.random(),
Array.from({ length: n }, () => Math.random()),
]);
const s0 = samples.map(([x, arr]: any) => [x, arr.slice()]);
const s1 = samples.map(([x, arr]: any) => [x, arr.slice()]);
it("push + sort", () => {
for (const [x, arr] of s0) {
arr.push(x);
arr.sort(sortFn);
}
});
it("sortPush", () => {
for (const [x, arr] of s1) {
sortPush(arr, x, sortFn);
}
});
});

View File

@@ -0,0 +1,130 @@
import {
getColor,
isEmpty,
isInside,
isInsideLarge,
setColorEmpty,
} from "@snk/types/grid";
import {
getHeadX,
getHeadY,
getSnakeLength,
nextSnake,
snakeEquals,
snakeWillSelfCollide,
} from "@snk/types/snake";
import { around4, Point } from "@snk/types/point";
import { getBestTunnel } from "./getBestTunnel";
import { fillOutside } from "./outside";
import type { Outside } from "./outside";
import type { Snake } from "@snk/types/snake";
import type { Color, Empty, Grid } from "@snk/types/grid";
export const clearCleanColoredLayer = (
grid: Grid,
outside: Outside,
snake0: Snake,
color: Color
) => {
const snakeN = getSnakeLength(snake0);
const points = getTunnellablePoints(grid, outside, snakeN, color);
const chain: Snake[] = [snake0];
while (points.length) {
const path = getPathToNextPoint(grid, chain[0], color, points)!;
path.pop();
for (const snake of path)
setEmptySafe(grid, getHeadX(snake), getHeadY(snake));
chain.unshift(...path);
}
fillOutside(outside, grid);
chain.pop();
return chain;
};
type M = { snake: Snake; parent: M | null };
const unwrap = (m: M | null): Snake[] =>
!m ? [] : [m.snake, ...unwrap(m.parent)];
const getPathToNextPoint = (
grid: Grid,
snake0: Snake,
color: Color,
points: Point[]
) => {
const closeList: Snake[] = [];
const openList: M[] = [{ snake: snake0 } as any];
while (openList.length) {
const o = openList.shift()!;
const x = getHeadX(o.snake);
const y = getHeadY(o.snake);
const i = points.findIndex((p) => p.x === x && p.y === y);
if (i >= 0) {
points.splice(i, 1);
return unwrap(o);
}
for (const { x: dx, y: dy } of around4) {
if (
isInsideLarge(grid, 2, x + dx, y + dy) &&
!snakeWillSelfCollide(o.snake, dx, dy) &&
getColorSafe(grid, x + dx, y + dy) <= color
) {
const snake = nextSnake(o.snake, dx, dy);
if (!closeList.some((s0) => snakeEquals(s0, snake))) {
closeList.push(snake);
openList.push({ snake, parent: o });
}
}
}
}
};
/**
* get all cells that are tunnellable
*/
export const getTunnellablePoints = (
grid: Grid,
outside: Outside,
snakeN: number,
color: Color
) => {
const points: Point[] = [];
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
if (
!isEmpty(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 (!isEmptySafe(grid, p.x, p.y)) points.push(p);
}
}
return points;
};
const getColorSafe = (grid: Grid, x: number, y: number) =>
isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty);
const setEmptySafe = (grid: Grid, x: number, y: number) => {
if (isInside(grid, x, y)) setColorEmpty(grid, x, y);
};
const isEmptySafe = (grid: Grid, x: number, y: number) =>
!isInside(grid, x, y) && isEmpty(getColor(grid, x, y));

View File

@@ -0,0 +1,152 @@
import {
Empty,
getColor,
isEmpty,
isInside,
setColorEmpty,
} from "@snk/types/grid";
import { getHeadX, getHeadY, getSnakeLength } from "@snk/types/snake";
import { getBestTunnel } from "./getBestTunnel";
import { fillOutside, Outside } from "./outside";
import { getTunnelPath } from "./tunnel";
import { getPathTo } from "./getPathTo";
import type { Snake } from "@snk/types/snake";
import type { Color, Grid } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
type T = Point & { tunnel: Point[]; priority: number };
export const clearResidualColoredLayer = (
grid: Grid,
outside: Outside,
snake0: Snake,
color: Color
) => {
const snakeN = getSnakeLength(snake0);
const tunnels = getTunnellablePoints(grid, outside, snakeN, color);
// sort
tunnels.sort((a, b) => b.priority - a.priority);
const chain: Snake[] = [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) setEmptySafe(grid, x, y);
// update outside
fillOutside(outside, grid);
// update tunnels
for (let i = tunnels.length; i--; )
if (isEmpty(getColor(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: T[], snake: Snake) => {
let minDistance = Infinity;
let closestTunnel: Point[] | null = null;
const x = getHeadX(snake);
const y = getHeadY(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
*/
export const getTunnellablePoints = (
grid: Grid,
outside: Outside,
snakeN: number,
color: Color
) => {
const points: T[] = [];
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
if (!isEmpty(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
*/
export const getPriority = (grid: Grid, color: Color, tunnel: Point[]) => {
let nColor = 0;
let nLess = 0;
for (let i = 0; i < tunnel.length; i++) {
const { x, y } = tunnel[i];
const c = getColorSafe(grid, x, y);
if (!isEmpty(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: number, ay: number, bx: number, by: number) =>
(ax - bx) ** 2 + (ay - by) ** 2;
const getColorSafe = (grid: Grid, x: number, y: number) =>
isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty);
const setEmptySafe = (grid: Grid, x: number, y: number) => {
if (isInside(grid, x, y)) setColorEmpty(grid, x, y);
};

View File

@@ -0,0 +1,28 @@
import { copyGrid } from "@snk/types/grid";
import { createOutside } from "./outside";
import { clearResidualColoredLayer } from "./clearResidualColoredLayer";
import { clearCleanColoredLayer } from "./clearCleanColoredLayer";
import type { Color, Grid } from "@snk/types/grid";
import type { Snake } from "@snk/types/snake";
export const getBestRoute = (grid0: Grid, snake0: Snake) => {
const grid = copyGrid(grid0);
const outside = createOutside(grid);
const chain: Snake[] = [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: Grid): Color[] => {
// @ts-ignore
let maxColor = Math.max(...grid.data);
return Array.from({ length: maxColor }, (_, i) => (i + 1) as Color);
};

View File

@@ -0,0 +1,113 @@
import { copyGrid, getColor, isInside, setColorEmpty } from "@snk/types/grid";
import { around4 } from "@snk/types/point";
import { sortPush } from "./utils/sortPush";
import {
createSnakeFromCells,
getHeadX,
getHeadY,
nextSnake,
snakeEquals,
snakeWillSelfCollide,
} from "@snk/types/snake";
import { isOutside } from "./outside";
import { trimTunnelEnd, trimTunnelStart } from "./tunnel";
import type { Outside } from "./outside";
import type { Snake } from "@snk/types/snake";
import type { Empty, Color, Grid } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
const getColorSafe = (grid: Grid, x: number, y: number) =>
isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty);
const setEmptySafe = (grid: Grid, x: number, y: number) => {
if (isInside(grid, x, y)) setColorEmpty(grid, x, y);
};
type M = { snake: Snake; parent: M | null; w: number };
const unwrap = (m: M | null): Point[] =>
!m
? []
: [...unwrap(m.parent), { x: getHeadX(m.snake), y: getHeadY(m.snake) }];
/**
* returns the path to reach the outside which contains the least color cell
*/
const getSnakeEscapePath = (
grid: Grid,
outside: Outside,
snake0: Snake,
color: Color
) => {
const openList: M[] = [{ snake: snake0, w: 0 } as any];
const closeList: Snake[] = [];
while (openList[0]) {
const o = openList.shift()!;
const x = getHeadX(o.snake);
const y = getHeadY(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 && !snakeWillSelfCollide(o.snake, a.x, a.y)) {
const snake = nextSnake(o.snake, a.x, a.y);
if (!closeList.some((s0) => snakeEquals(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
*/
export const getBestTunnel = (
grid: Grid,
outside: Outside,
x: number,
y: number,
color: Color,
snakeN: number
) => {
const c = { x, y };
const snake0 = createSnakeFromCells(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 = createSnakeFromCells(snakeICells);
// remove from the grid the colors that one eat
const gridI = copyGrid(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;
};

View File

@@ -0,0 +1,66 @@
import { isInsideLarge, getColor, isInside, isEmpty } from "@snk/types/grid";
import { around4 } from "@snk/types/point";
import {
getHeadX,
getHeadY,
nextSnake,
snakeEquals,
snakeWillSelfCollide,
} from "@snk/types/snake";
import { sortPush } from "./utils/sortPush";
import type { Snake } from "@snk/types/snake";
import type { Grid } from "@snk/types/grid";
type M = { parent: M | null; snake: Snake; w: number; h: number; f: number };
/**
* starting from snake0, get to the cell x,y
* return the snake chain (reversed)
*/
export const getPathTo = (grid: Grid, snake0: Snake, x: number, y: number) => {
const openList: M[] = [{ snake: snake0, w: 0 } as any];
const closeList: Snake[] = [];
while (openList.length) {
const c = openList.shift()!;
const cx = getHeadX(c.snake);
const cy = getHeadY(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 = [nextSnake(c.snake, dx, dy)];
let e: M["parent"] = c;
while (e.parent) {
path.push(e.snake);
e = e.parent;
}
return path;
}
if (
isInsideLarge(grid, 2, nx, ny) &&
!snakeWillSelfCollide(c.snake, dx, dy) &&
(!isInside(grid, nx, ny) || isEmpty(getColor(grid, nx, ny)))
) {
const nsnake = nextSnake(c.snake, dx, dy);
if (!closeList.some((s) => snakeEquals(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);
}
}
}
}
};

View File

@@ -0,0 +1,99 @@
import {
getHeadX,
getHeadY,
getSnakeLength,
nextSnake,
snakeEquals,
snakeToCells,
snakeWillSelfCollide,
} from "@snk/types/snake";
import type { Snake } from "@snk/types/snake";
import {
getColor,
Grid,
isEmpty,
isInside,
isInsideLarge,
} from "@snk/types/grid";
import { getTunnelPath } from "./tunnel";
import { around4 } from "@snk/types/point";
import { sortPush } from "./utils/sortPush";
const isEmptySafe = (grid: Grid, x: number, y: number) =>
!isInside(grid, x, y) || isEmpty(getColor(grid, x, y));
type M = { snake: Snake; parent: M | null; w: number; f: number };
export const getPathToPose = (snake0: Snake, target: Snake, grid?: Grid) => {
if (snakeEquals(snake0, target)) return [];
const targetCells = snakeToCells(target).reverse();
const snakeN = getSnakeLength(snake0);
const box = {
min: {
x: Math.min(getHeadX(snake0), getHeadX(target)) - snakeN - 1,
y: Math.min(getHeadY(snake0), getHeadY(target)) - snakeN - 1,
},
max: {
x: Math.max(getHeadX(snake0), getHeadX(target)) + snakeN + 1,
y: Math.max(getHeadY(snake0), getHeadY(target)) + snakeN + 1,
},
};
const [t0, ...forbidden] = targetCells;
forbidden.slice(0, 3);
const openList: M[] = [{ snake: snake0, w: 0 } as any];
const closeList: Snake[] = [];
while (openList.length) {
const o = openList.shift()!;
const x = getHeadX(o.snake);
const y = getHeadY(o.snake);
if (x === t0.x && y === t0.y) {
const path: Snake[] = [];
let e: M["parent"] = 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 (
!snakeWillSelfCollide(o.snake, dx, dy) &&
(!grid || isEmptySafe(grid, nx, ny)) &&
(grid
? isInsideLarge(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 = nextSnake(o.snake, dx, dy);
if (!closeList.some((s) => snakeEquals(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);
}
}
}
}
};

View File

@@ -0,0 +1,48 @@
import {
createEmptyGrid,
getColor,
isEmpty,
isInside,
setColor,
setColorEmpty,
} from "@snk/types/grid";
import { around4 } from "@snk/types/point";
import type { Color, Grid } from "@snk/types/grid";
export type Outside = Grid & { __outside: true };
export const createOutside = (grid: Grid, color: Color = 0 as Color) => {
const outside = createEmptyGrid(grid.width, grid.height) as Outside;
for (let x = outside.width; x--; )
for (let y = outside.height; y--; ) setColor(outside, x, y, 1 as Color);
fillOutside(outside, grid, color);
return outside;
};
export const fillOutside = (
outside: Outside,
grid: Grid,
color: Color = 0 as Color
) => {
let changed = true;
while (changed) {
changed = false;
for (let x = outside.width; x--; )
for (let y = outside.height; y--; )
if (
getColor(grid, x, y) <= color &&
!isOutside(outside, x, y) &&
around4.some((a) => isOutside(outside, x + a.x, y + a.y))
) {
changed = true;
setColorEmpty(outside, x, y);
}
}
return outside;
};
export const isOutside = (outside: Outside, x: number, y: number) =>
!isInside(outside, x, y) || isEmpty(getColor(outside, x, y));

View File

@@ -0,0 +1,7 @@
{
"name": "@snk/solver",
"version": "1.0.0",
"devDependencies": {
"park-miller": "1.1.0"
}
}

20
packages/solver/step.ts Normal file
View File

@@ -0,0 +1,20 @@
import {
Color,
getColor,
Grid,
isEmpty,
isInside,
setColorEmpty,
} from "@snk/types/grid";
import { getHeadX, getHeadY, Snake } from "@snk/types/snake";
export const step = (grid: Grid, stack: Color[], snake: Snake) => {
const x = getHeadX(snake);
const y = getHeadY(snake);
const color = getColor(grid, x, y);
if (isInside(grid, x, y) && !isEmpty(color)) {
stack.push(color);
setColorEmpty(grid, x, y);
}
};

81
packages/solver/tunnel.ts Normal file
View File

@@ -0,0 +1,81 @@
import { getColor, isEmpty, isInside } from "@snk/types/grid";
import { getHeadX, getHeadY, nextSnake } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake";
import type { Grid } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
/**
* get the sequence of snake to cross the tunnel
*/
export const getTunnelPath = (snake0: Snake, tunnel: Point[]) => {
const chain: Snake[] = [];
let snake = snake0;
for (let i = 1; i < tunnel.length; i++) {
const dx = tunnel[i].x - getHeadX(snake);
const dy = tunnel[i].y - getHeadY(snake);
snake = nextSnake(snake, dx, dy);
chain.unshift(snake);
}
return chain;
};
/**
* assuming the grid change and the colors got deleted, update the tunnel
*/
export const updateTunnel = (
grid: Grid,
tunnel: Point[],
toDelete: Point[]
) => {
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: Grid, x: number, y: number) =>
!isInside(grid, x, y) || isEmpty(getColor(grid, x, y));
/**
* remove empty cell from start
*/
export const trimTunnelStart = (grid: Grid, tunnel: Point[]) => {
while (tunnel.length) {
const { x, y } = tunnel[0];
if (isEmptySafe(grid, x, y)) tunnel.shift();
else break;
}
};
/**
* remove empty cell from end
*/
export const trimTunnelEnd = (grid: Grid, tunnel: Point[]) => {
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;
}
};

View File

@@ -0,0 +1,2 @@
export const arrayEquals = <T>(a: T[], b: T[]) =>
a.length === b.length && a.every((_, i) => a[i] === b[i]);

View File

@@ -0,0 +1,22 @@
export const sortPush = <T>(arr: T[], x: T, sortFn: (a: T, b: T) => number) => {
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);
};

View File

@@ -0,0 +1,5 @@
# @snk/svg-creator
Generate a svg file from the grid and snake path.
Use css style tag to animate the snake and the grid cells. For that reason it only work in browser. Animations are likely to be ignored be native image reader.

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,39 @@
import * as fs from "fs";
import * as path from "path";
import { createSvg } from "..";
import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
import { getBestRoute } from "@snk/solver/getBestRoute";
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
dark: {
colorEmpty: "#161b22",
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
},
};
const gifOptions = { frameDuration: 100, step: 1 };
const dir = path.resolve(__dirname, "__snapshots__");
try {
fs.mkdirSync(dir);
} catch (err) {}
for (const [key, grid] of Object.entries(grids))
it(`should generate ${key} svg`, async () => {
const chain = [snake, ...getBestRoute(grid, snake)!];
const svg = await createSvg(grid, chain, drawOptions, gifOptions);
expect(svg).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".svg"), svg);
});

View File

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

View File

@@ -0,0 +1,161 @@
import {
copyGrid,
getColor,
isEmpty,
isInside,
setColorEmpty,
} from "@snk/types/grid";
import { getHeadX, getHeadY } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake";
import type { Grid, Color, Empty } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
import { createSnake } from "./snake";
import { createGrid } from "./grid";
import { createStack } from "./stack";
import { h } from "./utils";
import * as csso from "csso";
export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder: string;
colorSnake: string;
sizeCell: number;
sizeDot: number;
sizeBorderRadius: number;
cells?: Point[];
dark?: {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder?: string;
colorSnake?: string;
};
};
const getCellsFromGrid = ({ width, height }: Grid) =>
Array.from({ length: width }, (_, x) =>
Array.from({ length: height }, (_, y) => ({ x, y }))
).flat();
const createLivingCells = (
grid0: Grid,
chain: Snake[],
drawOptions: Options
) => {
const cells: (Point & {
t: number | null;
color: Color | Empty;
})[] = (drawOptions.cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
x,
y,
t: null,
color: getColor(grid0, x, y),
}));
const grid = copyGrid(grid0);
for (let i = 0; i < chain.length; i++) {
const snake = chain[i];
const x = getHeadX(snake);
const y = getHeadY(snake);
if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) {
setColorEmpty(grid, x, y);
const cell = cells.find((c) => c.x === x && c.y === y)!;
cell.t = i / chain.length;
}
}
return cells;
};
export const createSvg = (
grid: Grid,
chain: Snake[],
drawOptions: Options,
gifOptions: { frameDuration: number }
) => {
const width = (grid.width + 2) * drawOptions.sizeCell;
const height = (grid.height + 5) * drawOptions.sizeCell;
const duration = gifOptions.frameDuration * chain.length;
const cells = createLivingCells(grid, chain, drawOptions);
const elements = [
createGrid(cells, drawOptions, duration),
createStack(
cells,
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: string) => csso.minify(css).css;
const optimizeSvg = (svg: string) => svg;
const generateColorVar = (drawOptions: Options) =>
`
:root {
--cb: ${drawOptions.colorBorder};
--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.colorBorder || drawOptions.colorBorder};
--cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake};
--ce: ${drawOptions.dark.colorEmpty};
${Object.entries(drawOptions.dark.colorDots)
.map(([i, color]) => `--c${i}:${color};`)
.join("")}
}
}
`
: "");

View File

@@ -0,0 +1,11 @@
{
"name": "@snk/svg-creator",
"version": "1.0.0",
"dependencies": {
"@snk/solver": "1.0.0",
"csso": "5.0.3"
},
"devDependencies": {
"@types/csso": "5.0.0"
}
}

View File

@@ -0,0 +1,101 @@
import { getSnakeLength, snakeToCells } 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 { h } from "./utils";
export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder: string;
colorSnake: string;
sizeCell: 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;
export const createSnake = (
chain: Snake[],
{ sizeCell, sizeDot }: Options,
duration: number
) => {
const snakeN = chain[0] ? getSnakeLength(chain[0]) : 0;
const snakeParts: Point[][] = Array.from({ length: snakeN }, () => []);
for (const snake of chain) {
const cells = snakeToCells(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 }: Point) =>
`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;
return [
`@keyframes ${animationName} {` +
removeInterpolatedPositions(
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}}`,
];
}),
].flat();
return { svgElements, styles };
};
const removeInterpolatedPositions = <T extends Point>(arr: T[]) =>
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);
});

View File

@@ -0,0 +1,80 @@
import type { Color, Empty } from "@snk/types/grid";
import { h } from "./utils";
export type Options = {
sizeDot: number;
};
const percent = (x: number) => (x * 100).toFixed(2);
export const createStack = (
cells: { t: number | null; color: Color | Empty }[],
{ sizeDot }: Options,
width: number,
y: number,
duration: number
) => {
const svgElements: string[] = [];
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!) as any[];
const blocks: { color: Color; ts: number[] }[] = [];
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(
`@keyframes ${animationName} {` +
[
...ts.map((t, i, { length }) => [
{ scale: i / length, t: t - 0.0001 },
{ scale: (i + 1) / length, t: t + 0.0001 },
]),
[{ scale: 1, t: 1 }],
]
.flat()
.map(
({ scale, t }) =>
`${percent(t)}%{transform:scale(${scale.toFixed(2)},1)}`
)
.join("\n") +
"}",
`.u.${id}{fill:var(--c${color});animation-name:${animationName};transform-origin:${x}px 0}`
);
}
return { svgElements, styles };
};

View File

@@ -0,0 +1,8 @@
export const h = (element: string, attributes: any) =>
`<${element} ${toAttribute(attributes)}/>`;
export const toAttribute = (o: any) =>
Object.entries(o)
.filter(([, value]) => value !== null)
.map(([name, value]) => `${name}="${value}"`)
.join(" ");

3
packages/types/README.md Normal file
View File

@@ -0,0 +1,3 @@
# @snk/types
set of basic types and helpers

View File

@@ -0,0 +1,19 @@
import { Color, createEmptyGrid, setColor } from "../grid";
export const createFromAscii = (ascii: string) => {
const a = ascii.split("\n");
if (a[0] === "") a.shift();
const height = a.length;
const width = Math.max(...a.map((r) => r.length));
const grid = createEmptyGrid(width, height);
for (let x = width; x--; )
for (let y = height; y--; ) {
const c = a[y][x];
const color =
(c === "#" && 3) || (c === "@" && 2) || (c === "." && 1) || +c;
if (c) setColor(grid, x, y, color as Color);
}
return grid;
};

View File

@@ -0,0 +1,11 @@
import ParkMiller from "park-miller";
import { Color, createEmptyGrid } from "../grid";
import { randomlyFillGrid } from "../randomlyFillGrid";
export const createFromSeed = (seed: number, width = 5, height = 5) => {
const grid = createEmptyGrid(width, height);
const pm = new ParkMiller(seed);
const random = pm.integerInRange.bind(pm);
randomlyFillGrid(grid, { colors: [1, 2] as Color[], emptyP: 2 }, random);
return grid;
};

Some files were not shown because too many files have changed in this diff Show More