Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7ef7da9fb | ||
|
|
db283098a9 | ||
|
|
621d78be60 | ||
|
|
42f5b68655 | ||
|
|
c135277bdf | ||
|
|
34d5617f54 | ||
|
|
be9fca7f10 | ||
|
|
c0a042d6b4 | ||
|
|
85e229a04d | ||
|
|
da5e045399 | ||
|
|
c22b80d02b | ||
|
|
3db2b4069e | ||
|
|
83033510f0 | ||
|
|
a69d1dbca7 | ||
|
|
f2057e5efe | ||
|
|
7fbc58b61d | ||
|
|
1f7630d984 | ||
|
|
852f0ae376 | ||
|
|
10c4c3c7bd | ||
|
|
ace186c41f | ||
|
|
79c252356c | ||
|
|
3c171061b3 | ||
|
|
4783e68ce7 | ||
|
|
85da3901f5 | ||
|
|
74bc4f0651 | ||
|
|
e55fe1f13c | ||
|
|
876448a004 | ||
|
|
d35dc83cf2 | ||
|
|
bb7d69dde8 | ||
|
|
14a003db51 | ||
|
|
2479713155 | ||
|
|
debec31440 | ||
|
|
5332254423 | ||
|
|
a9052b7ca2 | ||
|
|
8b7b3e6ace | ||
|
|
92f4de3970 | ||
|
|
c9644d3dfa | ||
|
|
01fa6d7aac | ||
|
|
b58af55b7d | ||
|
|
4e5805f8af | ||
|
|
743771147d | ||
|
|
8eddcbdbea | ||
|
|
6f0ace6560 | ||
|
|
835fdd6b84 | ||
|
|
e6034f3972 | ||
|
|
aebc3a9285 | ||
|
|
1574f65738 | ||
|
|
ebeb59fced | ||
|
|
4489504b7a | ||
|
|
027f89563f | ||
|
|
7233ec9e15 | ||
|
|
54dbbbf73d | ||
|
|
3eed9ce6d6 | ||
|
|
3acebc09eb | ||
|
|
82417bf9f5 | ||
|
|
7b6d52d221 | ||
|
|
fd133c88c7 | ||
|
|
229c9a9cd6 | ||
|
|
3803e1ccfa | ||
|
|
8ca289e908 | ||
|
|
fd7cc1f05a | ||
|
|
632fcf6cb7 | ||
|
|
e2eb91cf8f | ||
|
|
38e2ed4f23 | ||
|
|
b7a9c1e353 | ||
|
|
a0e08722d9 | ||
|
|
29c7ee48ec | ||
|
|
21655d1bda | ||
|
|
b895ed2e0f | ||
|
|
96773d2b2e | ||
|
|
79ae29668c | ||
|
|
62f6ff3091 | ||
|
|
4a03759871 | ||
|
|
463b90d43c | ||
|
|
b40f17a02e | ||
|
|
f83b9ab0c3 | ||
|
|
fb80d60b23 | ||
|
|
d078b2d231 | ||
|
|
a81c1bcc97 | ||
|
|
40b26d0110 | ||
|
|
d6e930af5b | ||
|
|
98feaa6035 | ||
|
|
8f1481341a | ||
|
|
2e275adbb6 | ||
|
|
66fef03781 | ||
|
|
5841a21a09 | ||
|
|
cce5c4514d | ||
|
|
fb82d42d53 | ||
|
|
e3ad8b2caf | ||
|
|
c21e390ca9 | ||
|
|
7077112ba4 | ||
|
|
e7aa7b7289 | ||
|
|
6b320a1ac4 | ||
|
|
579bcf1afe | ||
|
|
1018f7a937 | ||
|
|
4edf90f41b | ||
|
|
faf76e6eb6 | ||
|
|
5bede02e06 | ||
|
|
4f7ff9bc90 | ||
|
|
b0d592375a | ||
|
|
672fe6bf0e | ||
|
|
829a59da98 | ||
|
|
58176f658e | ||
|
|
9c881735b7 | ||
|
|
3c697c687e | ||
|
|
825e58e5fd | ||
|
|
9232c14971 | ||
|
|
cd3320efff | ||
|
|
553d8d8efa | ||
|
|
e80a44ca5f | ||
|
|
4ced502e11 | ||
|
|
0374e20a50 | ||
|
|
7ba88d1fbd | ||
|
|
909a9c7fce | ||
|
|
e1dcae75b9 | ||
|
|
5df41911e6 | ||
|
|
c9b130d9da | ||
|
|
05df7cb642 | ||
|
|
309795a2a5 | ||
|
|
e79b3bb634 | ||
|
|
7c0522bfa8 | ||
|
|
be91c43c71 | ||
|
|
67c66ac8ae | ||
|
|
c97378f175 | ||
|
|
b7298f7ff7 | ||
|
|
b4e8fc83ef | ||
|
|
4e9c1ff670 | ||
|
|
c409c8cf1e | ||
|
|
c2e503311a | ||
|
|
a9a9e29cf2 | ||
|
|
2844b095f3 | ||
|
|
1da950d886 | ||
|
|
74418879a4 | ||
|
|
bedc8d0e31 | ||
|
|
859fd7a695 | ||
|
|
45fc325241 | ||
|
|
aa6a4782ee | ||
|
|
6823a283fd | ||
|
|
4ea2ed94b8 | ||
|
|
81d9d01a78 | ||
|
|
37e9dde1a3 | ||
|
|
bd1472c5f4 | ||
|
|
10050246e9 | ||
|
|
e3edbc05d5 | ||
|
|
4e2826c095 | ||
|
|
dfa1298fe4 | ||
|
|
5eafc13f47 | ||
|
|
3ac539cf13 | ||
|
|
244b2fe6d4 | ||
|
|
5299f99928 | ||
|
|
5f9f03e248 | ||
|
|
4ea8673034 | ||
|
|
17b852aab5 | ||
|
|
9b0776b203 | ||
|
|
1ebe73cf90 | ||
|
|
a3f79b9ca4 | ||
|
|
fd7202c05e | ||
|
|
17db3fff68 | ||
|
|
9e15fb3633 | ||
|
|
e5c3fef1ff | ||
|
|
55758d606c | ||
|
|
15fbf4bff6 | ||
|
|
fef280dceb | ||
|
|
485b70d30b | ||
|
|
57a7e7cf36 | ||
|
|
55feaa46bc | ||
|
|
f52b295206 | ||
|
|
cbb4ebd010 | ||
|
|
b71cd68bac | ||
|
|
817362d1dd | ||
|
|
24e7a1ceec | ||
|
|
e61a38f66a | ||
|
|
cd458e61d3 | ||
|
|
2d1d70a10c | ||
|
|
bd2e350c23 | ||
|
|
bb3d2bce11 | ||
|
|
686f61d725 | ||
|
|
bfd53d721d | ||
|
|
af5f93140e | ||
|
|
ab861f6be5 | ||
|
|
cd68afe29f | ||
|
|
b595e7de53 | ||
|
|
d81ecec836 | ||
|
|
1c6814c2fa | ||
|
|
d6c79a0e47 | ||
|
|
5740293865 | ||
|
|
a3f590a7d2 | ||
|
|
69c3551cc5 | ||
|
|
9889966e29 | ||
|
|
3e32c45cb6 | ||
|
|
4d5abad76e | ||
|
|
d7b90195da | ||
|
|
b9c67baa6a | ||
|
|
4f9ff10741 | ||
|
|
242a28959f | ||
|
|
b2ac63d6ef | ||
|
|
a9c2cbc763 | ||
|
|
64b04e9eba | ||
|
|
43aa3022af | ||
|
|
00e0c54b80 | ||
|
|
1d24bc8a0f | ||
|
|
87766811ad | ||
|
|
59c83249e5 | ||
|
|
2b403e3772 | ||
|
|
43cee13f25 | ||
|
|
5958a006b7 | ||
|
|
87f9d50bb5 | ||
|
|
d75d3d76e7 | ||
|
|
0fc64a0dab | ||
|
|
c4889362d3 | ||
|
|
4c1de148f9 | ||
|
|
a5c9eed6cc | ||
|
|
2e818ce425 | ||
|
|
89e2630eec | ||
|
|
5243a665b1 | ||
|
|
7e5dcb345d | ||
|
|
ee08150eff | ||
|
|
42083b4250 | ||
|
|
99ae4e3863 | ||
|
|
f90fd34b7b | ||
|
|
ddcb1ae97c | ||
|
|
335757dc9d | ||
|
|
40c6caa805 | ||
|
|
6db574c4ba | ||
|
|
523aebc4d5 |
134
.github/workflows/main.yml
vendored
134
.github/workflows/main.yml
vendored
@@ -7,82 +7,120 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- run: sudo apt-get install gifsicle graphicsmagick
|
||||
- uses: actions/checkout@v2.3.3
|
||||
- uses: actions/setup-node@v1.4.4
|
||||
with:
|
||||
node-version: 14
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: bahmutov/npm-install@v1.4.3
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- run: yarn type
|
||||
- run: yarn lint
|
||||
- run: yarn test --ci
|
||||
- run: yarn build:action
|
||||
- run: bun run build
|
||||
working-directory: packages/solver-r
|
||||
|
||||
test-benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
- run: npm run type
|
||||
|
||||
steps:
|
||||
- run: sudo apt-get install gifsicle graphicsmagick
|
||||
- uses: actions/checkout@v2.3.3
|
||||
- uses: actions/setup-node@v1.4.4
|
||||
with:
|
||||
node-version: 14
|
||||
- run: bun test
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: cargo test
|
||||
working-directory: packages/solver-r
|
||||
|
||||
- uses: bahmutov/npm-install@v1.4.3
|
||||
|
||||
- run: ( cd packages/compute ; yarn benchmark )
|
||||
- run: ( cd packages/gif-creator ; yarn benchmark )
|
||||
- run: npm run lint
|
||||
|
||||
test-action:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- run: mkdir dist
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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: snake-gif
|
||||
uses: Platane/snk@master
|
||||
id: generate-snake
|
||||
uses: ./
|
||||
with:
|
||||
github_user_name: platane
|
||||
gif_out_path: dist/github-contribution-grid-snake.gif
|
||||
outputs: |
|
||||
dist/github-contribution-grid-snake.svg
|
||||
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
|
||||
dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
- name: ensure the generated file exists
|
||||
run: |
|
||||
ls -l ${{ steps.snake-gif.outputs.gif_out_path }}
|
||||
test -f ${{ steps.snake-gif.outputs.gif_out_path }}
|
||||
ls dist
|
||||
test -f dist/github-contribution-grid-snake.svg
|
||||
test -f dist/github-contribution-grid-snake-dark.svg
|
||||
test -f dist/github-contribution-grid-snake.gif
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: output
|
||||
path: ${{ steps.snake-gif.outputs.gif_out_path }}
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.1.3
|
||||
- uses: crazy-max/ghaction-github-pages@v4.1.0
|
||||
with:
|
||||
target_branch: output
|
||||
build_dir: dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-action-svg-only:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: build svg-only action
|
||||
run: |
|
||||
npm run build:action
|
||||
rm -r svg-only/dist
|
||||
mv packages/action/dist svg-only/dist
|
||||
|
||||
- name: generate-snake-game-from-github-contribution-grid
|
||||
id: generate-snake
|
||||
uses: ./svg-only
|
||||
with:
|
||||
github_user_name: platane
|
||||
outputs: |
|
||||
dist/github-contribution-grid-snake.svg
|
||||
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
|
||||
dist/github-contribution-grid-snake-blue.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
- name: ensure the generated file exists
|
||||
run: |
|
||||
ls dist
|
||||
test -f dist/github-contribution-grid-snake.svg
|
||||
test -f dist/github-contribution-grid-snake-dark.svg
|
||||
test -f dist/github-contribution-grid-snake-blue.svg
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v4.1.0
|
||||
with:
|
||||
target_branch: output-svg-only
|
||||
build_dir: dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
deploy-ghpages:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.3
|
||||
- uses: actions/setup-node@v1.4.4
|
||||
with:
|
||||
node-version: 14
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
|
||||
- uses: bahmutov/npm-install@v1.4.3
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- run: yarn build:demo
|
||||
- run: npm run build:demo
|
||||
env:
|
||||
BASE_PATHNAME: "snk"
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://github-user-contribution.platane.workers.dev/github-user-contribution/
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.1.3
|
||||
if: success() && github.ref == 'refs/heads/master'
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
path: packages/demo/dist
|
||||
|
||||
- uses: actions/deploy-pages@v4
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
|
||||
- run: bunx wrangler deploy
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
working-directory: packages/github-user-contribution-service
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
47
.github/workflows/manual-run.yml
vendored
Normal file
47
.github/workflows/manual-run.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: manual run
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: Platane/snk/svg-only@v3
|
||||
with:
|
||||
github_user_name: ${{ github.repository_owner }}
|
||||
outputs: |
|
||||
dist/only-svg/github-contribution-grid-snake.svg
|
||||
dist/only-svg/github-contribution-grid-snake-dark.svg?palette=github-dark
|
||||
dist/only-svg/github-contribution-grid-snake-blue.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
- uses: Platane/snk@v3
|
||||
with:
|
||||
github_user_name: ${{ github.repository_owner }}
|
||||
outputs: |
|
||||
dist/docker/github-contribution-grid-snake.svg
|
||||
dist/docker/github-contribution-grid-snake-dark.svg?palette=github-dark
|
||||
dist/docker/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
- name: ensure the generated file exists
|
||||
run: |
|
||||
ls dist
|
||||
test -f dist/only-svg/github-contribution-grid-snake.svg
|
||||
test -f dist/only-svg/github-contribution-grid-snake-dark.svg
|
||||
test -f dist/only-svg/github-contribution-grid-snake-blue.svg
|
||||
|
||||
test -f dist/docker/github-contribution-grid-snake.svg
|
||||
test -f dist/docker/github-contribution-grid-snake-dark.svg
|
||||
test -f dist/docker/github-contribution-grid-snake.gif
|
||||
|
||||
- name: push github-contribution-grid-snake.svg to the output branch
|
||||
uses: crazy-max/ghaction-github-pages@v4.1.0
|
||||
with:
|
||||
target_branch: manual-run-output
|
||||
build_dir: dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
86
.github/workflows/release.yml
vendored
Normal file
86
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: |
|
||||
New version for the release
|
||||
If the version is in format <major>.<minor>.<patch> a new release is emitted.
|
||||
Otherwise for other format ( for example <major>.<minor>.<patch>-beta.1 ), a prerelease is emitted.
|
||||
default: "0.0.1"
|
||||
required: true
|
||||
type: string
|
||||
description:
|
||||
description: "Version description"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: build and publish the docker image
|
||||
uses: docker/build-push-action@v4
|
||||
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: oven-sh/setup-bun@v1
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: build svg-only action
|
||||
run: |
|
||||
npm run build:action
|
||||
rm -r svg-only/dist
|
||||
mv packages/action/dist svg-only/dist
|
||||
|
||||
- name: bump package version
|
||||
run: npm 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 main --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 "prerelease=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "prerelease=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: ncipollo/release-action@v1.15.0
|
||||
with:
|
||||
tag: v${{ github.event.inputs.version }}
|
||||
body: ${{ github.event.inputs.description }}
|
||||
prerelease: ${{ steps.push-tags.outputs.prerelease }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
dist
|
||||
!svg-only/dist
|
||||
build
|
||||
out.gif
|
||||
.env
|
||||
.wrangler
|
||||
.dev.vars
|
||||
target
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -1,19 +1,27 @@
|
||||
FROM node:14-slim
|
||||
FROM oven/bun:1.2.2-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 tsconfig.json package.json yarn.lock /github/snk/
|
||||
COPY packages /github/snk/packages
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
RUN ( \
|
||||
cd /github/snk \
|
||||
&& find . \
|
||||
&& yarn install --frozen-lockfile \
|
||||
&& yarn build:action \
|
||||
&& mv packages/action/dist/* . \
|
||||
&& rm -rf packages tsconfig.json package.json yarn.lock node_modules \
|
||||
)
|
||||
COPY tsconfig.json ./
|
||||
|
||||
CMD ["node", "/github/snk/index.js"]
|
||||
COPY packages packages
|
||||
|
||||
RUN bun install --no-cache
|
||||
|
||||
RUN bun run build:action
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM oven/bun:1.2.2-slim
|
||||
|
||||
WORKDIR /action-release
|
||||
|
||||
RUN bun add canvas@3.1.0 gifsicle@5.3.0 --no-lockfile --no-cache
|
||||
|
||||
COPY --from=builder /app/packages/action/dist/ /action-release/
|
||||
|
||||
CMD ["bun", "/action-release/index.js"]
|
||||
|
||||
89
README.md
89
README.md
@@ -1,12 +1,93 @@
|
||||
# snk
|
||||
|
||||
[](https://github.com/Platane/Platane/actions/workflows/main.yml)
|
||||
[](https://github.com/platane/snk/releases/latest)
|
||||
[](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
|
||||

|
||||

|
||||
|
||||

|
||||
Generates a snake game from a github user contributions graph
|
||||
|
||||
Generates a snake game from a github user contributions grid and output a screen capture as gif
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake-dark.svg"
|
||||
/>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
|
||||
/>
|
||||
<img
|
||||
alt="github contribution grid snake animation"
|
||||
src="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
- [demo](https://platane.github.io/snk/index.html)
|
||||
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.
|
||||
|
||||
- [github action](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
|
||||
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. It can automatically generate a new image each day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
|
||||
|
||||
## Usage
|
||||
|
||||
**github action**
|
||||
|
||||
```yaml
|
||||
- uses: Platane/snk@v3
|
||||
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 }}
|
||||
|
||||
# list of files to generate.
|
||||
# one file per line. Each output can be customized with options as query string.
|
||||
#
|
||||
# supported options:
|
||||
# - palette: A preset of color, one of [github, github-dark, github-light]
|
||||
# - color_snake: Color of the snake
|
||||
# - color_dots: Coma separated list of dots color.
|
||||
# The first one is 0 contribution, then it goes from the low contribution to the highest.
|
||||
# Exactly 5 colors are expected.
|
||||
outputs: |
|
||||
dist/github-snake.svg
|
||||
dist/github-snake-dark.svg?palette=github-dark
|
||||
dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
```
|
||||
|
||||
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L26-L33)
|
||||
|
||||
If you are only interested in generating a svg, consider using this faster action: `uses: Platane/snk/svg-only@v3`
|
||||
|
||||
**dark mode**
|
||||
|
||||
For **dark mode** support on github, use this [special syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to) in your readme.
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="github-snake-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="github-snake.svg" />
|
||||
<img alt="github-snake" src="github-snake.svg" />
|
||||
</picture>
|
||||
```
|
||||
|
||||
**interactive demo**
|
||||
|
||||
<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)
|
||||
|
||||
34
action.yml
34
action.yml
@@ -1,20 +1,34 @@
|
||||
name: "generate-snake-game-from-github-contribution-grid"
|
||||
description: "Generates a snake game from a github user contributions grid and output a screen capture as gif"
|
||||
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: "path of the generated gif"
|
||||
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
using: docker
|
||||
image: docker://platane/snk@sha256:96390294299275740e5963058c9784c60c5393b3b8b16082dcf41b240db791f9
|
||||
|
||||
inputs:
|
||||
github_user_name:
|
||||
description: "github user name"
|
||||
required: true
|
||||
gif_out_path:
|
||||
description: "path of the generated gif"
|
||||
github_token:
|
||||
description: "github token used to fetch the contribution calendar. Default to the action token if empty."
|
||||
required: false
|
||||
default: "./github-contribution-grid-snake.gif"
|
||||
default: ${{ github.token }}
|
||||
outputs:
|
||||
required: false
|
||||
description: |
|
||||
list of files to generate.
|
||||
one file per line. Each output can be customized with options as query string.
|
||||
|
||||
supported query string options:
|
||||
|
||||
- palette: A preset of color, one of [github, github-dark, github-light]
|
||||
- color_snake: Color of the snake
|
||||
- color_dots: Coma separated list of dots color.
|
||||
The first one is 0 contribution, then it goes from the low contribution to the highest.
|
||||
Exactly 5 colors are expected.
|
||||
example:
|
||||
outputs: |
|
||||
dark.svg?palette=github-dark&color_snake=blue
|
||||
light.svg?color_snake=#7845ab
|
||||
ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["**/__tests__/**/?(*.)+(spec|test).ts"],
|
||||
};
|
||||
29
package.json
29
package.json
@@ -1,26 +1,25 @@
|
||||
{
|
||||
"name": "snk",
|
||||
"description": "Generates a snake game from a github user contributions grid and output a screen capture as gif",
|
||||
"version": "1.0.0",
|
||||
"description": "Generates a snake game from a github user contributions grid",
|
||||
"version": "3.3.0",
|
||||
"private": true,
|
||||
"repository": "github:platane/snk",
|
||||
"devDependencies": {
|
||||
"@types/jest": "26.0.14",
|
||||
"@types/node": "14.11.8",
|
||||
"jest": "26.5.2",
|
||||
"prettier": "2.1.2",
|
||||
"ts-jest": "26.4.1",
|
||||
"typescript": "4.0.3"
|
||||
"@types/bun": "1.2.2",
|
||||
"prettier": "3.5.1",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/**"
|
||||
"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'",
|
||||
"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 )"
|
||||
}
|
||||
"lint": "prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**' '!svg-only/dist/**'",
|
||||
"dev:demo": "( cd packages/demo ; npm run dev )",
|
||||
"build:demo": "( cd packages/demo ; npm run build )",
|
||||
"build:action": "( cd packages/action ; npm run build )"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"wasm-pack"
|
||||
]
|
||||
}
|
||||
|
||||
13
packages/action/README.md
Normal file
13
packages/action/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @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 )
|
||||
3
packages/action/__tests__/__snapshots__/.gitignore
vendored
Normal file
3
packages/action/__tests__/__snapshots__/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!*.snap
|
||||
@@ -0,0 +1,120 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should parse /out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]} 1`] = `
|
||||
{
|
||||
"animationOptions": {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "yellow",
|
||||
"dark": undefined,
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444 1`] = `
|
||||
{
|
||||
"animationOptions": {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "orange",
|
||||
"dark": undefined,
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44 1`] = `
|
||||
{
|
||||
"animationOptions": {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "orange",
|
||||
"dark": {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": [
|
||||
"#a00",
|
||||
"#a11",
|
||||
"#a22",
|
||||
"#a33",
|
||||
"#a44",
|
||||
],
|
||||
"colorEmpty": "#a00",
|
||||
"colorSnake": "orange",
|
||||
},
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse path/to/out.gif 1`] = `
|
||||
{
|
||||
"animationOptions": {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
],
|
||||
"colorEmpty": "#ebedf0",
|
||||
"colorSnake": "purple",
|
||||
"dark": undefined,
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "path/to/out.gif",
|
||||
"format": "gif",
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
|
||||
generateContributionSnake("platane").then((buffer) => {
|
||||
process.stdout.write(buffer);
|
||||
});
|
||||
45
packages/action/__tests__/generateContributionSnake.spec.ts
Normal file
45
packages/action/__tests__/generateContributionSnake.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { it, expect } from "bun:test";
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
import { parseOutputsOption } from "../outputsOptions";
|
||||
|
||||
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 entries = [
|
||||
path.join(__dirname, "__snapshots__/out.svg"),
|
||||
|
||||
path.join(__dirname, "__snapshots__/out-dark.svg") +
|
||||
"?palette=github-dark&color_snake=orange",
|
||||
|
||||
path.join(__dirname, "__snapshots__/out.gif") +
|
||||
"?color_snake=orange&color_dots=#d4e0f0,#8dbdff,#64a1f4,#4b91f1,#3c7dd9",
|
||||
];
|
||||
|
||||
const outputs = parseOutputsOption(entries);
|
||||
|
||||
const results = await generateContributionSnake("platane", outputs, {
|
||||
githubToken: process.env.GITHUB_TOKEN!,
|
||||
});
|
||||
|
||||
expect(results[0]).toBeDefined();
|
||||
expect(results[1]).toBeDefined();
|
||||
expect(results[2]).toBeDefined();
|
||||
|
||||
fs.writeFileSync(outputs[0]!.filename, results[0]!);
|
||||
fs.writeFileSync(outputs[1]!.filename, results[1]!);
|
||||
fs.writeFileSync(outputs[2]!.filename, results[2]!);
|
||||
}),
|
||||
{ timeout: 2 * 60 * 1000 },
|
||||
);
|
||||
61
packages/action/__tests__/outputsOptions.spec.ts
Normal file
61
packages/action/__tests__/outputsOptions.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { parseEntry } from "../outputsOptions";
|
||||
import { it, expect } from "bun:test";
|
||||
|
||||
it("should parse options as json", () => {
|
||||
expect(
|
||||
parseEntry(`/out.svg {"color_snake":"yellow"}`)?.drawOptions,
|
||||
).toHaveProperty("colorSnake", "yellow");
|
||||
|
||||
expect(
|
||||
parseEntry(`/out.svg?{"color_snake":"yellow"}`)?.drawOptions,
|
||||
).toHaveProperty("colorSnake", "yellow");
|
||||
|
||||
expect(
|
||||
parseEntry(`/out.svg?{"color_dots":["#000","#111","#222","#333","#444"]}`)
|
||||
?.drawOptions.colorDots,
|
||||
).toEqual(["#000", "#111", "#222", "#333", "#444"]);
|
||||
});
|
||||
|
||||
it("should parse options as searchparams", () => {
|
||||
expect(parseEntry(`/out.svg?color_snake=yellow`)?.drawOptions).toHaveProperty(
|
||||
"colorSnake",
|
||||
"yellow",
|
||||
);
|
||||
|
||||
expect(
|
||||
parseEntry(`/out.svg?color_dots=#000,#111,#222,#333,#444`)?.drawOptions
|
||||
.colorDots,
|
||||
).toEqual(["#000", "#111", "#222", "#333", "#444"]);
|
||||
});
|
||||
|
||||
it("should parse filename", () => {
|
||||
expect(parseEntry(`/a/b/c.svg?{"color_snake":"yellow"}`)).toHaveProperty(
|
||||
"filename",
|
||||
"/a/b/c.svg",
|
||||
);
|
||||
expect(
|
||||
parseEntry(`/a/b/out.svg?.gif.svg?{"color_snake":"yellow"}`),
|
||||
).toHaveProperty("filename", "/a/b/out.svg?.gif.svg");
|
||||
|
||||
expect(
|
||||
parseEntry(`/a/b/{[-1].svg?.gif.svg?{"color_snake":"yellow"}`),
|
||||
).toHaveProperty("filename", "/a/b/{[-1].svg?.gif.svg");
|
||||
});
|
||||
|
||||
[
|
||||
// default
|
||||
"path/to/out.gif",
|
||||
|
||||
// overwrite colors (search params)
|
||||
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444",
|
||||
|
||||
// overwrite colors (json)
|
||||
`/out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]}`,
|
||||
|
||||
// overwrite dark colors
|
||||
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44",
|
||||
].forEach((entry) =>
|
||||
it(`should parse ${entry}`, () => {
|
||||
expect(parseEntry(entry)).toMatchSnapshot();
|
||||
}),
|
||||
);
|
||||
@@ -1,37 +1,52 @@
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import { createGif } from "@snk/gif-creator";
|
||||
import { createSnake } from "@snk/compute/snake";
|
||||
import { getBestRoute } from "@snk/compute/getBestRoute";
|
||||
import { userContributionToGrid } from "./userContributionToGrid";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { snake4 } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
export const generateContributionSnake = async (userName: string) => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution(userName);
|
||||
export const generateContributionSnake = async (
|
||||
userName: string,
|
||||
outputs: ({
|
||||
format: "svg" | "gif";
|
||||
drawOptions: DrawOptions;
|
||||
animationOptions: AnimationOptions;
|
||||
} | null)[],
|
||||
options: { githubToken: string },
|
||||
) => {
|
||||
console.log("🎣 fetching github user contribution");
|
||||
const cells = await getGithubUserContribution(userName, options);
|
||||
|
||||
const grid0 = userContributionToGrid(cells);
|
||||
const grid = userContributionToGrid(cells);
|
||||
const snake = snake4;
|
||||
|
||||
const snake0 = createSnake([
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
]);
|
||||
console.log("📡 computing best route");
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: colorScheme,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 2 };
|
||||
|
||||
const chain = getBestRoute(grid0, snake0)!;
|
||||
|
||||
const buffer = await createGif(grid0, chain, drawOptions, gifOptions);
|
||||
|
||||
return buffer;
|
||||
return Promise.all(
|
||||
outputs.map(async (out, i) => {
|
||||
if (!out) return;
|
||||
const { format, drawOptions, animationOptions } = out;
|
||||
switch (format) {
|
||||
case "svg": {
|
||||
console.log(`🖌 creating svg (outputs[${i}])`);
|
||||
const { createSvg } = await import("@snk/svg-creator");
|
||||
return createSvg(grid, cells, chain, drawOptions, animationOptions);
|
||||
}
|
||||
case "gif": {
|
||||
console.log(`📹 creating gif (outputs[${i}])`);
|
||||
const { createGif } = await import("@snk/gif-creator");
|
||||
return await createGif(
|
||||
grid,
|
||||
cells,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as core from "@actions/core";
|
||||
import { generateContributionSnake } from "./generateContributionSnake";
|
||||
import { parseOutputsOption } from "./outputsOptions";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const userName = core.getInput("github_user_name");
|
||||
const gifOutPath = core.getInput("gif_out_path");
|
||||
const outputs = parseOutputsOption(
|
||||
core.getMultilineInput("outputs") ?? [
|
||||
core.getInput("gif_out_path"),
|
||||
core.getInput("svg_out_path"),
|
||||
],
|
||||
);
|
||||
const githubToken =
|
||||
process.env.GITHUB_TOKEN ?? core.getInput("github_token");
|
||||
|
||||
const buffer = await generateContributionSnake(userName);
|
||||
const { generateContributionSnake } = await import(
|
||||
"./generateContributionSnake"
|
||||
);
|
||||
const results = await generateContributionSnake(userName, outputs, {
|
||||
githubToken,
|
||||
});
|
||||
|
||||
fs.writeFileSync(gifOutPath, buffer);
|
||||
|
||||
console.log(`::set-output name=gif_out_path::${gifOutPath}`);
|
||||
} catch (e) {
|
||||
outputs.forEach((out, i) => {
|
||||
const result = results[i];
|
||||
if (out?.filename && result) {
|
||||
console.log(`💾 writing to ${out?.filename}`);
|
||||
fs.mkdirSync(path.dirname(out?.filename), { recursive: true });
|
||||
fs.writeFileSync(out?.filename, result);
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
core.setFailed(`Action failed with "${e.message}"`);
|
||||
}
|
||||
})();
|
||||
|
||||
86
packages/action/outputsOptions.ts
Normal file
86
packages/action/outputsOptions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
import { palettes } from "./palettes";
|
||||
|
||||
export const parseOutputsOption = (lines: string[]) => lines.map(parseEntry);
|
||||
|
||||
export const parseEntry = (entry: string) => {
|
||||
const m = entry.trim().match(/^(.+\.(svg|gif))(\?(.*)|\s*({.*}))?$/);
|
||||
|
||||
if (!m) return null;
|
||||
|
||||
const [, filename, format, _, q1, q2] = m;
|
||||
|
||||
const query = q1 ?? q2;
|
||||
|
||||
let sp = new URLSearchParams(query || "");
|
||||
|
||||
try {
|
||||
const o = JSON.parse(query);
|
||||
|
||||
if (Array.isArray(o.color_dots)) o.color_dots = o.color_dots.join(",");
|
||||
if (Array.isArray(o.dark_color_dots))
|
||||
o.dark_color_dots = o.dark_color_dots.join(",");
|
||||
|
||||
sp = new URLSearchParams(o);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SyntaxError)) throw err;
|
||||
}
|
||||
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
...palettes["default"],
|
||||
dark: palettes["default"].dark && { ...palettes["default"].dark },
|
||||
};
|
||||
const animationOptions: AnimationOptions = { step: 1, frameDuration: 100 };
|
||||
|
||||
{
|
||||
const palette = palettes[sp.get("palette")!];
|
||||
if (palette) {
|
||||
Object.assign(drawOptions, palette);
|
||||
drawOptions.dark = palette.dark && { ...palette.dark };
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const dark_palette = palettes[sp.get("dark_palette")!];
|
||||
if (dark_palette) {
|
||||
const clone = { ...dark_palette, dark: undefined };
|
||||
drawOptions.dark = clone;
|
||||
}
|
||||
}
|
||||
|
||||
if (sp.has("color_snake")) drawOptions.colorSnake = sp.get("color_snake")!;
|
||||
if (sp.has("color_dots")) {
|
||||
const colors = sp.get("color_dots")!.split(/[,;]/);
|
||||
drawOptions.colorDots = colors;
|
||||
drawOptions.colorEmpty = colors[0];
|
||||
drawOptions.dark = undefined;
|
||||
}
|
||||
if (sp.has("color_dot_border"))
|
||||
drawOptions.colorDotBorder = sp.get("color_dot_border")!;
|
||||
|
||||
if (sp.has("dark_color_dots")) {
|
||||
const colors = sp.get("dark_color_dots")!.split(/[,;]/);
|
||||
drawOptions.dark = {
|
||||
colorDotBorder: drawOptions.colorDotBorder,
|
||||
colorSnake: drawOptions.colorSnake,
|
||||
...drawOptions.dark,
|
||||
colorDots: colors,
|
||||
colorEmpty: colors[0],
|
||||
};
|
||||
}
|
||||
if (sp.has("dark_color_dot_border") && drawOptions.dark)
|
||||
drawOptions.dark.colorDotBorder = sp.get("color_dot_border")!;
|
||||
if (sp.has("dark_color_snake") && drawOptions.dark)
|
||||
drawOptions.dark.colorSnake = sp.get("color_snake")!;
|
||||
|
||||
return {
|
||||
filename,
|
||||
format: format as "svg" | "gif",
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
};
|
||||
};
|
||||
@@ -2,15 +2,17 @@
|
||||
"name": "@snk/action",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "1.2.6",
|
||||
"@actions/core": "1.11.1",
|
||||
"@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.38.3"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
27
packages/action/palettes.ts
Normal file
27
packages/action/palettes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
|
||||
export const basePalettes: Record<
|
||||
string,
|
||||
Pick<
|
||||
DrawOptions,
|
||||
"colorDotBorder" | "colorEmpty" | "colorSnake" | "colorDots" | "dark"
|
||||
>
|
||||
> = {
|
||||
"github-light": {
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorDots: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
},
|
||||
"github-dark": {
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: ["#161b22", "#01311f", "#034525", "#0f6d31", "#00c647"],
|
||||
colorSnake: "purple",
|
||||
},
|
||||
};
|
||||
|
||||
// aliases
|
||||
export const palettes = { ...basePalettes };
|
||||
palettes["github"] = palettes["github-light"];
|
||||
palettes["default"] = palettes["github"];
|
||||
@@ -1,13 +1,16 @@
|
||||
import { setColor, createEmptyGrid } from "@snk/compute/grid";
|
||||
import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid";
|
||||
import type { Cell } from "@snk/github-user-contribution";
|
||||
import type { Color } from "@snk/compute/grid";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
|
||||
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 = createEmptyGrid(width, height);
|
||||
for (const c of cells) if (c.k) setColor(grid, c.x, c.y, c.k as Color);
|
||||
for (const c of cells) {
|
||||
if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color);
|
||||
else setColorEmpty(grid, c.x, c.y);
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// @ts-ignore
|
||||
import * as ParkMiller from "park-miller";
|
||||
import { Color, createEmptyGrid, setColor } from "@snk/compute/grid";
|
||||
import { fillRandomGrid } from "../generateGrid";
|
||||
|
||||
const colors = [1, 2, 3] as Color[];
|
||||
|
||||
// empty small grid
|
||||
export const empty = createEmptyGrid(5, 5);
|
||||
|
||||
// empty small grid with a unique color at the middle
|
||||
export const simple = createEmptyGrid(5, 5);
|
||||
setColor(simple, 2, 2, 1 as Color);
|
||||
|
||||
// empty small grid with color at each corner
|
||||
export const corner = createEmptyGrid(5, 5);
|
||||
setColor(corner, 0, 4, 1 as Color);
|
||||
setColor(corner, 4, 0, 1 as Color);
|
||||
setColor(corner, 4, 4, 1 as Color);
|
||||
setColor(corner, 0, 0, 1 as Color);
|
||||
|
||||
// enclaved color
|
||||
export const enclave = createEmptyGrid(7, 7);
|
||||
setColor(enclave, 3, 4, 2 as Color);
|
||||
setColor(enclave, 2, 3, 2 as Color);
|
||||
setColor(enclave, 2, 4, 2 as Color);
|
||||
setColor(enclave, 4, 4, 2 as Color);
|
||||
setColor(enclave, 4, 3, 2 as Color);
|
||||
setColor(enclave, 3, 3, 1 as Color);
|
||||
setColor(enclave, 5, 5, 1 as Color);
|
||||
|
||||
// enclaved color
|
||||
export const enclaveBorder = createEmptyGrid(7, 7);
|
||||
setColor(enclaveBorder, 1, 0, 3 as Color);
|
||||
setColor(enclaveBorder, 2, 1, 3 as Color);
|
||||
setColor(enclaveBorder, 3, 0, 3 as Color);
|
||||
setColor(enclaveBorder, 2, 0, 1 as Color);
|
||||
|
||||
const create = (width: number, height: number, emptyP: number) => {
|
||||
const grid = createEmptyGrid(width, height);
|
||||
const random = new ParkMiller(10);
|
||||
const rand = (a: number, b: number) => random.integerInRange(a, b - 1);
|
||||
fillRandomGrid(grid, { colors, emptyP }, rand);
|
||||
return grid;
|
||||
};
|
||||
|
||||
// small realistic
|
||||
export const small = create(10, 7, 3);
|
||||
export const smallPacked = create(10, 7, 1);
|
||||
export const smallFull = create(10, 7, 0);
|
||||
|
||||
// small realistic
|
||||
export const realistic = create(52, 7, 3);
|
||||
export const realisticFull = create(52, 7, 0);
|
||||
@@ -1,29 +0,0 @@
|
||||
import { realistic as grid } from "../__fixtures__/grid";
|
||||
import { snake3 } from "../__fixtures__/snake";
|
||||
import { performance } from "perf_hooks";
|
||||
import { getAvailableRoutes } from "../getAvailableRoutes";
|
||||
import { getBestRoute } from "../getBestRoute";
|
||||
|
||||
{
|
||||
const m = 100;
|
||||
const s = performance.now();
|
||||
for (let k = m; k--; ) {
|
||||
const solutions = [];
|
||||
|
||||
getAvailableRoutes(grid, snake3, (snakes) => {
|
||||
solutions.push(snakes);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
console.log("getAvailableRoutes", (performance.now() - s) / m, "ms");
|
||||
}
|
||||
|
||||
{
|
||||
const m = 10;
|
||||
const s = performance.now();
|
||||
for (let k = m; k--; ) {
|
||||
getBestRoute(grid, snake3);
|
||||
}
|
||||
|
||||
console.log("getBestRoute", (performance.now() - s) / m, "ms");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { getBestRoute } from "../getBestRoute";
|
||||
import { Color, createEmptyGrid, setColor } from "../grid";
|
||||
import { createSnake, snakeToCells } from "../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, createSnake(snk0))!;
|
||||
|
||||
expect(snakeToCells(chain[0])[1]).toEqual({ x: 0, y: 0 });
|
||||
|
||||
expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 });
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { copyGrid, isEmpty, setColorEmpty } from "./grid";
|
||||
import { getHeadX, getHeadY, snakeEquals } from "./snake";
|
||||
import { sortPush } from "./utils/sortPush";
|
||||
import { arrayEquals } from "./utils/array";
|
||||
import { getAvailableRoutes } from "./getAvailableRoutes";
|
||||
import type { Snake } from "./snake";
|
||||
import type { Grid } from "./grid";
|
||||
import type { Point } from "./point";
|
||||
|
||||
type M = {
|
||||
snake: Snake;
|
||||
chain: Snake[];
|
||||
chunk: Point[];
|
||||
grid: Grid;
|
||||
parent: M | null;
|
||||
w: number;
|
||||
h: number;
|
||||
f: number;
|
||||
};
|
||||
const unwrap = (o: M | null): Snake[] =>
|
||||
!o ? [] : [...o.chain, ...unwrap(o.parent)];
|
||||
|
||||
const createGetHeuristic = (grid: Grid, chunk0: Point[]) => {
|
||||
const n = grid.data.reduce((sum, x: any) => sum + +!isEmpty(x), 0);
|
||||
const area = grid.width * grid.height;
|
||||
|
||||
const k =
|
||||
Math.sqrt((2 * area) / chunk0.length) * 1 + (n - chunk0.length) / area;
|
||||
|
||||
return (chunk: any[]) => chunk.length * k;
|
||||
};
|
||||
|
||||
export const getAvailableWhiteListedRoutes = (
|
||||
grid: Grid,
|
||||
snake: Snake,
|
||||
whiteList: Point[]
|
||||
) => {
|
||||
let solution: Snake[] | null;
|
||||
|
||||
getAvailableRoutes(grid, snake, (chain) => {
|
||||
const hx = getHeadX(chain[0]);
|
||||
const hy = getHeadY(chain[0]);
|
||||
|
||||
if (!whiteList.some(({ x, y }) => hx === x && hy === y)) return false;
|
||||
|
||||
solution = chain;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
return solution;
|
||||
};
|
||||
|
||||
export const cleanLayer = (grid0: Grid, snake0: Snake, chunk0: Point[]) => {
|
||||
const getH = createGetHeuristic(grid0, chunk0);
|
||||
|
||||
const next = {
|
||||
grid: grid0,
|
||||
snake: snake0,
|
||||
chain: [snake0],
|
||||
chunk: chunk0,
|
||||
parent: null,
|
||||
h: getH(chunk0),
|
||||
f: getH(chunk0),
|
||||
w: 0,
|
||||
};
|
||||
|
||||
const openList: M[] = [next];
|
||||
const closeList: M[] = [next];
|
||||
|
||||
while (openList.length) {
|
||||
const o = openList.shift()!;
|
||||
|
||||
if (o.chunk.length === 0) return unwrap(o).slice(0, -1);
|
||||
|
||||
const chain = getAvailableWhiteListedRoutes(o.grid, o.snake, o.chunk);
|
||||
|
||||
if (chain) {
|
||||
const snake = chain[0];
|
||||
const x = getHeadX(snake);
|
||||
const y = getHeadY(snake);
|
||||
|
||||
const chunk = o.chunk.filter((u) => u.x !== x || u.y !== y);
|
||||
|
||||
if (
|
||||
!closeList.some(
|
||||
(u) => snakeEquals(u.snake, snake) && arrayEquals(u.chunk, chunk)
|
||||
)
|
||||
) {
|
||||
const grid = copyGrid(o.grid);
|
||||
setColorEmpty(grid, x, y);
|
||||
|
||||
const h = getH(chunk);
|
||||
const w = o.w + chain.length;
|
||||
const f = h + w;
|
||||
|
||||
const next = { snake, chain, chunk, grid, parent: o, h, w, f };
|
||||
sortPush(openList, next, (a, b) => a.f - b.f);
|
||||
closeList.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// export const getAvailableWhiteListedRoutes = (
|
||||
// grid: Grid,
|
||||
// snake: Snake,
|
||||
// whiteList0: Point[],
|
||||
// n = 3
|
||||
// ) => {
|
||||
// const whiteList = whiteList0.slice();
|
||||
// const solutions: Snake[][] = [];
|
||||
|
||||
// getAvailableRoutes(grid, snake, (chain) => {
|
||||
// const hx = getHeadX(chain[0]);
|
||||
// const hy = getHeadY(chain[0]);
|
||||
|
||||
// const i = whiteList.findIndex(({ x, y }) => hx === x && hy === y);
|
||||
|
||||
// if (i >= 0) {
|
||||
// whiteList.splice(i, 1);
|
||||
// solutions.push(chain);
|
||||
|
||||
// if (solutions.length >= n || whiteList.length === 0) return true;
|
||||
// }
|
||||
|
||||
// return false;
|
||||
// });
|
||||
|
||||
// return solutions;
|
||||
// };
|
||||
@@ -1,57 +0,0 @@
|
||||
import { isInsideLarge, getColor, isInside, isEmpty } from "./grid";
|
||||
import { around4 } from "./point";
|
||||
import {
|
||||
getHeadX,
|
||||
getHeadY,
|
||||
nextSnake,
|
||||
snakeEquals,
|
||||
snakeWillSelfCollide,
|
||||
} from "./snake";
|
||||
import { sortPush } from "./utils/sortPush";
|
||||
import type { Snake } from "./snake";
|
||||
import type { Grid, Color } from "./grid";
|
||||
|
||||
/**
|
||||
* get routes leading to non-empty cells until onSolution returns true
|
||||
*/
|
||||
export const getAvailableRoutes = (
|
||||
grid: Grid,
|
||||
snake0: Snake,
|
||||
onSolution: (snakes: Snake[], color: Color) => boolean
|
||||
) => {
|
||||
const openList: Snake[][] = [[snake0]];
|
||||
const closeList: Snake[] = [];
|
||||
|
||||
while (openList.length) {
|
||||
const c = openList.shift()!;
|
||||
const [snake] = c;
|
||||
|
||||
const cx = getHeadX(snake);
|
||||
const cy = getHeadY(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 (
|
||||
isInsideLarge(grid, 1, nx, ny) &&
|
||||
!snakeWillSelfCollide(snake, dx, dy)
|
||||
) {
|
||||
const nsnake = nextSnake(snake, dx, dy);
|
||||
|
||||
if (!closeList.some((s) => snakeEquals(nsnake, s))) {
|
||||
const color = isInside(grid, nx, ny) && getColor(grid, nx, ny);
|
||||
|
||||
if (!color || isEmpty(color)) {
|
||||
sortPush(openList, [nsnake, ...c], (a, b) => a.length - b.length);
|
||||
closeList.push(nsnake);
|
||||
} else {
|
||||
if (onSolution([nsnake, ...c.slice(0, -1)], color)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { copyGrid, extractColors } from "./grid";
|
||||
import type { Snake } from "./snake";
|
||||
import type { Grid } from "./grid";
|
||||
import { pruneLayer } from "./pruneLayer";
|
||||
import { cleanLayer } from "./cleanLayer";
|
||||
|
||||
export const getBestRoute = (grid0: Grid, snake0: Snake) => {
|
||||
const grid = copyGrid(grid0);
|
||||
const colors = extractColors(grid0);
|
||||
const snakeN = snake0.length / 2;
|
||||
|
||||
const chain: Snake[] = [snake0];
|
||||
|
||||
for (const color of colors) {
|
||||
const gridN = copyGrid(grid);
|
||||
const chunk = pruneLayer(grid, color, snakeN);
|
||||
const c = cleanLayer(gridN, chain[0], chunk);
|
||||
if (c) chain.unshift(...c);
|
||||
}
|
||||
|
||||
return chain.reverse().slice(1);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@snk/compute",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@zeit/ncc": "0.22.3",
|
||||
"park-miller": "1.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { getColor, isEmpty, isInside, setColorEmpty } from "./grid";
|
||||
import { around4 } from "./point";
|
||||
import { sortPush } from "./utils/sortPush";
|
||||
import type { Color, Grid } from "./grid";
|
||||
import type { Point } from "./point";
|
||||
|
||||
type M = Point & { parent: M | null; h: number };
|
||||
|
||||
const unwrap = (grid: Grid, m: M | null): Point[] =>
|
||||
m ? [...unwrap(grid, m.parent), m] : [];
|
||||
|
||||
const getEscapePath = (
|
||||
grid: Grid,
|
||||
x: number,
|
||||
y: number,
|
||||
color: Color,
|
||||
forbidden: Point[] = []
|
||||
) => {
|
||||
const openList: M[] = [{ x, y, h: 0, parent: null }];
|
||||
const closeList: Point[] = [];
|
||||
|
||||
while (openList.length) {
|
||||
const c = openList.shift()!;
|
||||
|
||||
if (c.y === -1 || c.y === grid.height) return unwrap(grid, c);
|
||||
|
||||
for (const a of around4) {
|
||||
const x = c.x + a.x;
|
||||
const y = c.y + a.y;
|
||||
|
||||
if (!forbidden.some((cl) => cl.x === x && cl.y === y)) {
|
||||
if (!isInside(grid, x, y))
|
||||
return unwrap(grid, { x, y, parent: c } as any);
|
||||
|
||||
const u = getColor(grid, x, y);
|
||||
|
||||
if (
|
||||
(isEmpty(u) || u <= color) &&
|
||||
!closeList.some((cl) => cl.x === x && cl.y === y)
|
||||
) {
|
||||
const h = Math.abs(grid.height / 2 - y);
|
||||
const o = { x, y, parent: c, h };
|
||||
|
||||
sortPush(openList, o, (a, b) => a.h - b.h);
|
||||
closeList.push(o);
|
||||
openList.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isFree = (
|
||||
grid: Grid,
|
||||
x: number,
|
||||
y: number,
|
||||
color: Color,
|
||||
snakeN: number
|
||||
) => {
|
||||
const one = getEscapePath(grid, x, y, color);
|
||||
|
||||
if (!one) return false;
|
||||
|
||||
const two = getEscapePath(grid, x, y, color, one.slice(0, snakeN));
|
||||
|
||||
return !!two;
|
||||
};
|
||||
|
||||
export const pruneLayer = (grid: Grid, color: Color, snakeN: number) => {
|
||||
const chunk: Point[] = [];
|
||||
|
||||
for (let x = grid.width; x--; )
|
||||
for (let y = grid.height; y--; ) {
|
||||
const c = getColor(grid, x, y);
|
||||
|
||||
if (!isEmpty(c) && c <= color && isFree(grid, x, y, color, snakeN)) {
|
||||
setColorEmpty(grid, x, y);
|
||||
chunk.push({ x, y });
|
||||
}
|
||||
}
|
||||
|
||||
return chunk;
|
||||
};
|
||||
1
packages/demo/.gitignore
vendored
1
packages/demo/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
webpack.config.js
|
||||
3
packages/demo/README.md
Normal file
3
packages/demo/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @snk/demo
|
||||
|
||||
Contains various demo to test and validate some pieces of the algorithm.
|
||||
@@ -1,23 +1,38 @@
|
||||
import { Color, Grid } from "@snk/compute/grid";
|
||||
import { Color, Grid } from "@snk/types/grid";
|
||||
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Snake } from "@snk/compute/snake";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
|
||||
export const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
export const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorDots: {
|
||||
1: "#9be9a8",
|
||||
2: "#40c463",
|
||||
3: "#30a14e",
|
||||
4: "#216e39",
|
||||
5: "orange",
|
||||
},
|
||||
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,
|
||||
@@ -33,15 +48,27 @@ export const createCanvas = ({
|
||||
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);
|
||||
drawWorld(ctx, grid, null, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const drawLerp = (
|
||||
@@ -49,11 +76,24 @@ export const createCanvas = ({
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number
|
||||
k: number,
|
||||
) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
drawLerpWorld(ctx, grid, null, snake0, snake1, stack, k, drawOptions);
|
||||
};
|
||||
|
||||
return { draw, drawLerp, canvas, ctx };
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createCanvas } from "./canvas";
|
||||
import { snakeToCells } from "@snk/compute/snake";
|
||||
import { GUI } from "dat.gui";
|
||||
import { grid, snake } from "./sample";
|
||||
import { getAvailableRoutes } from "@snk/compute/getAvailableRoutes";
|
||||
import type { Point } from "@snk/compute/point";
|
||||
import type { Snake } from "@snk/compute/snake";
|
||||
|
||||
//
|
||||
// compute
|
||||
|
||||
const routes: Snake[][] = [];
|
||||
getAvailableRoutes(grid, snake, (chain) => {
|
||||
routes.push(chain);
|
||||
return routes.length > 10;
|
||||
});
|
||||
|
||||
const config = { routeN: 0, routeK: 0 };
|
||||
|
||||
//
|
||||
// draw
|
||||
|
||||
const { canvas, ctx, draw } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
draw(grid, snake, []);
|
||||
|
||||
let cancel: number;
|
||||
|
||||
const mod = (x: number, m: number) => ((x % m) + m) % m;
|
||||
|
||||
const onChange = () => {
|
||||
const t = Math.floor(Date.now() / 300);
|
||||
|
||||
cancelAnimationFrame(cancel);
|
||||
cancel = requestAnimationFrame(onChange);
|
||||
|
||||
const chain = routes[config.routeN] || [snake];
|
||||
|
||||
draw(grid, chain[mod(-t, chain.length)], []);
|
||||
|
||||
const cells: Point[] = [];
|
||||
chain.forEach((s) => cells.push(...snakeToCells(s)));
|
||||
|
||||
ctx.fillStyle = "orange";
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
|
||||
cells
|
||||
.filter((x, i, arr) => i === arr.indexOf(x))
|
||||
.forEach((c) => {
|
||||
ctx.beginPath();
|
||||
ctx.fillRect((1 + c.x + 0.5) * 16 - 2, (2 + c.y + 0.5) * 16 - 2, 4, 4);
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// ui
|
||||
|
||||
const gui = new GUI();
|
||||
gui.add(config, "routeN", 0, routes.length - 1, 1).onChange(onChange);
|
||||
|
||||
onChange();
|
||||
@@ -1,57 +1,41 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { getBestRoute } from "@snk/compute/getBestRoute";
|
||||
import { Color, copyGrid } from "../compute/grid";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { Color, copyGrid } from "@snk/types/grid";
|
||||
import { grid, snake } from "./sample";
|
||||
import { step } from "@snk/compute/step";
|
||||
import { isStableAndBound, stepSpring } from "./springUtils";
|
||||
import { step } from "@snk/solver/step";
|
||||
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
|
||||
//
|
||||
// draw
|
||||
let k = 0;
|
||||
|
||||
const spring = { x: 0, v: 0, target: 0 };
|
||||
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
|
||||
let animationFrame: number;
|
||||
|
||||
const { canvas, drawLerp } = createCanvas(grid);
|
||||
const { canvas, draw } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||
const onChange = () => {
|
||||
const gridN = copyGrid(grid);
|
||||
const stack: Color[] = [];
|
||||
for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]);
|
||||
|
||||
const loop = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
|
||||
stepSpring(spring, springParams, spring.target);
|
||||
const stable = isStableAndBound(spring, spring.target);
|
||||
|
||||
const grid0 = copyGrid(grid);
|
||||
const stack0: Color[] = [];
|
||||
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
|
||||
step(grid0, stack0, 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;
|
||||
|
||||
drawLerp(grid0, snake0, snake1, stack0, k);
|
||||
|
||||
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||
draw(gridN, chain[k], stack);
|
||||
};
|
||||
|
||||
loop();
|
||||
onChange();
|
||||
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.max = chain.length - 1;
|
||||
input.style.width = "90%";
|
||||
input.addEventListener("input", () => {
|
||||
spring.target = +input.value;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(loop);
|
||||
k = +input.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(input);
|
||||
document.body.addEventListener("click", () => input.focus());
|
||||
window.addEventListener("click", (e) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
input.focus();
|
||||
});
|
||||
|
||||
80
packages/demo/demo.getBestTunnel.ts
Normal file
80
packages/demo/demo.getBestTunnel.ts
Normal 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();
|
||||
59
packages/demo/demo.getPathTo.ts
Normal file
59
packages/demo/demo.getPathTo.ts
Normal 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);
|
||||
41
packages/demo/demo.getPathToPose.ts
Normal file
41
packages/demo/demo.getPathToPose.ts
Normal 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);
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as grid from "@snk/compute/__fixtures__/grid";
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.fontFamily = "helvetica";
|
||||
document.body.appendChild(container);
|
||||
|
||||
for (const demo of require("./demo.json").filter((x: any) => x !== "index")) {
|
||||
const title = document.createElement("h1");
|
||||
title.innerText = demo;
|
||||
|
||||
container.appendChild(title);
|
||||
|
||||
for (const g of Object.keys(grid)) {
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "block";
|
||||
a.innerText = `${demo} - ${g}`;
|
||||
a.href = `./${demo}.html?grid=${g}`;
|
||||
|
||||
container.appendChild(a);
|
||||
}
|
||||
}
|
||||
316
packages/demo/demo.interactive.ts
Normal file
316
packages/demo/demo.interactive.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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 type { Point } from "@snk/types/point";
|
||||
import {
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
Options as DrawOptions,
|
||||
} 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";
|
||||
import { AnimationOptions } from "@snk/gif-creator";
|
||||
import { basePalettes } from "@snk/action/palettes";
|
||||
|
||||
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,
|
||||
cells,
|
||||
}: {
|
||||
grid0: Grid;
|
||||
chain: Snake[];
|
||||
cells: Point[];
|
||||
}) => {
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
...basePalettes["github-light"],
|
||||
};
|
||||
|
||||
//
|
||||
// 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, cells, snake0, snake1, stack, k, drawOptions);
|
||||
|
||||
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
|
||||
//
|
||||
// controls
|
||||
const input = document.createElement("input");
|
||||
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);
|
||||
|
||||
//
|
||||
const schemaSelect = document.createElement("select");
|
||||
schemaSelect.style.margin = "10px";
|
||||
schemaSelect.style.alignSelf = "flex-start";
|
||||
schemaSelect.value = "github-light";
|
||||
schemaSelect.addEventListener("change", () => {
|
||||
Object.assign(drawOptions, basePalettes[schemaSelect.value]);
|
||||
|
||||
svgString = createSvg(grid0, cells, chain, drawOptions, {
|
||||
frameDuration: 100,
|
||||
} as AnimationOptions);
|
||||
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
|
||||
svgLink.href = svgImageUri;
|
||||
|
||||
if (schemaSelect.value.includes("dark"))
|
||||
document.body.parentElement?.classList.add("dark-mode");
|
||||
else document.body.parentElement?.classList.remove("dark-mode");
|
||||
|
||||
loop();
|
||||
});
|
||||
for (const name of Object.keys(basePalettes)) {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
option.innerText = name;
|
||||
schemaSelect.appendChild(option);
|
||||
}
|
||||
document.body.append(schemaSelect);
|
||||
|
||||
//
|
||||
// dark mode
|
||||
const style = document.createElement("style");
|
||||
style.innerText = `
|
||||
html { transition:background-color 180ms }
|
||||
a { transition:color 180ms }
|
||||
html.dark-mode{ background-color:#0d1117 }
|
||||
html.dark-mode a{ color:rgb(201, 209, 217) }
|
||||
`;
|
||||
document.head.append(style);
|
||||
|
||||
//
|
||||
// svg
|
||||
const svgLink = document.createElement("a");
|
||||
let svgString = createSvg(grid0, cells, chain, drawOptions, {
|
||||
frameDuration: 100,
|
||||
} as AnimationOptions);
|
||||
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(
|
||||
(document.body.parentElement?.classList.contains("dark-mode")
|
||||
? "<style>html{ background-color:#0d1117 }</style>"
|
||||
: "") +
|
||||
`<a href="${svgLink.href}" 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 = (await res.json()) as Res;
|
||||
|
||||
const grid = userContributionToGrid(cells);
|
||||
|
||||
const chain = await getChain(grid);
|
||||
|
||||
dispose();
|
||||
|
||||
createViewer({ grid0: grid, chain, cells });
|
||||
};
|
||||
|
||||
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";
|
||||
17
packages/demo/demo.interactive.worker.ts
Normal file
17
packages/demo/demo.interactive.worker.ts
Normal 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);
|
||||
@@ -1 +1,10 @@
|
||||
["index", "getAvailableRoutes", "getBestRoute", "pruneLayer"]
|
||||
[
|
||||
"interactive",
|
||||
"getBestRoute",
|
||||
"getBestTunnel",
|
||||
"outside",
|
||||
"getPathToPose",
|
||||
"getPathTo",
|
||||
"svg",
|
||||
"rust"
|
||||
]
|
||||
|
||||
42
packages/demo/demo.outside.ts
Normal file
42
packages/demo/demo.outside.ts
Normal 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();
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createCanvas } from "./canvas";
|
||||
import { Color, copyGrid } from "../compute/grid";
|
||||
import { grid, snake } from "./sample";
|
||||
import { pruneLayer } from "@snk/compute/pruneLayer";
|
||||
|
||||
const colors = [1, 2, 3] as Color[];
|
||||
|
||||
const snakeN = snake.length / 2;
|
||||
|
||||
const layers = [{ grid, chunk: [] as { x: number; y: number }[] }];
|
||||
let grid0 = copyGrid(grid);
|
||||
for (const color of colors) {
|
||||
const chunk = pruneLayer(grid0, color, snakeN);
|
||||
layers.push({ chunk, grid: copyGrid(grid0) });
|
||||
}
|
||||
|
||||
const { canvas, ctx, draw } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
let k = 0;
|
||||
|
||||
const loop = () => {
|
||||
const { grid, chunk } = layers[k];
|
||||
|
||||
draw(grid, snake, []);
|
||||
|
||||
ctx.fillStyle = "orange";
|
||||
chunk.forEach(({ x, y }) => {
|
||||
ctx.beginPath();
|
||||
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
|
||||
});
|
||||
};
|
||||
|
||||
loop();
|
||||
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = layers.length - 1;
|
||||
input.style.width = "90%";
|
||||
input.addEventListener("input", () => {
|
||||
k = +input.value;
|
||||
loop();
|
||||
});
|
||||
document.body.append(input);
|
||||
document.body.addEventListener("click", () => input.focus());
|
||||
24
packages/demo/demo.rust.ts
Normal file
24
packages/demo/demo.rust.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createCanvas } from "./canvas";
|
||||
import "./menu";
|
||||
import { grid } from "./sample";
|
||||
|
||||
(async () => {
|
||||
const api = await import("@snk/solver-r");
|
||||
|
||||
const g = api.IGrid.create(grid.width, grid.height, grid.data);
|
||||
|
||||
const freeCells = api.iget_free_cell(g);
|
||||
|
||||
{
|
||||
const { canvas, draw, highlightCell } = createCanvas(g);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
draw({ width: g.width, height: g.height, data: g.data }, [] as any, []);
|
||||
|
||||
for (let i = freeCells.length / 2; i--; ) {
|
||||
const x = freeCells[i * 2 + 0];
|
||||
const y = freeCells[i * 2 + 1];
|
||||
highlightCell(x, y);
|
||||
}
|
||||
}
|
||||
})();
|
||||
20
packages/demo/demo.svg.ts
Normal file
20
packages/demo/demo.svg.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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";
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
const chain = getBestRoute(grid, snake);
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
(async () => {
|
||||
const svg = await createSvg(grid, null, chain, drawOptions, {
|
||||
frameDuration: 200,
|
||||
} as AnimationOptions);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svg;
|
||||
document.body.appendChild(container);
|
||||
})();
|
||||
36
packages/demo/menu.ts
Normal file
36
packages/demo/menu.ts
Normal 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);
|
||||
@@ -2,22 +2,26 @@
|
||||
"name": "@snk/demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0",
|
||||
"@snk/draw": "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": {
|
||||
"@types/dat.gui": "0.7.5",
|
||||
"@types/webpack": "4.41.22",
|
||||
"dat.gui": "0.7.7",
|
||||
"html-webpack-plugin": "4.5.0",
|
||||
"ts-loader": "8.0.4",
|
||||
"ts-node": "9.0.0",
|
||||
"webpack": "4.44.2",
|
||||
"webpack-cli": "3.3.12",
|
||||
"webpack-dev-server": "3.11.0"
|
||||
"@types/dat.gui": "0.7.13",
|
||||
"dat.gui": "0.7.9",
|
||||
"dotenv": "16.4.7",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"ts-loader": "9.5.2",
|
||||
"ts-node": "10.9.2",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"dev": "webpack-dev-server --port ${PORT-3000}"
|
||||
"dev": "webpack serve"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Grid } from "@snk/compute/grid";
|
||||
import { Snake } from "@snk/compute/snake";
|
||||
import * as grids from "@snk/compute/__fixtures__/grid";
|
||||
import * as snakes from "@snk/compute/__fixtures__/snake";
|
||||
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);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const stepSpringOne = (
|
||||
maxVelocity = Infinity,
|
||||
}: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
dt = 1 / 60,
|
||||
) => {
|
||||
const a = -tension * (s.x - target) - friction * s.v;
|
||||
|
||||
@@ -31,13 +31,13 @@ const stepSpringOne = (
|
||||
export const isStable = (
|
||||
s: { x: number; v: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
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
|
||||
dt?: number,
|
||||
) => {
|
||||
const stable = isStable(s, target, dt);
|
||||
if (stable) {
|
||||
@@ -51,7 +51,7 @@ export const stepSpring = (
|
||||
s: { x: number; v: number },
|
||||
params: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
dt = 1 / 60,
|
||||
) => {
|
||||
const interval = 1 / 60;
|
||||
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
import * as path from "path";
|
||||
// @ts-ignore
|
||||
import * as HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
|
||||
import type { Configuration } from "webpack";
|
||||
|
||||
const basePathname = (process.env.BASE_PATHNAME || "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
import path from "path";
|
||||
import HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
import webpack from "webpack";
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import type { Configuration as WebpackConfiguration } from "webpack";
|
||||
import {
|
||||
ExpressRequestHandler,
|
||||
type Configuration as WebpackDevServerConfiguration,
|
||||
} from "webpack-dev-server";
|
||||
import { config } from "dotenv";
|
||||
config({ path: __dirname + "/../../.env" });
|
||||
|
||||
const demos: string[] = require("./demo.json");
|
||||
|
||||
const config: Configuration = {
|
||||
const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
|
||||
open: { target: demos[1] + ".html" },
|
||||
setupMiddlewares: (ms) => [
|
||||
...ms,
|
||||
(async (req, res, next) => {
|
||||
const userName = req.url.match(
|
||||
/\/api\/github-user-contribution\/(\w+)/,
|
||||
)?.[1];
|
||||
if (userName)
|
||||
res.send(
|
||||
await getGithubUserContribution(userName, {
|
||||
githubToken: process.env.GITHUB_TOKEN!,
|
||||
}),
|
||||
);
|
||||
else next();
|
||||
}) as ExpressRequestHandler,
|
||||
],
|
||||
};
|
||||
|
||||
const webpackConfiguration: WebpackConfiguration = {
|
||||
mode: "development",
|
||||
entry: Object.fromEntries(
|
||||
demos.map((demo: string) => [demo, `./demo.${demo}`])
|
||||
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(""),
|
||||
},
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -28,11 +52,10 @@ const config: Configuration = {
|
||||
test: /\.ts$/,
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
lib: ["dom", "es2020"],
|
||||
target: "es2020",
|
||||
module: "es2020",
|
||||
moduleResolution: "node",
|
||||
target: "es2019",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -42,14 +65,27 @@ const config: Configuration = {
|
||||
...demos.map(
|
||||
(demo) =>
|
||||
new HtmlWebpackPlugin({
|
||||
title: "snk - " + demo,
|
||||
filename: `${demo}.html`,
|
||||
chunks: [demo],
|
||||
})
|
||||
}),
|
||||
),
|
||||
new HtmlWebpackPlugin({
|
||||
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",
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default {
|
||||
...webpackConfiguration,
|
||||
devServer: webpackDevServerConfiguration,
|
||||
};
|
||||
|
||||
59
packages/demo/worker-utils.ts
Normal file
59
packages/demo/worker-utils.ts
Normal 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
3
packages/draw/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @snk/draw
|
||||
|
||||
Draw grids and snakes on a canvas.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Color } from "@snk/compute/grid";
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
@@ -59,7 +59,7 @@ export const getCircleSize = (n: number) => {
|
||||
export const drawCircleStack = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
o: Options,
|
||||
) => {
|
||||
for (let i = stack.length; i--; ) {
|
||||
const { x, y } = cellPath[i];
|
||||
@@ -67,7 +67,7 @@ export const drawCircleStack = (
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
);
|
||||
|
||||
//@ts-ignore
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
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>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
colorDotBorder: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
sizeDotBorderRadius: number;
|
||||
};
|
||||
|
||||
export const drawGrid = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
o: Options
|
||||
cells: Point[] | null,
|
||||
o: Options,
|
||||
) => {
|
||||
for (let x = grid.width; x--; )
|
||||
for (let y = grid.height; 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
|
||||
);
|
||||
if (!cells || 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.colorDotBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeDotBorderRadius);
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { Snake, snakeToCells } from "@snk/compute/snake";
|
||||
import { snakeToCells } from "@snk/types/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
|
||||
type Options = {
|
||||
colorSnake: string;
|
||||
@@ -9,7 +10,7 @@ type Options = {
|
||||
export const drawSnake = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake: Snake,
|
||||
o: Options
|
||||
o: Options,
|
||||
) => {
|
||||
const cells = snakeToCells(snake);
|
||||
|
||||
@@ -24,7 +25,7 @@ export const drawSnake = (
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
(o.sizeCell - u * 2) * 0.25,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
@@ -39,12 +40,12 @@ export const drawSnakeLerp = (
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
k: number,
|
||||
o: Options
|
||||
o: Options,
|
||||
) => {
|
||||
const m = 0.8;
|
||||
const n = snake0.length / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u = (i + 1) * 0.6;
|
||||
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);
|
||||
@@ -60,7 +61,7 @@ export const drawSnakeLerp = (
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
(o.sizeCell - u * 2) * 0.25,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Grid, Color } from "@snk/compute/grid";
|
||||
import { drawGrid } from "./drawGrid";
|
||||
import { Snake } from "@snk/compute/snake";
|
||||
import { drawSnake, drawSnakeLerp } from "./drawSnake";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
colorDotBorder: string;
|
||||
colorSnake: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
sizeDotBorderRadius: number;
|
||||
};
|
||||
|
||||
export const drawStack = (
|
||||
@@ -18,7 +19,7 @@ export const drawStack = (
|
||||
stack: Color[],
|
||||
max: number,
|
||||
width: number,
|
||||
o: { colorDots: Record<Color, string> }
|
||||
o: { colorDots: Record<Color, string> },
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
@@ -35,14 +36,15 @@ export const drawStack = (
|
||||
export const drawWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
snake: Snake,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
o: Options,
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawGrid(ctx, grid, cells, o);
|
||||
drawSnake(ctx, snake, o);
|
||||
|
||||
ctx.restore();
|
||||
@@ -64,18 +66,19 @@ export const drawWorld = (
|
||||
};
|
||||
|
||||
export const drawLerpWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ctx: CanvasRenderingContext2D | CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number,
|
||||
o: Options
|
||||
o: Options,
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawGrid(ctx, grid, cells, o);
|
||||
drawSnakeLerp(ctx, snake0, snake1, k, o);
|
||||
|
||||
ctx.translate(0, (grid.height + 2) * o.sizeCell);
|
||||
@@ -85,3 +88,10 @@ export const drawLerpWorld = (
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"name": "@snk/draw",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0"
|
||||
"@snk/solver": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const pathRoundedRect = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
borderRadius: number
|
||||
borderRadius: number,
|
||||
) => {
|
||||
ctx.moveTo(borderRadius, 0);
|
||||
ctx.arcTo(width, 0, width, height, borderRadius);
|
||||
|
||||
5
packages/gif-creator/README.md
Normal file
5
packages/gif-creator/README.md
Normal 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.
|
||||
2
packages/gif-creator/__tests__/__snapshots__/.gitignore
vendored
Normal file
2
packages/gif-creator/__tests__/__snapshots__/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,38 +1,84 @@
|
||||
import * as fs from "fs";
|
||||
import { performance } from "perf_hooks";
|
||||
import { createSnake, nextSnake } from "@snk/compute/snake";
|
||||
import { realistic as grid } from "@snk/compute/__fixtures__/grid";
|
||||
import { createGif } from "..";
|
||||
import { createSnakeFromCells } from "@snk/types/snake";
|
||||
import { realistic as grid } from "@snk/types/__fixtures__/grid";
|
||||
import { AnimationOptions, createGif } from "..";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
|
||||
|
||||
let snake = createSnake(Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 })));
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// for (let x = grid.width - 1; x--; ) {
|
||||
// snake = nextSnake(snake, (y + 100) % 2 ? 1 : -1, 0);
|
||||
// chain.push(snake);
|
||||
// }
|
||||
// }
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 200, step: 1 };
|
||||
const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
(async () => {
|
||||
const m = 3;
|
||||
const s = performance.now();
|
||||
for (let k = m; k--; )
|
||||
await createGif(grid, chain.slice(0, 50), drawOptions, gifOptions);
|
||||
for (
|
||||
let length = 10;
|
||||
length < chain.length;
|
||||
length += Math.floor((chain.length - 10) / 3 / 10) * 10
|
||||
) {
|
||||
const stats: number[] = [];
|
||||
|
||||
console.log((performance.now() - s) / m, "ms");
|
||||
let buffer: Uint8Array;
|
||||
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,
|
||||
null,
|
||||
chainL,
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
);
|
||||
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!,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createGif } from "..";
|
||||
import * as grids from "@snk/compute/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/compute/__fixtures__/snake";
|
||||
import { createSnake, nextSnake } from "@snk/compute/snake";
|
||||
import { getBestRoute } from "@snk/compute/getBestRoute";
|
||||
import { it, expect } from "bun:test";
|
||||
import { AnimationOptions, createGif } from "..";
|
||||
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";
|
||||
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
|
||||
|
||||
jest.setTimeout(20 * 1000);
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
const upscale = 1;
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2 * upscale,
|
||||
sizeCell: 16 * upscale,
|
||||
sizeDot: 12 * upscale,
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 200, step: 1 };
|
||||
const animationOptions: AnimationOptions = { frameDuration: 200, step: 1 };
|
||||
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
|
||||
@@ -32,38 +33,59 @@ for (const key of [
|
||||
"corner",
|
||||
"small",
|
||||
"smallPacked",
|
||||
"enclave",
|
||||
] as const)
|
||||
it(`should generate ${key} gif`, async () => {
|
||||
const grid = grids[key];
|
||||
it(
|
||||
`should generate ${key} gif`,
|
||||
async () => {
|
||||
const grid = grids[key];
|
||||
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
const gif = await createGif(
|
||||
grid,
|
||||
null,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
|
||||
},
|
||||
{ timeout: 20 * 1000 },
|
||||
);
|
||||
|
||||
it(
|
||||
`should generate swipper`,
|
||||
async () => {
|
||||
const grid = grids.smallFull;
|
||||
let snk = createSnakeFromCells(
|
||||
Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 })),
|
||||
);
|
||||
|
||||
const chain = [snk];
|
||||
for (let y = -1; y < grid.height; y++) {
|
||||
snk = nextSnake(snk, 0, 1);
|
||||
chain.push(snk);
|
||||
|
||||
for (let x = grid.width - 1; x--; ) {
|
||||
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
|
||||
chain.push(snk);
|
||||
}
|
||||
}
|
||||
|
||||
const gif = await createGif(
|
||||
grid,
|
||||
null,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
|
||||
});
|
||||
|
||||
it(`should generate swipper`, async () => {
|
||||
const grid = grids.smallFull;
|
||||
let snk = createSnake(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);
|
||||
});
|
||||
fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
|
||||
},
|
||||
{ timeout: 20 * 1000 },
|
||||
);
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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 { Snake } from "@snk/compute/snake";
|
||||
import { Options, drawLerpWorld } from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/compute/step";
|
||||
import * as tmp from "tmp";
|
||||
import * as execa from "execa";
|
||||
import { Grid, copyGrid, Color } from "@snk/types/grid";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
Options as DrawOptions,
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import { step } from "@snk/solver/step";
|
||||
import tmp from "tmp";
|
||||
import gifsicle from "gifsicle";
|
||||
// @ts-ignore
|
||||
import GIFEncoder from "gif-encoder-2";
|
||||
|
||||
const withTmpDir = async <T>(
|
||||
handler: (dir: string) => Promise<T>
|
||||
handler: (dir: string) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
|
||||
unsafeCleanup: true,
|
||||
@@ -22,83 +30,70 @@ const withTmpDir = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
export type AnimationOptions = { frameDuration: number; step: number };
|
||||
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
cells: Point[] | null,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number; step: number }
|
||||
drawOptions: DrawOptions,
|
||||
animationOptions: AnimationOptions,
|
||||
) =>
|
||||
withTmpDir(async (dir) => {
|
||||
const width = drawOptions.sizeCell * (grid0.width + 2);
|
||||
const height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const ctx = canvas.getContext("2d") as any as CanvasRenderingContext2D;
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
const stack: Color[] = [];
|
||||
|
||||
const encoder = new GIFEncoder(width, height, "neuquant", true);
|
||||
encoder.setRepeat(0);
|
||||
encoder.setDelay(animationOptions.frameDuration);
|
||||
encoder.start();
|
||||
|
||||
for (let i = 0; i < chain.length; i += 1) {
|
||||
const snake0 = chain[i];
|
||||
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
|
||||
step(grid, stack, snake0);
|
||||
|
||||
for (let k = 0; k < gifOptions.step; k++) {
|
||||
ctx.clearRect(0, 0, 99999, 99999);
|
||||
for (let k = 0; k < animationOptions.step; k++) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, 99999, 99999);
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawLerpWorld(
|
||||
ctx,
|
||||
grid,
|
||||
cells,
|
||||
snake0,
|
||||
snake1,
|
||||
stack,
|
||||
k / gifOptions.step,
|
||||
drawOptions
|
||||
k / animationOptions.step,
|
||||
drawOptions,
|
||||
);
|
||||
|
||||
const buffer = canvas.toBuffer("image/png", {
|
||||
compressionLevel: 0,
|
||||
filters: canvas.PNG_FILTER_NONE,
|
||||
});
|
||||
|
||||
const fileName = path.join(
|
||||
dir,
|
||||
`${(i * gifOptions.step + k).toString().padStart(4, "0")}.png`
|
||||
);
|
||||
|
||||
fs.writeFileSync(fileName, buffer);
|
||||
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.frameDuration / 10).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()
|
||||
].flat(),
|
||||
);
|
||||
|
||||
return fs.readFileSync(optimizedFileName);
|
||||
return new Uint8Array(fs.readFileSync(optimizedFileName));
|
||||
});
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
"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",
|
||||
"tmp": "0.2.1"
|
||||
"@snk/solver": "1.0.0",
|
||||
"canvas": "3.1.0",
|
||||
"gif-encoder-2": "1.0.5",
|
||||
"gifsicle": "5.3.0",
|
||||
"tmp": "0.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/execa": "2.0.0",
|
||||
"@types/tmp": "0.2.0",
|
||||
"@zeit/ncc": "0.22.3"
|
||||
"@types/gifsicle": "5.2.2",
|
||||
"@types/tmp": "0.2.6"
|
||||
},
|
||||
"scripts": {
|
||||
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
|
||||
"benchmark": "bun __tests__/benchmark.ts"
|
||||
}
|
||||
}
|
||||
|
||||
14
packages/github-user-contribution-service/README.md
Normal file
14
packages/github-user-contribution-service/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @snk/github-user-contribution-service
|
||||
|
||||
Expose github-user-contribution as an endpoint. hosted on cloudflare
|
||||
|
||||
```sh
|
||||
|
||||
|
||||
# deploy
|
||||
bunx wrangler deploy --branch=production
|
||||
|
||||
# change secret
|
||||
bunx wrangler secret put GITHUB_TOKEN
|
||||
|
||||
```
|
||||
52
packages/github-user-contribution-service/index.ts
Normal file
52
packages/github-user-contribution-service/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
|
||||
const cors =
|
||||
<
|
||||
Req extends { headers: Headers },
|
||||
Res extends { headers: Headers },
|
||||
A extends Array<any>,
|
||||
>(
|
||||
f: (req: Req, ...args: A) => Res | Promise<Res>,
|
||||
) =>
|
||||
async (req: Req, ...args: A) => {
|
||||
const res = await f(req, ...args);
|
||||
|
||||
const origin = req.headers.get("origin");
|
||||
|
||||
if (origin) {
|
||||
const { host, hostname } = new URL(origin);
|
||||
|
||||
if (hostname === "localhost" || host === "platane.github.io")
|
||||
res.headers.set("Access-Control-Allow-Origin", origin);
|
||||
}
|
||||
|
||||
res.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
return res;
|
||||
};
|
||||
|
||||
export default {
|
||||
fetch: cors(async (req: Request, env: { GITHUB_TOKEN: string }) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const [, userName] =
|
||||
url.pathname.match(/^\/github-user-contribution\/([^\/]*)\/?$/) ?? [];
|
||||
|
||||
if (req.method === "OPTIONS") return new Response();
|
||||
|
||||
if (!userName || req.method !== "GET")
|
||||
return new Response("unknown route", { status: 404 });
|
||||
|
||||
const body = await getGithubUserContribution(userName, {
|
||||
githubToken: env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "max-age=21600, s-maxage=21600",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
14
packages/github-user-contribution-service/package.json
Normal file
14
packages/github-user-contribution-service/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@snk/github-user-contribution-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/github-user-contribution": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "3.109.2",
|
||||
"@cloudflare/workers-types": "4.20250214.0"
|
||||
},
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy"
|
||||
}
|
||||
}
|
||||
9
packages/github-user-contribution-service/wrangler.toml
Normal file
9
packages/github-user-contribution-service/wrangler.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
name = "github-user-contribution"
|
||||
main = "index.ts"
|
||||
compatibility_date = "2024-09-02"
|
||||
|
||||
account_id = "56268cde636c288343cb0767952ecf2e"
|
||||
workers_dev = true
|
||||
|
||||
# [observability]
|
||||
# enabled = true
|
||||
29
packages/github-user-contribution/README.md
Normal file
29
packages/github-user-contribution/README.md
Normal 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.
|
||||
@@ -1,14 +1,32 @@
|
||||
import { getGithubUserContribution } from "..";
|
||||
import { describe, it, expect } from "bun:test";
|
||||
|
||||
it("should get user contribution", async () => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
describe("getGithubUserContribution", () => {
|
||||
const promise = getGithubUserContribution("platane", {
|
||||
githubToken: process.env.GITHUB_TOKEN!,
|
||||
});
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
it("should resolve", async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
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 = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
|
||||
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: any) => c.x === x && c.y === y));
|
||||
|
||||
expect(undefinedDays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,101 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* @param userName
|
||||
* 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) => {
|
||||
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
|
||||
export const getGithubUserContribution = async (
|
||||
userName: string,
|
||||
o: { githubToken: string },
|
||||
) => {
|
||||
const query = /* GraphQL */ `
|
||||
query ($login: String!) {
|
||||
user(login: $login) {
|
||||
contributionsCollection {
|
||||
contributionCalendar {
|
||||
weeks {
|
||||
contributionDays {
|
||||
contributionCount
|
||||
contributionLevel
|
||||
weekday
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = { login: userName };
|
||||
|
||||
const colorScheme = Array.from(
|
||||
dom.window.document.querySelectorAll(".legend > li")
|
||||
).map(
|
||||
(element) =>
|
||||
element.getAttribute("style")?.match(/background\-color: +(#\w+)/)?.[1]!
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
headers: {
|
||||
Authorization: `bearer ${o.githubToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "me@platane.me",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({ variables, query }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText));
|
||||
|
||||
const { data, errors } = (await res.json()) as {
|
||||
data: GraphQLRes;
|
||||
errors?: { message: string }[];
|
||||
};
|
||||
|
||||
if (errors?.[0]) throw errors[0];
|
||||
|
||||
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(
|
||||
({ contributionDays }, x) =>
|
||||
contributionDays.map((d) => ({
|
||||
x,
|
||||
y: d.weekday,
|
||||
date: d.date,
|
||||
count: d.contributionCount,
|
||||
level:
|
||||
(d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
|
||||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
|
||||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
|
||||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
|
||||
0,
|
||||
})),
|
||||
);
|
||||
|
||||
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) => {
|
||||
const count = +element.getAttribute("data-count")!;
|
||||
const date = element.getAttribute("data-date")!;
|
||||
const color = element.getAttribute("fill")!;
|
||||
const k = colorScheme.indexOf(color);
|
||||
|
||||
return { x, y, count, date, color, k };
|
||||
})
|
||||
)
|
||||
.flat();
|
||||
|
||||
return { colorScheme, cells };
|
||||
};
|
||||
|
||||
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
|
||||
type GraphQLRes = {
|
||||
user: {
|
||||
contributionsCollection: {
|
||||
contributionCalendar: {
|
||||
weeks: {
|
||||
contributionDays: {
|
||||
contributionCount: number;
|
||||
contributionLevel:
|
||||
| "FOURTH_QUARTILE"
|
||||
| "THIRD_QUARTILE"
|
||||
| "SECOND_QUARTILE"
|
||||
| "FIRST_QUARTILE"
|
||||
| "NONE";
|
||||
date: string;
|
||||
weekday: number;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type Cell = ThenArg<
|
||||
ReturnType<typeof getGithubUserContribution>
|
||||
>["cells"][number];
|
||||
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;
|
||||
|
||||
export type Cell = Res[number];
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"name": "@snk/github-user-contribution",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"jsdom": "16.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "16.2.4"
|
||||
}
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
2
packages/solver-r/.gitignore
vendored
Normal file
2
packages/solver-r/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target
|
||||
pkg
|
||||
341
packages/solver-r/Cargo.lock
generated
Normal file
341
packages/solver-r/Cargo.lock
generated
Normal file
@@ -0,0 +1,341 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_log"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "snk-solver-rust"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"minicov",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
26
packages/solver-r/Cargo.toml
Normal file
26
packages/solver-r/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "snk-solver-rust"
|
||||
version = "1.0.0"
|
||||
authors = ["platane"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2.100"
|
||||
js-sys = "0.3.77"
|
||||
console_log = "1.0.0"
|
||||
log = "0.4"
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
||||
# logging them with `console.error`. This is great for development, but requires
|
||||
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
|
||||
# code size when deploying.
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.34"
|
||||
11
packages/solver-r/package.json
Normal file
11
packages/solver-r/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@snk/solver-r",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"wasm-pack": "0.13.1"
|
||||
},
|
||||
"main": "./pkg/snk_solver_rust.js",
|
||||
"scripts": {
|
||||
"build": "wasm-pack build"
|
||||
}
|
||||
}
|
||||
84
packages/solver-r/src/grid.rs
Normal file
84
packages/solver-r/src/grid.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug)]
|
||||
pub struct Point {
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
|
||||
#[repr(u8)]
|
||||
pub enum Cell {
|
||||
Empty = 0,
|
||||
Color1 = 1,
|
||||
Color2 = 2,
|
||||
Color3 = 3,
|
||||
Color4 = 4,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Grid {
|
||||
pub width: u8,
|
||||
pub height: u8,
|
||||
pub cells: Vec<Cell>,
|
||||
}
|
||||
impl Grid {
|
||||
pub fn create_empty(width: u8, height: u8) -> Grid {
|
||||
let n = (width as usize) * (height as usize);
|
||||
let cells = (0..n).map(|_| Cell::Empty).collect();
|
||||
|
||||
Grid {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_index(&self, x: i8, y: i8) -> usize {
|
||||
return (x as usize) * (self.height as usize) + (y as usize);
|
||||
}
|
||||
pub fn get_cell(&self, p: &Point) -> Cell {
|
||||
let i = self.get_index(p.x, p.y);
|
||||
return self.cells[i];
|
||||
}
|
||||
pub fn set_cell(&mut self, p: &Point, value: Cell) -> () {
|
||||
let i = self.get_index(p.x, p.y);
|
||||
self.cells[i] = value;
|
||||
}
|
||||
pub fn is_inside(&self, p: &Point) -> bool {
|
||||
p.x >= 0 && p.x < (self.width as i8) && p.y >= 0 && p.y < (self.height as i8)
|
||||
}
|
||||
}
|
||||
|
||||
pub const DIRECTION_RIGHT: Point = Point { x: 1, y: 0 };
|
||||
pub const DIRECTION_LEFT: Point = Point { x: -1, y: 0 };
|
||||
pub const DIRECTION_UP: Point = Point { x: 0, y: 1 };
|
||||
pub const DIRECTION_DOWN: Point = Point { x: 0, y: -1 };
|
||||
pub const DIRECTIONS: [Point; 4] = [
|
||||
DIRECTION_RIGHT,
|
||||
DIRECTION_LEFT,
|
||||
DIRECTION_UP,
|
||||
DIRECTION_DOWN,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn it_should_sort_cell() {
|
||||
assert_eq!(Cell::Empty < Cell::Color1, true);
|
||||
assert_eq!(Cell::Color1 < Cell::Color2, true);
|
||||
assert_eq!(Cell::Color2 < Cell::Color3, true);
|
||||
assert_eq!(Cell::Color3 < Cell::Color4, true);
|
||||
}
|
||||
#[test]
|
||||
fn it_should_grid_create() {
|
||||
let grid = Grid::create_empty(30, 10);
|
||||
|
||||
assert_eq!(grid.width, 30);
|
||||
assert_eq!(grid.height, 10);
|
||||
assert_eq!(grid.get_cell(&Point { x: 2, y: 3 }), Cell::Empty);
|
||||
}
|
||||
#[test]
|
||||
fn it_should_grid_setter() {
|
||||
let mut grid = Grid::create_empty(20, 10);
|
||||
|
||||
grid.set_cell(&Point { x: 12, y: 3 }, Cell::Color1);
|
||||
|
||||
assert_eq!(grid.get_cell(&Point { x: 12, y: 3 }), Cell::Color1);
|
||||
}
|
||||
79
packages/solver-r/src/lib.rs
Normal file
79
packages/solver-r/src/lib.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
mod grid;
|
||||
mod snake;
|
||||
mod snake_compact;
|
||||
mod snake_walk;
|
||||
mod solver;
|
||||
|
||||
use grid::{Cell, Grid};
|
||||
use js_sys;
|
||||
use solver::get_free_cell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
fn alert(s: &str);
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn greet() {
|
||||
alert("Hello, wasm-game-of-life!");
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct IGrid {
|
||||
pub width: u8,
|
||||
pub height: u8,
|
||||
cells: Vec<Cell>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl IGrid {
|
||||
pub fn create(width: u8, height: u8, data: js_sys::Uint8Array) -> IGrid {
|
||||
let cells = data
|
||||
.to_vec()
|
||||
.iter()
|
||||
.map(|u| match u {
|
||||
0 => Cell::Empty,
|
||||
1 => Cell::Color1,
|
||||
2 => Cell::Color2,
|
||||
3 => Cell::Color3,
|
||||
4 => Cell::Color4,
|
||||
_ => panic!("unknown cell"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
IGrid {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn data(&self) -> js_sys::Uint8Array {
|
||||
let o: Vec<u8> = self.cells.iter().map(|u| *u as u8).collect();
|
||||
js_sys::Uint8Array::from(&o[..])
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IGrid> for Grid {
|
||||
fn from(value: IGrid) -> Self {
|
||||
Self {
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
cells: value.cells,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn iget_free_cell(grid: &IGrid) -> js_sys::Uint8Array {
|
||||
let g = Grid::from(grid.clone());
|
||||
|
||||
let (_, out) = get_free_cell(&g, Cell::Color1);
|
||||
|
||||
let o: Vec<u8> = out.iter().flat_map(|p| [p.x as u8, p.y as u8]).collect();
|
||||
|
||||
js_sys::Uint8Array::from(&o[..])
|
||||
}
|
||||
95
packages/solver-r/src/snake.rs
Normal file
95
packages/solver-r/src/snake.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crate::grid::{Point, DIRECTIONS, DIRECTION_DOWN, DIRECTION_LEFT, DIRECTION_UP};
|
||||
|
||||
/**
|
||||
* head is at 0
|
||||
*/
|
||||
pub type Snake = Vec<Point>;
|
||||
|
||||
pub fn move_snake(s: &mut Snake, dir: &Point) -> () {
|
||||
let mut e = s.pop().unwrap();
|
||||
e.x = s[0].x + dir.x;
|
||||
e.y = s[0].y + dir.y;
|
||||
s.insert(0, e);
|
||||
}
|
||||
pub fn snake_will_self_collide(s: &Snake, dir: &Point) -> bool {
|
||||
let next_head = Point {
|
||||
x: s[0].x + dir.x,
|
||||
y: s[0].y + dir.y,
|
||||
};
|
||||
|
||||
(&s[0..(s.len() - 1)]).contains(&next_head)
|
||||
}
|
||||
pub fn get_snake_head(s: &Snake) -> Point {
|
||||
s[0]
|
||||
}
|
||||
pub fn get_next_snake_head(s: &Snake, dir: &Point) -> Point {
|
||||
Point {
|
||||
x: s[0].x + dir.x,
|
||||
y: s[0].y + dir.y,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_return_head() {
|
||||
let s = vec![
|
||||
//
|
||||
Point { x: 3, y: 0 },
|
||||
Point { x: 2, y: 0 },
|
||||
Point { x: 1, y: 0 },
|
||||
];
|
||||
|
||||
assert_eq!(get_snake_head(&s), Point { x: 3, y: 0 });
|
||||
}
|
||||
#[test]
|
||||
fn it_should_detect_self_collide() {
|
||||
let mut s = vec![
|
||||
//
|
||||
Point { x: 6, y: 0 },
|
||||
Point { x: 5, y: 0 },
|
||||
Point { x: 4, y: 0 },
|
||||
Point { x: 3, y: 0 },
|
||||
Point { x: 2, y: 0 },
|
||||
Point { x: 1, y: 0 },
|
||||
];
|
||||
|
||||
move_snake(&mut s, &DIRECTION_UP);
|
||||
move_snake(&mut s, &DIRECTION_LEFT);
|
||||
|
||||
assert_eq!(snake_will_self_collide(&s, &DIRECTION_DOWN), true);
|
||||
|
||||
move_snake(&mut s, &DIRECTION_LEFT);
|
||||
|
||||
assert_eq!(snake_will_self_collide(&s, &DIRECTION_DOWN), false);
|
||||
}
|
||||
#[test]
|
||||
fn it_should_detect_self_collide_2() {
|
||||
let s = vec![
|
||||
//
|
||||
Point { x: 3, y: 0 },
|
||||
Point { x: 2, y: 0 },
|
||||
Point { x: 1, y: 0 },
|
||||
];
|
||||
|
||||
assert_eq!(snake_will_self_collide(&s, &DIRECTION_LEFT), true);
|
||||
}
|
||||
#[test]
|
||||
fn it_should_move_snake() {
|
||||
let mut s = vec![
|
||||
//
|
||||
Point { x: 3, y: 0 },
|
||||
Point { x: 2, y: 0 },
|
||||
Point { x: 1, y: 0 },
|
||||
];
|
||||
|
||||
move_snake(&mut s, &DIRECTION_UP);
|
||||
|
||||
assert_eq!(
|
||||
s,
|
||||
vec![
|
||||
//
|
||||
Point { x: 3, y: 1 },
|
||||
Point { x: 3, y: 0 },
|
||||
Point { x: 2, y: 0 },
|
||||
]
|
||||
);
|
||||
}
|
||||
176
packages/solver-r/src/snake_compact.rs
Normal file
176
packages/solver-r/src/snake_compact.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::grid::Point;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
|
||||
pub enum Direction {
|
||||
Left = 0,
|
||||
Right = 1,
|
||||
Up = 2,
|
||||
Down = 3,
|
||||
}
|
||||
|
||||
fn get_direction_vector(dir: Direction) -> Point {
|
||||
match dir {
|
||||
Direction::Down => Point { x: 0, y: -1 },
|
||||
Direction::Up => Point { x: 0, y: 1 },
|
||||
Direction::Left => Point { x: -1, y: 0 },
|
||||
Direction::Right => Point { x: 1, y: 0 },
|
||||
}
|
||||
}
|
||||
fn get_direction_from_vector(v: &Point) -> Direction {
|
||||
match v {
|
||||
Point { x: 0, y: -1 } => Direction::Down,
|
||||
Point { x: 0, y: 1 } => Direction::Up,
|
||||
Point { x: -1, y: 0 } => Direction::Left,
|
||||
Point { x: 1, y: 0 } => Direction::Right,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SnakeC {
|
||||
pub head: Point,
|
||||
pub body: Vec<Direction>,
|
||||
}
|
||||
impl SnakeC {
|
||||
pub fn get_cells(&self) -> Vec<Point> {
|
||||
let mut e = self.head.clone();
|
||||
let mut out = Vec::new();
|
||||
|
||||
out.push(e.clone());
|
||||
for dir in self.body.iter() {
|
||||
let v = get_direction_vector(*dir);
|
||||
e.x -= v.x;
|
||||
e.y -= v.y;
|
||||
out.push(e.clone());
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_head_self_colliding(&self) -> bool {
|
||||
self.get_cells()[1..].contains(&self.head)
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, dir: Direction) -> () {
|
||||
let v = get_direction_vector(dir);
|
||||
|
||||
self.head.x += v.x;
|
||||
self.head.y += v.y;
|
||||
|
||||
self.body.pop();
|
||||
self.body.insert(0, dir);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Point>> for SnakeC {
|
||||
fn from(value: Vec<Point>) -> Self {
|
||||
let head = value.get(0).unwrap().clone();
|
||||
let body = value
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
let v = Point {
|
||||
x: w[0].x - w[1].x,
|
||||
y: w[0].y - w[1].y,
|
||||
};
|
||||
get_direction_from_vector(&v)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { head, body }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_get_the_snake_cell() {
|
||||
let s = SnakeC {
|
||||
head: Point { x: 10, y: 5 },
|
||||
body: vec![Direction::Up, Direction::Up, Direction::Left],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
s.get_cells(),
|
||||
vec![
|
||||
//
|
||||
Point { x: 10, y: 5 },
|
||||
Point { x: 10, y: 4 },
|
||||
Point { x: 10, y: 3 },
|
||||
Point { x: 11, y: 3 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_get_snake_from_point_list() {
|
||||
let s = SnakeC::from(vec![
|
||||
//
|
||||
Point { x: 10, y: 5 },
|
||||
Point { x: 10, y: 4 },
|
||||
Point { x: 10, y: 3 },
|
||||
Point { x: 11, y: 3 },
|
||||
Point { x: 12, y: 3 },
|
||||
Point { x: 12, y: 2 },
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
s.get_cells(),
|
||||
vec![
|
||||
//
|
||||
Point { x: 10, y: 5 },
|
||||
Point { x: 10, y: 4 },
|
||||
Point { x: 10, y: 3 },
|
||||
Point { x: 11, y: 3 },
|
||||
Point { x: 12, y: 3 },
|
||||
Point { x: 12, y: 2 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_advance_snake() {
|
||||
let mut s = SnakeC::from(vec![
|
||||
//
|
||||
Point { x: 10, y: 3 },
|
||||
Point { x: 11, y: 3 },
|
||||
Point { x: 12, y: 3 },
|
||||
Point { x: 12, y: 2 },
|
||||
]);
|
||||
|
||||
s.advance(Direction::Up);
|
||||
|
||||
assert_eq!(
|
||||
s.get_cells(),
|
||||
vec![
|
||||
//
|
||||
Point { x: 10, y: 4 },
|
||||
Point { x: 10, y: 3 },
|
||||
Point { x: 11, y: 3 },
|
||||
Point { x: 12, y: 3 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_detect_self_collision() {
|
||||
let mut s = SnakeC::from(vec![
|
||||
//
|
||||
Point { x: 0, y: 0 },
|
||||
Point { x: 0, y: 1 },
|
||||
Point { x: 0, y: 2 },
|
||||
Point { x: 0, y: 3 },
|
||||
Point { x: 0, y: 4 },
|
||||
Point { x: 0, y: 5 },
|
||||
Point { x: 0, y: 6 },
|
||||
]);
|
||||
|
||||
assert_eq!(s.is_head_self_colliding(), false);
|
||||
|
||||
s.advance(Direction::Right);
|
||||
s.advance(Direction::Up);
|
||||
s.advance(Direction::Up);
|
||||
|
||||
assert_eq!(s.is_head_self_colliding(), false);
|
||||
|
||||
s.advance(Direction::Left);
|
||||
|
||||
assert_eq!(s.is_head_self_colliding(), true);
|
||||
}
|
||||
58
packages/solver-r/src/snake_walk.rs
Normal file
58
packages/solver-r/src/snake_walk.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::grid::{Cell, Grid, Point, DIRECTIONS};
|
||||
use crate::snake::{
|
||||
get_next_snake_head, get_snake_head, move_snake, snake_will_self_collide, Snake,
|
||||
};
|
||||
|
||||
pub fn get_route_to_eat_all(
|
||||
grid: &Grid,
|
||||
walkable: Cell,
|
||||
initial_snake: &Snake,
|
||||
cells_to_eat: HashSet<Point>,
|
||||
) -> Vec<Point> {
|
||||
// let mut targets: Vec<Point> = cells_to_eat.iter().map(|p| p.clone()).collect();
|
||||
|
||||
let mut targets: Vec<&Point> = cells_to_eat.iter().collect();
|
||||
|
||||
let mut path: Vec<Point> = Vec::new();
|
||||
|
||||
let mut initial_snake = initial_snake.clone();
|
||||
|
||||
while let Some(target) = targets.pop() {
|
||||
// prepare
|
||||
let mut open_list: HashSet<(Snake, Vec<Point>)> = HashSet::new();
|
||||
open_list.insert((initial_snake.clone(), Vec::new()));
|
||||
|
||||
while let Some(x) = open_list.iter().next().cloned() {
|
||||
open_list.remove(&x);
|
||||
|
||||
let snake = x.0;
|
||||
let mut sub_path = x.1;
|
||||
|
||||
if get_snake_head(&snake) == *target {
|
||||
path.append(&mut sub_path);
|
||||
initial_snake = snake;
|
||||
break;
|
||||
}
|
||||
|
||||
for dir in DIRECTIONS {
|
||||
if {
|
||||
let h = get_next_snake_head(&snake, &dir);
|
||||
grid.get_cell(&h) <= walkable
|
||||
} && !snake_will_self_collide(&snake, &dir)
|
||||
{
|
||||
let mut next_snake = snake.clone();
|
||||
move_snake(&mut next_snake, &dir);
|
||||
|
||||
let mut next_sub_path = sub_path.clone();
|
||||
next_sub_path.push(dir.clone());
|
||||
|
||||
open_list.insert((next_snake, next_sub_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
127
packages/solver-r/src/solver.rs
Normal file
127
packages/solver-r/src/solver.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::grid::{Cell, Grid, Point};
|
||||
|
||||
pub fn get_free_cell(grid: &Grid, walkable: Cell) -> (HashSet<Point>, HashSet<Point>) {
|
||||
let mut free_cells: HashSet<Point> = HashSet::new();
|
||||
let mut one_way_cells: HashSet<Point> = HashSet::new();
|
||||
let mut open_list: HashSet<Point> = HashSet::new();
|
||||
|
||||
for x in 0..(grid.width as i8) {
|
||||
open_list.insert(Point { x, y: 0 });
|
||||
open_list.insert(Point {
|
||||
x,
|
||||
y: (grid.height as i8) - 1,
|
||||
});
|
||||
}
|
||||
for y in 0..(grid.height as i8) {
|
||||
open_list.insert(Point { x: 0, y });
|
||||
open_list.insert(Point {
|
||||
x: (grid.width as i8) - 1,
|
||||
y,
|
||||
});
|
||||
}
|
||||
open_list.retain(|p| grid.get_cell(&p) <= walkable);
|
||||
|
||||
let directions = [
|
||||
Point { x: 1, y: 0 },
|
||||
Point { x: -1, y: 0 },
|
||||
Point { x: 0, y: 1 },
|
||||
Point { x: 0, y: -1 },
|
||||
];
|
||||
|
||||
while let Some(p) = open_list.iter().next().cloned() {
|
||||
open_list.remove(&p);
|
||||
|
||||
let has_enough_free_exits = {
|
||||
let mut exit_count = 0;
|
||||
let mut visited: HashSet<Point> = HashSet::new();
|
||||
|
||||
for dir in directions {
|
||||
let neighbour = Point {
|
||||
x: p.x + dir.x,
|
||||
y: p.y + dir.y,
|
||||
};
|
||||
|
||||
if !visited.contains(&neighbour)
|
||||
&& (free_cells.contains(&neighbour) || !grid.is_inside(&neighbour))
|
||||
{
|
||||
visited.insert(neighbour);
|
||||
exit_count += 1;
|
||||
}
|
||||
|
||||
if grid.is_inside(&neighbour) && grid.get_cell(&neighbour) <= walkable {
|
||||
for alt in [-1, 1] {
|
||||
let corner = {
|
||||
if neighbour.x != 0 {
|
||||
Point {
|
||||
x: neighbour.x,
|
||||
y: neighbour.y + alt,
|
||||
}
|
||||
} else {
|
||||
Point {
|
||||
x: neighbour.x + alt,
|
||||
y: neighbour.y,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !visited.contains(&neighbour)
|
||||
&& !visited.contains(&corner)
|
||||
&& (free_cells.contains(&corner) || !grid.is_inside(&corner))
|
||||
{
|
||||
visited.insert(neighbour);
|
||||
visited.insert(corner);
|
||||
exit_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit_count >= 2
|
||||
};
|
||||
|
||||
if has_enough_free_exits {
|
||||
free_cells.insert(p);
|
||||
|
||||
for dir in directions {
|
||||
let neighbour = Point {
|
||||
x: p.x + dir.x,
|
||||
y: p.y + dir.y,
|
||||
};
|
||||
|
||||
if !free_cells.contains(&neighbour)
|
||||
&& grid.is_inside(&neighbour)
|
||||
&& grid.get_cell(&neighbour) <= walkable
|
||||
{
|
||||
open_list.insert(neighbour);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
one_way_cells.insert(p);
|
||||
}
|
||||
}
|
||||
|
||||
one_way_cells.retain(|p| !free_cells.contains(&p));
|
||||
|
||||
(free_cells, one_way_cells)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_collect_free_cell() {
|
||||
let mut grid = Grid::create_empty(2, 2);
|
||||
|
||||
grid.set_cell(&Point { x: 1, y: 1 }, Cell::Color2);
|
||||
|
||||
let (free_cells, _) = get_free_cell(&grid, Cell::Color1);
|
||||
|
||||
assert_eq!(
|
||||
free_cells,
|
||||
HashSet::from([
|
||||
//
|
||||
Point { x: 0, y: 0 },
|
||||
Point { x: 0, y: 1 },
|
||||
Point { x: 1, y: 0 },
|
||||
])
|
||||
);
|
||||
}
|
||||
33
packages/solver/README.md
Normal file
33
packages/solver/README.md
Normal 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
|
||||
49
packages/solver/__tests__/getBestRoute-fuzz.spec.ts
Normal file
49
packages/solver/__tests__/getBestRoute-fuzz.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { it, expect } from "bun:test";
|
||||
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`);
|
||||
}
|
||||
};
|
||||
27
packages/solver/__tests__/getBestRoute.spec.ts
Normal file
27
packages/solver/__tests__/getBestRoute.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { it, expect } from "bun:test";
|
||||
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);
|
||||
});
|
||||
13
packages/solver/__tests__/getPathTo.spec.ts
Normal file
13
packages/solver/__tests__/getPathTo.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { it, expect } from "bun:test";
|
||||
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]);
|
||||
});
|
||||
20
packages/solver/__tests__/getPathToPose.spec.ts
Normal file
20
packages/solver/__tests__/getPathToPose.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { it, expect } from "bun:test";
|
||||
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();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { it, expect, describe } from "bun:test";
|
||||
import { sortPush } from "../utils/sortPush";
|
||||
|
||||
const sortFn = (a: number, b: number) => a - b;
|
||||
130
packages/solver/clearCleanColoredLayer.ts
Normal file
130
packages/solver/clearCleanColoredLayer.ts
Normal 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));
|
||||
152
packages/solver/clearResidualColoredLayer.ts
Normal file
152
packages/solver/clearResidualColoredLayer.ts
Normal 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);
|
||||
};
|
||||
28
packages/solver/getBestRoute.ts
Normal file
28
packages/solver/getBestRoute.ts
Normal 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);
|
||||
};
|
||||
113
packages/solver/getBestTunnel.ts
Normal file
113
packages/solver/getBestTunnel.ts
Normal 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;
|
||||
};
|
||||
66
packages/solver/getPathTo.ts
Normal file
66
packages/solver/getPathTo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
99
packages/solver/getPathToPose.ts
Normal file
99
packages/solver/getPathToPose.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
48
packages/solver/outside.ts
Normal file
48
packages/solver/outside.ts
Normal 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));
|
||||
7
packages/solver/package.json
Normal file
7
packages/solver/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@snk/solver",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"park-miller": "1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
isEmpty,
|
||||
isInside,
|
||||
setColorEmpty,
|
||||
} from "./grid";
|
||||
import { getHeadX, getHeadY, Snake } from "./snake";
|
||||
} 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);
|
||||
81
packages/solver/tunnel.ts
Normal file
81
packages/solver/tunnel.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user