Compare commits
141 Commits
v0.0.11
...
v1.0.2-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1e1967ef61 | ||
|
|
3d16c675bd | ||
|
|
8f5c1969a6 | ||
|
|
fe821f6251 | ||
|
|
d7423423f8 | ||
|
|
03396bae31 | ||
|
|
b63a1191b4 | ||
|
|
a9555b092a | ||
|
|
1f9dda0ca6 | ||
|
|
202bd7cacb | ||
|
|
bc18120a98 | ||
|
|
bb0750e8ba | ||
|
|
16a47349be | ||
|
|
b0784fbaca | ||
|
|
d5bdc84680 | ||
|
|
9c758febe7 | ||
|
|
2125640716 | ||
|
|
64f0b872aa | ||
|
|
9b92697ef9 | ||
|
|
8d8956229c | ||
|
|
2499529b1d | ||
|
|
3625bdb819 | ||
|
|
9ab55aaad6 | ||
|
|
48d89528d5 | ||
|
|
e637604df1 | ||
|
|
1898ec16e4 | ||
|
|
fd9d7dadf6 | ||
|
|
73bfce908e | ||
|
|
dd23c1630e | ||
|
|
7377068a9a | ||
|
|
8a06b668cd |
29
.github/workflows/deploy.yml
vendored
29
.github/workflows/deploy.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: bahmutov/npm-install@v1
|
||||
|
||||
- run: yarn build:demo
|
||||
env:
|
||||
BASE_PATHNAME: "snk"
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@068e494
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
78
.github/workflows/main.yml
vendored
78
.github/workflows/main.yml
vendored
@@ -1,20 +1,84 @@
|
||||
name: main
|
||||
|
||||
on: [push, pull_request]
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- run: sudo apt-get install gifsicle graphicsmagick
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: bahmutov/npm-install@v1
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: yarn type
|
||||
- run: yarn lint
|
||||
- run: yarn test --ci
|
||||
|
||||
test-benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: ( cd packages/gif-creator ; yarn benchmark )
|
||||
|
||||
test-action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: update action.yml
|
||||
run: |
|
||||
sed -i "s/image: .*/image: Dockerfile/" action.yml
|
||||
|
||||
- name: generate-snake-game-from-github-contribution-grid
|
||||
id: generate-snake
|
||||
uses: ./
|
||||
with:
|
||||
github_user_name: platane
|
||||
gif_out_path: dist/github-contribution-grid-snake.gif
|
||||
svg_out_path: dist/github-contribution-grid-snake.svg
|
||||
|
||||
- name: ensure the generated file exists
|
||||
run: |
|
||||
ls dist
|
||||
test -f ${{ steps.generate-snake.outputs.gif_out_path }}
|
||||
test -f ${{ steps.generate-snake.outputs.svg_out_path }}
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.5.0
|
||||
with:
|
||||
target_branch: output
|
||||
build_dir: dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
deploy-ghpages:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: yarn build:demo
|
||||
env:
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.6.0
|
||||
if: success() && github.ref == 'refs/heads/master'
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
|
||||
74
.github/workflows/release.yml
vendored
Normal file
74
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version"
|
||||
default: "0.0.1"
|
||||
required: true
|
||||
type: string
|
||||
description:
|
||||
description: "Version description"
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Prerelease"
|
||||
default: false
|
||||
required: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: docker/setup-qemu-action@v1
|
||||
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v2
|
||||
id: docker-build
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
platane/snk:${{ github.sha }}
|
||||
platane/snk:${{ github.event.inputs.version }}
|
||||
|
||||
- name: update action.yml
|
||||
run: |
|
||||
sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
|
||||
- name: bump version
|
||||
run: yarn version --no-git-tag-version --new-version ${{ github.event.inputs.version }}
|
||||
|
||||
- name: build svg-only action
|
||||
run: |
|
||||
yarn install --frozen-lockfile
|
||||
yarn build:action
|
||||
mv packages/action/dist/* svg-only/
|
||||
|
||||
- name: push new commit
|
||||
uses: EndBug/add-and-commit@v7
|
||||
with:
|
||||
add: package.json svg-only action.yml
|
||||
message: 📦 ${{ github.event.inputs.version }}
|
||||
tag: v${{ github.event.inputs.version }}
|
||||
|
||||
- uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
body: ${{ github.event.inputs.description }}
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,10 +1,32 @@
|
||||
FROM node:14-slim
|
||||
FROM node:16-slim as builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
COPY packages/action/dist/* ./github-contribution-grid-snake
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
CMD ["node", "github-contribution-grid-snake/index.js"]
|
||||
COPY tsconfig.json ./
|
||||
|
||||
COPY packages packages
|
||||
|
||||
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
|
||||
&& yarn install --frozen-lockfile \
|
||||
&& rm -r "$YARN_CACHE_FOLDER"
|
||||
|
||||
RUN yarn build:action
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM node:16-slim
|
||||
|
||||
WORKDIR /action-release
|
||||
|
||||
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
|
||||
&& yarn add canvas@2.9.1 gifsicle@5.3.0 --no-lockfile \
|
||||
&& rm -r "$YARN_CACHE_FOLDER"
|
||||
|
||||
COPY --from=builder /app/packages/action/dist/ /action-release/
|
||||
|
||||
CMD ["node", "/action-release/index.js"]
|
||||
|
||||
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# snk
|
||||
|
||||
[](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
|
||||

|
||||

|
||||
|
||||
Generates a snake game from a github user contributions graph
|
||||
|
||||

|
||||
|
||||
Pull a github user's contribution graph.
|
||||
Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion.
|
||||
|
||||
Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image.
|
||||
|
||||
Available as github action. Automatically generate a new image at the end of the day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
|
||||
|
||||
## Usage
|
||||
|
||||
**github action**
|
||||
|
||||
```yaml
|
||||
- uses: Platane/snk@master
|
||||
with:
|
||||
# github user name to read the contribution graph from (**required**)
|
||||
# using action context var `github.repository_owner` or specified user
|
||||
github_user_name: ${{ github.repository_owner }}
|
||||
|
||||
# path of the generated gif file
|
||||
# If left empty, the gif file will not be generated
|
||||
gif_out_path: dist/github-snake.gif
|
||||
|
||||
# path of the generated svg file
|
||||
# If left empty, the svg file will not be generated
|
||||
svg_out_path: dist/github-snake.svg
|
||||
```
|
||||
|
||||
> [example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
|
||||
|
||||
**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)
|
||||
31
action.yml
31
action.yml
@@ -1,23 +1,26 @@
|
||||
name: "github-contribution-grid-snake"
|
||||
description: ""
|
||||
name: "generate-snake-game-from-github-contribution-grid"
|
||||
description: "Generates a snake game from a github user contributions grid. Output the animation as gif or svg"
|
||||
author: "platane"
|
||||
|
||||
outputs:
|
||||
gif_out_path:
|
||||
description: ""
|
||||
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
args:
|
||||
- ${{ inputs.github_user_name }}
|
||||
- ${{ inputs.gif_out_path }}
|
||||
using: docker
|
||||
image: docker://platane/snk@sha256:d0501eedf6cf11223e720dd0b0071165e5a4f87aa67961a71a723ad273adbc77
|
||||
|
||||
inputs:
|
||||
github_user_name:
|
||||
description: ""
|
||||
description: "github user name"
|
||||
required: true
|
||||
gif_out_path:
|
||||
description: ""
|
||||
description: "path of the generated gif file. If left empty, the gif file will not be generated."
|
||||
required: false
|
||||
default: "./github-contribution-grid-snake.gif"
|
||||
default: null
|
||||
svg_out_path:
|
||||
description: "path of the generated svg file. If left empty, the svg file will not be generated."
|
||||
required: false
|
||||
default: null
|
||||
|
||||
outputs:
|
||||
gif_out_path:
|
||||
description: "path of the generated gif"
|
||||
svg_out_path:
|
||||
description: "path of the generated svg"
|
||||
|
||||
17
package.json
17
package.json
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"name": "snk",
|
||||
"version": "1.0.0",
|
||||
"description": "Generates a snake game from a github user contributions grid",
|
||||
"version": "1.0.2-rc.5",
|
||||
"private": true,
|
||||
"repository": "github:platane/snk",
|
||||
"devDependencies": {
|
||||
"@types/jest": "26.0.4",
|
||||
"jest": "26.1.0",
|
||||
"prettier": "2.0.5",
|
||||
"ts-jest": "26.1.2",
|
||||
"typescript": "3.9.6"
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "16.11.7",
|
||||
"jest": "27.5.1",
|
||||
"prettier": "2.6.0",
|
||||
"ts-jest": "27.1.3",
|
||||
"typescript": "4.6.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/**"
|
||||
],
|
||||
"scripts": {
|
||||
"type": "tsc --noEmit",
|
||||
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/action/dist/**' '!packages/demo/dist/**' '!packages/demo/webpack.config.js'",
|
||||
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**'",
|
||||
"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 )"
|
||||
}
|
||||
|
||||
3
packages/action/.gitignore
vendored
3
packages/action/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
!dist
|
||||
!dist/build
|
||||
out.gif
|
||||
15
packages/action/README.md
Normal file
15
packages/action/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @snk/action
|
||||
|
||||
Contains the github action code.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Docker
|
||||
|
||||
Because the gif generation requires some native libs, we cannot use a node.js action.
|
||||
|
||||
Use a docker action instead, the image is created from the [Dockerfile](./Dockerfile).
|
||||
|
||||
It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs )
|
||||
|
||||
Notice that the [action.yml](../../action.yml) point to the latest version of the image. Which makes releasing sematic versioning of the action pointless. Which is probably fine for a wacky project like this one.
|
||||
2
packages/action/__tests__/__snapshots__/.gitignore
vendored
Normal file
2
packages/action/__tests__/__snapshots__/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,5 +1,19 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
|
||||
generateContributionSnake("platane").then((buffer) => {
|
||||
process.stdout.write(buffer);
|
||||
});
|
||||
(async () => {
|
||||
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
|
||||
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
|
||||
|
||||
const buffer = await generateContributionSnake("platane", {
|
||||
svg: true,
|
||||
gif: true,
|
||||
});
|
||||
|
||||
console.log("💾 writing to", outputSvg);
|
||||
fs.writeFileSync(outputSvg, buffer.svg);
|
||||
|
||||
console.log("💾 writing to", outputGif);
|
||||
fs.writeFileSync(outputGif, buffer.gif);
|
||||
})();
|
||||
|
||||
38
packages/action/__tests__/generateContributionSnake.spec.ts
Normal file
38
packages/action/__tests__/generateContributionSnake.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
|
||||
jest.setTimeout(2 * 60 * 1000);
|
||||
|
||||
const silent = (handler: () => void | Promise<void>) => async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = () => undefined;
|
||||
try {
|
||||
return await handler();
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
};
|
||||
|
||||
it(
|
||||
"should generate contribution snake",
|
||||
silent(async () => {
|
||||
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
|
||||
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
|
||||
|
||||
console.log = () => undefined;
|
||||
const buffer = await generateContributionSnake("platane", {
|
||||
svg: true,
|
||||
gif: true,
|
||||
});
|
||||
|
||||
expect(buffer.svg).toBeDefined();
|
||||
expect(buffer.gif).toBeDefined();
|
||||
|
||||
console.log("💾 writing to", outputSvg);
|
||||
fs.writeFileSync(outputSvg, buffer.svg);
|
||||
|
||||
console.log("💾 writing to", outputGif);
|
||||
fs.writeFileSync(outputGif, buffer.gif);
|
||||
})
|
||||
);
|
||||
@@ -1,56 +1,53 @@
|
||||
import { getGithubUserContribution, Cell } from "@snk/github-user-contribution";
|
||||
import { generateEmptyGrid } from "@snk/compute/generateGrid";
|
||||
import { setColor } from "@snk/compute/grid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import { createGif } from "../gif-creator";
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import { userContributionToGrid } from "./userContributionToGrid";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { snake4 } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
export const userContributionToGrid = (cells: Cell[]) => {
|
||||
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
|
||||
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
|
||||
|
||||
const grid = generateEmptyGrid(width, height);
|
||||
for (const c of cells) setColor(grid, c.x, c.y, c.k === 0 ? null : c.k);
|
||||
|
||||
return grid;
|
||||
};
|
||||
|
||||
export const generateContributionSnake = async (userName: string) => {
|
||||
export const generateContributionSnake = async (
|
||||
userName: string,
|
||||
format: { svg?: boolean; gif?: boolean }
|
||||
) => {
|
||||
console.log("🎣 fetching github user contribution");
|
||||
const { cells, colorScheme } = await getGithubUserContribution(userName);
|
||||
|
||||
const grid0 = userContributionToGrid(cells);
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const snake = snake4;
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: colorScheme,
|
||||
colorDots: colorScheme as any,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorSnake: "purple",
|
||||
cells,
|
||||
dark: {
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
|
||||
},
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
const gifOptions = { delay: 10 };
|
||||
console.log("📡 computing best route");
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
const commands = computeBestRun(grid0, snake0, gameOptions);
|
||||
const output: Record<string, Buffer | string> = {};
|
||||
|
||||
const buffer = await createGif(
|
||||
grid0,
|
||||
snake0,
|
||||
commands,
|
||||
drawOptions,
|
||||
gameOptions,
|
||||
gifOptions
|
||||
);
|
||||
if (format.gif) {
|
||||
console.log("📹 creating gif");
|
||||
const { createGif } = await import("@snk/gif-creator");
|
||||
output.gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
if (format.svg) {
|
||||
console.log("🖌 creating svg");
|
||||
const { createSvg } = await import("@snk/svg-creator");
|
||||
output.svg = createSvg(grid, chain, drawOptions, gifOptions);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as core from "@actions/core";
|
||||
import { generateContributionSnake } from "./generateContributionSnake";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(core.getInput("user_name"));
|
||||
console.log(core.getInput("gif_out_path"));
|
||||
console.log("--");
|
||||
console.log(process.cwd());
|
||||
console.log("--");
|
||||
console.log(fs.readdirSync(process.cwd()));
|
||||
const userName = core.getInput("github_user_name");
|
||||
const format = {
|
||||
svg: core.getInput("svg_out_path"),
|
||||
gif: core.getInput("gif_out_path"),
|
||||
};
|
||||
|
||||
const buffer = await generateContributionSnake(core.getInput("user_name"));
|
||||
fs.writeFileSync(core.getInput("gif_out_path"), buffer);
|
||||
} catch (e) {
|
||||
const { svg, gif } = await generateContributionSnake(
|
||||
userName,
|
||||
format as any
|
||||
);
|
||||
|
||||
if (svg) {
|
||||
fs.mkdirSync(path.dirname(format.svg), { recursive: true });
|
||||
fs.writeFileSync(format.svg, svg);
|
||||
core.setOutput("svg_out_path", format.svg);
|
||||
}
|
||||
if (gif) {
|
||||
fs.mkdirSync(path.dirname(format.gif), { recursive: true });
|
||||
fs.writeFileSync(format.gif, gif);
|
||||
core.setOutput("gif_out_path", format.gif);
|
||||
}
|
||||
} catch (e: any) {
|
||||
core.setFailed(`Action failed with "${e.message}"`);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
"name": "@snk/action",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "1.2.4",
|
||||
"@actions/core": "1.6.0",
|
||||
"@snk/gif-creator": "1.0.0",
|
||||
"@snk/github-user-contribution": "1.0.0"
|
||||
"@snk/github-user-contribution": "1.0.0",
|
||||
"@snk/svg-creator": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@zeit/ncc": "0.22.3"
|
||||
"@zeit/ncc": "0.22.3",
|
||||
"ts-node": "10.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ncc build ./index.ts --out dist",
|
||||
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
|
||||
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts",
|
||||
"dev": "ts-node __tests__/dev.ts"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/action/userContributionToGrid.ts
Normal file
20
packages/action/userContributionToGrid.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid";
|
||||
import type { Cell } from "@snk/github-user-contribution";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
|
||||
export const userContributionToGrid = (
|
||||
cells: Cell[],
|
||||
colorScheme: string[]
|
||||
) => {
|
||||
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
|
||||
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
|
||||
|
||||
const grid = createEmptyGrid(width, height);
|
||||
for (const c of cells) {
|
||||
const k = colorScheme.indexOf(c.color);
|
||||
if (k > 0) setColor(grid, c.x, c.y, k as Color);
|
||||
else setColorEmpty(grid, c.x, c.y);
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { snakeSelfCollide } from "../snake";
|
||||
|
||||
test.each([
|
||||
[[{ x: 0, y: 0 }], false],
|
||||
[
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
true,
|
||||
],
|
||||
[
|
||||
[
|
||||
{ x: 1, y: 7 },
|
||||
{ x: 0, y: 6 },
|
||||
{ x: 2, y: 8 },
|
||||
{ x: 1, y: 7 },
|
||||
{ x: 3, y: 9 },
|
||||
],
|
||||
true,
|
||||
],
|
||||
])("should report snake collision", (snake, collide) => {
|
||||
expect(snakeSelfCollide(snake)).toBe(collide);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { step } from "../step";
|
||||
import { generateEmptyGrid } from "../generateGrid";
|
||||
import { around4 } from "../point";
|
||||
import { setColor, getColor } from "../grid";
|
||||
|
||||
it("should move snake", () => {
|
||||
const grid = generateEmptyGrid(4, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 5 };
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should move short snake", () => {
|
||||
const grid = generateEmptyGrid(8, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 3 };
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should pick up fruit", () => {
|
||||
const grid = generateEmptyGrid(4, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 2 };
|
||||
setColor(grid, 3, 1, 9);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(getColor(grid, 3, 1)).toBe(9);
|
||||
expect(stack).toEqual([]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(getColor(grid, 3, 1)).toBe(null);
|
||||
expect(stack).toEqual([9]);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Grid, Color } from "./grid";
|
||||
|
||||
const rand = (a: number, b: number) => Math.floor(Math.random() * (b - a)) + a;
|
||||
|
||||
export const generateEmptyGrid = (width: number, height: number) =>
|
||||
generateGrid(width, height, { colors: [], emptyP: 1 });
|
||||
|
||||
export const generateGrid = (
|
||||
width: number,
|
||||
height: number,
|
||||
options: { colors: Color[]; emptyP: number } = {
|
||||
colors: [1, 2, 3],
|
||||
emptyP: 2,
|
||||
}
|
||||
): Grid => {
|
||||
const g = {
|
||||
width,
|
||||
height,
|
||||
data: Array.from({ length: width * height }, () => {
|
||||
const x = rand(-options.emptyP, options.colors.length);
|
||||
|
||||
return x < 0 ? null : options.colors[x];
|
||||
}),
|
||||
};
|
||||
|
||||
return g;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
export type Color = number;
|
||||
|
||||
export type Grid = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: (Color | null)[];
|
||||
};
|
||||
|
||||
export const getIndex = (grid: Grid, x: number, y: number) =>
|
||||
x * grid.height + y;
|
||||
|
||||
export const isInside = (grid: Grid, x: number, y: number) =>
|
||||
x >= 0 && y >= 0 && x < grid.width && y < grid.height;
|
||||
|
||||
export const isInsideLarge = (grid: Grid, m: number, x: number, y: number) =>
|
||||
x >= -m && y >= -m && x < grid.width + m && y < grid.height + m;
|
||||
|
||||
export const getColor = (grid: Grid, x: number, y: number) =>
|
||||
grid.data[getIndex(grid, x, y)];
|
||||
|
||||
export const copyGrid = (grid: Grid) => ({ ...grid, data: grid.data.slice() });
|
||||
|
||||
export const setColor = (
|
||||
grid: Grid,
|
||||
x: number,
|
||||
y: number,
|
||||
color: Color | null
|
||||
) => {
|
||||
grid.data[getIndex(grid, x, y)] = color;
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Grid, Color, copyGrid, isInsideLarge } from "./grid";
|
||||
import { Point, around4 } from "./point";
|
||||
import { stepSnake, step } from "./step";
|
||||
import { copySnake, snakeSelfCollide } from "./snake";
|
||||
|
||||
const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === null);
|
||||
|
||||
export const computeBestRun = (
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const g = copyGrid(grid);
|
||||
const s = copySnake(snake);
|
||||
const q: Color[] = [];
|
||||
|
||||
const commands: Point[] = [];
|
||||
|
||||
let u = 500;
|
||||
|
||||
while (!isGridEmpty(g) && u-- > 0) {
|
||||
let direction;
|
||||
|
||||
for (let k = 10; k--; ) {
|
||||
direction = around4[Math.floor(Math.random() * around4.length)];
|
||||
|
||||
const sn = copySnake(s);
|
||||
stepSnake(sn, direction, options);
|
||||
|
||||
if (isInsideLarge(g, 1, sn[0].x, sn[0].y) && !snakeSelfCollide(sn)) {
|
||||
break;
|
||||
} else {
|
||||
direction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction !== undefined) {
|
||||
step(g, s, q, direction, options);
|
||||
commands.push(direction);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "@snk/compute",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Point } from "./point";
|
||||
|
||||
export const snakeSelfCollideNext = (
|
||||
snake: Point[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const hx = snake[0].x + direction.x;
|
||||
const hy = snake[0].y + direction.y;
|
||||
|
||||
for (let i = 0; i < Math.min(options.maxSnakeLength, snake.length); i++)
|
||||
if (snake[i].x === hx && snake[i].y === hy) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const snakeSelfCollide = (snake: Point[]) => {
|
||||
for (let i = 1; i < snake.length; i++)
|
||||
if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Grid, Color, getColor, isInside, setColor } from "./grid";
|
||||
import { Point } from "./point";
|
||||
|
||||
const moveSnake = (snake: Point[], headx: number, heady: number) => {
|
||||
for (let k = snake.length - 1; k > 0; k--) {
|
||||
snake[k].x = snake[k - 1].x;
|
||||
snake[k].y = snake[k - 1].y;
|
||||
}
|
||||
snake[0].x = headx;
|
||||
snake[0].y = heady;
|
||||
};
|
||||
|
||||
export const stepSnake = (
|
||||
snake: Point[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const headx = snake[0].x + direction.x;
|
||||
const heady = snake[0].y + direction.y;
|
||||
|
||||
if (snake.length === options.maxSnakeLength) {
|
||||
moveSnake(snake, headx, heady);
|
||||
} else {
|
||||
snake.unshift({ x: headx, y: heady });
|
||||
}
|
||||
};
|
||||
|
||||
export const stepPicking = (grid: Grid, snake: Point[], stack: Color[]) => {
|
||||
if (isInside(grid, snake[0].x, snake[0].y)) {
|
||||
const c = getColor(grid, snake[0].x, snake[0].y);
|
||||
|
||||
if (c) {
|
||||
setColor(grid, snake[0].x, snake[0].y, null);
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const step = (
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
stack: Color[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
stepSnake(snake, direction, options);
|
||||
stepPicking(grid, snake, stack);
|
||||
};
|
||||
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.
|
||||
98
packages/demo/canvas.ts
Normal file
98
packages/demo/canvas.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Color, Grid } from "@snk/types/grid";
|
||||
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
|
||||
export const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: {
|
||||
1: "#9be9a8",
|
||||
2: "#40c463",
|
||||
3: "#30a14e",
|
||||
4: "#216e39",
|
||||
},
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
dark: {
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
|
||||
},
|
||||
};
|
||||
|
||||
const getPointedCell =
|
||||
(canvas: HTMLCanvasElement) =>
|
||||
({ pageX, pageY }: MouseEvent) => {
|
||||
const { left, top } = canvas.getBoundingClientRect();
|
||||
|
||||
const x = Math.floor((pageX - left) / drawOptions.sizeCell) - 1;
|
||||
const y = Math.floor((pageY - top) / drawOptions.sizeCell) - 2;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const createCanvas = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const upscale = 2;
|
||||
const w = drawOptions.sizeCell * (width + 4);
|
||||
const h = drawOptions.sizeCell * (height + 4) + 200;
|
||||
canvas.width = w * upscale;
|
||||
canvas.height = h * upscale;
|
||||
canvas.style.width = w + "px";
|
||||
canvas.style.height = h + "px";
|
||||
canvas.style.display = "block";
|
||||
// canvas.style.pointerEvents = "none";
|
||||
|
||||
const cellInfo = document.createElement("div");
|
||||
cellInfo.style.height = "20px";
|
||||
|
||||
document.body.appendChild(cellInfo);
|
||||
document.body.appendChild(canvas);
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
const { x, y } = getPointedCell(canvas)(e);
|
||||
cellInfo.innerText = [x, y]
|
||||
.map((u) => u.toString().padStart(2, " "))
|
||||
.join(" / ");
|
||||
});
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.scale(upscale, upscale);
|
||||
|
||||
const draw = (grid: Grid, snake: Snake, stack: Color[]) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const drawLerp = (
|
||||
grid: Grid,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number
|
||||
) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
};
|
||||
|
||||
const highlightCell = (x: number, y: number, color = "orange") => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
|
||||
};
|
||||
|
||||
return {
|
||||
draw,
|
||||
drawLerp,
|
||||
highlightCell,
|
||||
canvas,
|
||||
getPointedCell: getPointedCell(canvas),
|
||||
ctx,
|
||||
};
|
||||
};
|
||||
41
packages/demo/demo.getBestRoute.ts
Normal file
41
packages/demo/demo.getBestRoute.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { Color, copyGrid } from "@snk/types/grid";
|
||||
import { grid, snake } from "./sample";
|
||||
import { step } from "@snk/solver/step";
|
||||
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
|
||||
//
|
||||
// draw
|
||||
let k = 0;
|
||||
|
||||
const { canvas, draw } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const onChange = () => {
|
||||
const gridN = copyGrid(grid);
|
||||
const stack: Color[] = [];
|
||||
for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]);
|
||||
|
||||
draw(gridN, chain[k], stack);
|
||||
};
|
||||
onChange();
|
||||
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = chain.length - 1;
|
||||
input.style.width = "90%";
|
||||
input.addEventListener("input", () => {
|
||||
k = +input.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(input);
|
||||
window.addEventListener("click", (e) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
input.focus();
|
||||
});
|
||||
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);
|
||||
255
packages/demo/demo.interactive.ts
Normal file
255
packages/demo/demo.interactive.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { Color, copyGrid, Grid } from "@snk/types/grid";
|
||||
import { step } from "@snk/solver/step";
|
||||
import { isStableAndBound, stepSpring } from "./springUtils";
|
||||
import { Res } from "@snk/github-user-contribution";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
Options,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import { userContributionToGrid } from "../action/userContributionToGrid";
|
||||
import { snake4 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import { createSvg } from "../svg-creator";
|
||||
|
||||
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).catch((err) => {
|
||||
label.innerText = "error :(";
|
||||
throw err;
|
||||
});
|
||||
|
||||
input.disabled = true;
|
||||
submit.disabled = true;
|
||||
form.appendChild(label);
|
||||
label.innerText = "loading ...";
|
||||
});
|
||||
|
||||
//
|
||||
// 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.alignItems = "flex-start";
|
||||
const image = document.createElement("img");
|
||||
image.style.width = "100px";
|
||||
image.style.height = "100px";
|
||||
image.style.borderRadius = "50px";
|
||||
const name = document.createElement("a");
|
||||
name.style.padding = "4px 0 0 0";
|
||||
|
||||
document.body.appendChild(container);
|
||||
container.appendChild(image);
|
||||
container.appendChild(name);
|
||||
|
||||
image.addEventListener("load", () => {
|
||||
container.style.opacity = "1";
|
||||
});
|
||||
const onChangeUser = (userName: string) => {
|
||||
container.style.opacity = "0";
|
||||
name.innerText = userName;
|
||||
name.href = `https://github.com/${userName}`;
|
||||
image.src = `https://github.com/${userName}.png`;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
document.body.removeChild(container);
|
||||
};
|
||||
|
||||
return { dispose, onChangeUser };
|
||||
};
|
||||
|
||||
const createViewer = ({
|
||||
grid0,
|
||||
chain,
|
||||
drawOptions,
|
||||
}: {
|
||||
grid0: Grid;
|
||||
chain: Snake[];
|
||||
drawOptions: Options;
|
||||
}) => {
|
||||
//
|
||||
// canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const w = Math.min(width, window.innerWidth);
|
||||
const h = (height / width) * w;
|
||||
canvas.style.width = w + "px";
|
||||
canvas.style.height = h + "px";
|
||||
canvas.style.pointerEvents = "none";
|
||||
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
//
|
||||
// draw
|
||||
let animationFrame: number;
|
||||
const spring = { x: 0, v: 0, target: 0 };
|
||||
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const loop = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
|
||||
stepSpring(spring, springParams, spring.target);
|
||||
const stable = isStableAndBound(spring, spring.target);
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
const stack: Color[] = [];
|
||||
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
|
||||
step(grid, stack, chain[i]);
|
||||
|
||||
const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)];
|
||||
const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)];
|
||||
const k = spring.x % 1;
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
|
||||
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
|
||||
//
|
||||
// controls
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.style.width = "calc( 100% - 20px )";
|
||||
input.addEventListener("input", () => {
|
||||
spring.target = +input.value;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(loop);
|
||||
});
|
||||
const onClickBackground = (e: MouseEvent) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
input.focus();
|
||||
};
|
||||
window.addEventListener("click", onClickBackground);
|
||||
document.body.append(input);
|
||||
|
||||
//
|
||||
// svg
|
||||
const svgLink = document.createElement("a");
|
||||
const svgString = createSvg(grid0, chain, drawOptions, {
|
||||
frameDuration: 100,
|
||||
});
|
||||
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
|
||||
svgLink.href = svgImageUri;
|
||||
svgLink.innerText = "github-user-contribution.svg";
|
||||
svgLink.download = "github-user-contribution.svg";
|
||||
svgLink.addEventListener("click", (e) => {
|
||||
const w = window.open("")!;
|
||||
w.document.write(
|
||||
`<a href="${svgImageUri}" download="github-user-contribution.svg">` +
|
||||
svgString +
|
||||
"<a/>"
|
||||
);
|
||||
e.preventDefault();
|
||||
});
|
||||
svgLink.style.padding = "20px";
|
||||
svgLink.style.paddingTop = "60px";
|
||||
svgLink.style.alignSelf = "flex-start";
|
||||
document.body.append(svgLink);
|
||||
|
||||
//
|
||||
// dispose
|
||||
const dispose = () => {
|
||||
window.removeEventListener("click", onClickBackground);
|
||||
cancelAnimationFrame(animationFrame);
|
||||
document.body.removeChild(canvas);
|
||||
document.body.removeChild(input);
|
||||
document.body.removeChild(svgLink);
|
||||
};
|
||||
|
||||
return { dispose };
|
||||
};
|
||||
|
||||
const onSubmit = async (userName: string) => {
|
||||
const res = await fetch(
|
||||
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName
|
||||
);
|
||||
const { cells, colorScheme } = (await res.json()) as Res;
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: colorScheme as any,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorSnake: "purple",
|
||||
cells,
|
||||
};
|
||||
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
dispose();
|
||||
|
||||
createViewer({ grid0: grid, chain, drawOptions });
|
||||
};
|
||||
|
||||
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";
|
||||
9
packages/demo/demo.json
Normal file
9
packages/demo/demo.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
"interactive",
|
||||
"getBestRoute",
|
||||
"getBestTunnel",
|
||||
"outside",
|
||||
"getPathToPose",
|
||||
"getPathTo",
|
||||
"svg"
|
||||
]
|
||||
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();
|
||||
});
|
||||
17
packages/demo/demo.svg.ts
Normal file
17
packages/demo/demo.svg.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "./menu";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { createSvg } from "../svg-creator";
|
||||
import { grid, snake } from "./sample";
|
||||
import { drawOptions } from "./canvas";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
const chain = getBestRoute(grid, snake);
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
(async () => {
|
||||
const svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 });
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svg;
|
||||
document.body.appendChild(container);
|
||||
})();
|
||||
@@ -1,81 +0,0 @@
|
||||
// import { generateGrid } from "@snk/compute/generateGrid";
|
||||
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { Color, copyGrid } from "@snk/compute/grid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import { step } from "@snk/compute/step";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Point } from "@snk/compute/point";
|
||||
|
||||
const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
|
||||
export const run = async () => {
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const grid0 = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const stack0: Color[] = [];
|
||||
|
||||
const chain = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = drawOptions.sizeCell * (grid0.width + 4);
|
||||
canvas.height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const update = (n: number) => {
|
||||
const snake = copySnake(snake0);
|
||||
const stack = stack0.slice();
|
||||
const grid = copyGrid(grid0);
|
||||
|
||||
for (let i = 0; i < n; i++) step(grid, snake, stack, chain[i], gameOptions);
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const input: any = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.style.width = "100%";
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.step = 1;
|
||||
input.value = 0;
|
||||
input.addEventListener("input", () => update(+input.value));
|
||||
document.addEventListener("click", () => input.focus());
|
||||
|
||||
document.body.appendChild(input);
|
||||
|
||||
update(+input.value);
|
||||
|
||||
// while (chain.length) {
|
||||
// await wait(100);
|
||||
|
||||
// step(grid, snake, stack, chain.shift()!, gameOptions);
|
||||
|
||||
// ctx.clearRect(0, 0, 9999, 9999);
|
||||
// drawWorld(ctx, grid, snake, stack, options);
|
||||
// }
|
||||
|
||||
// const wait = (delay = 0) => new Promise((r) => setTimeout(r, delay));
|
||||
};
|
||||
|
||||
run();
|
||||
36
packages/demo/menu.ts
Normal file
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,18 +2,23 @@
|
||||
"name": "@snk/demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0"
|
||||
"@snk/draw": "1.0.0",
|
||||
"@snk/solver": "1.0.0",
|
||||
"canvas": "2.9.1",
|
||||
"gifsicle": "5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "4.43.0",
|
||||
"webpack-cli": "3.3.12",
|
||||
"webpack-dev-server": "3.11.0",
|
||||
"ts-loader": "8.0.1",
|
||||
"html-webpack-plugin": "4.3.0"
|
||||
"@types/dat.gui": "0.7.7",
|
||||
"dat.gui": "0.7.7",
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"ts-loader": "9.2.6",
|
||||
"ts-node": "10.7.0",
|
||||
"webpack": "5.70.0",
|
||||
"webpack-cli": "4.9.2",
|
||||
"webpack-dev-server": "4.7.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "tsc webpack.config.ts",
|
||||
"build": "yarn prepare ; webpack",
|
||||
"dev": "yarn prepare ; webpack-dev-server --port ${PORT-3000}"
|
||||
"build": "webpack",
|
||||
"dev": "webpack serve"
|
||||
}
|
||||
}
|
||||
|
||||
14
packages/demo/sample.ts
Normal file
14
packages/demo/sample.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import * as snakes from "@snk/types/__fixtures__/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Grid } from "@snk/types/grid";
|
||||
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
|
||||
const gLabel = sp.get("grid") || "simple";
|
||||
const sLabel = sp.get("snake") || "snake3";
|
||||
|
||||
//@ts-ignore
|
||||
export const grid: Grid = grids[gLabel] || grids.simple;
|
||||
//@ts-ignore
|
||||
export const snake: Snake = snakes[sLabel] || snakes.snake3;
|
||||
63
packages/demo/springUtils.ts
Normal file
63
packages/demo/springUtils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
const epsilon = 0.01;
|
||||
|
||||
export const clamp = (a: number, b: number) => (x: number) =>
|
||||
Math.max(a, Math.min(b, x));
|
||||
|
||||
/**
|
||||
* step the spring, mutate the state to reflect the state at t+dt
|
||||
*
|
||||
*/
|
||||
const stepSpringOne = (
|
||||
s: { x: number; v: number },
|
||||
{
|
||||
tension,
|
||||
friction,
|
||||
maxVelocity = Infinity,
|
||||
}: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => {
|
||||
const a = -tension * (s.x - target) - friction * s.v;
|
||||
|
||||
s.v += a * dt;
|
||||
s.v = clamp(-maxVelocity / dt, maxVelocity / dt)(s.v);
|
||||
s.x += s.v * dt;
|
||||
};
|
||||
|
||||
/**
|
||||
* return true if the spring is to be considered in a stable state
|
||||
* ( close enough to the target and with a small enough velocity )
|
||||
*/
|
||||
export const isStable = (
|
||||
s: { x: number; v: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon;
|
||||
|
||||
export const isStableAndBound = (
|
||||
s: { x: number; v: number },
|
||||
target: number,
|
||||
dt?: number
|
||||
) => {
|
||||
const stable = isStable(s, target, dt);
|
||||
if (stable) {
|
||||
s.x = target;
|
||||
s.v = 0;
|
||||
}
|
||||
return stable;
|
||||
};
|
||||
|
||||
export const stepSpring = (
|
||||
s: { x: number; v: number },
|
||||
params: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => {
|
||||
const interval = 1 / 60;
|
||||
|
||||
while (dt > 0) {
|
||||
stepSpringOne(s, params, target, Math.min(interval, dt));
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
dt -= interval;
|
||||
}
|
||||
};
|
||||
@@ -1,47 +1,75 @@
|
||||
import * as path from "path";
|
||||
import path from "path";
|
||||
import HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
|
||||
// @ts-ignore
|
||||
import * as HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
import type { Configuration } from "webpack";
|
||||
import type { Configuration as WebpackConfiguration } from "webpack";
|
||||
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
|
||||
import webpack from "webpack";
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
|
||||
const basePathname = (process.env.BASE_PATHNAME || "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const demos: string[] = require("./demo.json");
|
||||
|
||||
const config: Configuration = {
|
||||
const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
|
||||
open: { target: demos[1] + ".html" },
|
||||
onAfterSetupMiddleware: ({ app }) => {
|
||||
app!.get("/api/github-user-contribution/:userName", async (req, res) => {
|
||||
const userName: string = req.params.userName;
|
||||
res.send(await getGithubUserContribution(userName));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const webpackConfiguration: WebpackConfiguration = {
|
||||
mode: "development",
|
||||
entry: "./index",
|
||||
entry: Object.fromEntries(
|
||||
demos.map((demo: string) => [demo, `./demo.${demo}`])
|
||||
),
|
||||
target: ["web", "es2019"],
|
||||
resolve: { extensions: [".ts", ".js"] },
|
||||
output: {
|
||||
path: path.join(__dirname, "dist"),
|
||||
filename: "[contenthash].js",
|
||||
publicPath: "/" + basePathname.map((x) => x + "/").join(""),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
exclude: /node_modules/,
|
||||
test: /\.(js|ts)$/,
|
||||
test: /\.ts$/,
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
lib: ["dom", "es2020"],
|
||||
target: "es2019",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
// game
|
||||
...demos.map(
|
||||
(demo) =>
|
||||
new HtmlWebpackPlugin({
|
||||
title: "snk - " + demo,
|
||||
filename: `${demo}.html`,
|
||||
chunks: [demo],
|
||||
})
|
||||
),
|
||||
new HtmlWebpackPlugin({
|
||||
title: "demo",
|
||||
filename: "index.html",
|
||||
meta: {
|
||||
viewport: "width=device-width, initial-scale=1, shrink-to-fit=no",
|
||||
},
|
||||
title: "snk - " + demos[0],
|
||||
filename: `index.html`,
|
||||
chunks: [demos[0]],
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT:
|
||||
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT ??
|
||||
"/api/github-user-contribution/",
|
||||
}),
|
||||
],
|
||||
|
||||
devtool: false,
|
||||
stats: "errors-only",
|
||||
|
||||
// @ts-ignore
|
||||
devServer: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default {
|
||||
...webpackConfiguration,
|
||||
devServer: webpackDevServerConfiguration,
|
||||
};
|
||||
|
||||
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.
|
||||
86
packages/draw/drawCircleStack.ts
Normal file
86
packages/draw/drawCircleStack.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorBorder: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
};
|
||||
|
||||
const isInsideCircle = (x: number, y: number, r: number) => {
|
||||
const l = 6;
|
||||
let k = 0;
|
||||
for (let dx = 0; dx < l; dx++)
|
||||
for (let dy = 0; dy < l; dy++) {
|
||||
const ux = x + (dx + 0.5) / l;
|
||||
const uy = y + (dy + 0.5) / l;
|
||||
|
||||
if (ux * ux + uy * uy < r * r) k++;
|
||||
}
|
||||
|
||||
return k > l * l * 0.6;
|
||||
};
|
||||
|
||||
export const getCellPath = (n: number): Point[] => {
|
||||
const l = Math.ceil(Math.sqrt(n));
|
||||
|
||||
const cells = [];
|
||||
|
||||
for (let x = -l; x <= l; x++)
|
||||
for (let y = -l; y <= l; y++) {
|
||||
const a = (Math.atan2(y, x) + (5 * Math.PI) / 2) % (Math.PI * 2);
|
||||
|
||||
let r = 0;
|
||||
|
||||
while (!isInsideCircle(x, y, r + 0.5)) r++;
|
||||
|
||||
cells.push({ x, y, f: r * 100 + a });
|
||||
}
|
||||
|
||||
return cells.sort((a, b) => a.f - b.f).slice(0, n);
|
||||
};
|
||||
|
||||
export const cellPath = getCellPath(52 * 7 + 5);
|
||||
|
||||
export const getCircleSize = (n: number) => {
|
||||
const c = cellPath.slice(0, n);
|
||||
const xs = c.map((p) => p.x);
|
||||
const ys = c.map((p) => p.y);
|
||||
|
||||
return {
|
||||
max: { x: Math.max(0, ...xs), y: Math.max(0, ...ys) },
|
||||
min: { x: Math.min(0, ...xs), y: Math.min(0, ...ys) },
|
||||
};
|
||||
};
|
||||
|
||||
export const drawCircleStack = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
) => {
|
||||
for (let i = stack.length; i--; ) {
|
||||
const { x, y } = cellPath[i];
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
|
||||
//@ts-ignore
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Grid, getColor, Color } from "@snk/compute/grid";
|
||||
import { getColor } from "@snk/types/grid";
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
@@ -8,6 +10,7 @@ type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawGrid = (
|
||||
@@ -17,26 +20,28 @@ export const drawGrid = (
|
||||
) => {
|
||||
for (let x = grid.width; x--; )
|
||||
for (let y = grid.height; y--; ) {
|
||||
const c = getColor(grid, x, y);
|
||||
// @ts-ignore
|
||||
const color = c === null ? o.colorEmpty : o.colorDots[c];
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) {
|
||||
const c = getColor(grid, x, y);
|
||||
// @ts-ignore
|
||||
const color = !c ? o.colorEmpty : o.colorDots[c];
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
69
packages/draw/drawSnake.ts
Normal file
69
packages/draw/drawSnake.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { snakeToCells } from "@snk/types/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
|
||||
type Options = {
|
||||
colorSnake: string;
|
||||
sizeCell: number;
|
||||
};
|
||||
|
||||
export const drawSnake = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake: Snake,
|
||||
o: Options
|
||||
) => {
|
||||
const cells = snakeToCells(snake);
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const u = (i + 1) * 0.6;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
|
||||
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||
|
||||
export const drawSnakeLerp = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
k: number,
|
||||
o: Options
|
||||
) => {
|
||||
const m = 0.8;
|
||||
const n = snake0.length / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u = (i + 1) * 0.6 * (o.sizeCell / 16);
|
||||
|
||||
const a = (1 - m) * (i / Math.max(n - 1, 1));
|
||||
const ki = clamp((k - a) / m, 0, 1);
|
||||
|
||||
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
|
||||
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Grid, Color } from "@snk/compute/grid";
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import { drawGrid } from "./drawGrid";
|
||||
import { drawSnake, drawSnakeLerp } from "./drawSnake";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
|
||||
type Options = {
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
@@ -11,53 +12,85 @@ type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawSnake = (
|
||||
export const drawStack = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake: Point[],
|
||||
o: Options
|
||||
stack: Color[],
|
||||
max: number,
|
||||
width: number,
|
||||
o: { colorDots: Record<Color, string> }
|
||||
) => {
|
||||
for (let i = 0; i < snake.length; i++) {
|
||||
const u = (i + 1) * 0.6;
|
||||
ctx.save();
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(snake[i].x * o.sizeCell + u, snake[i].y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
const m = width / max;
|
||||
|
||||
for (let i = 0; i < stack.length; i++) {
|
||||
// @ts-ignore
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.fillRect(i * m, 0, m + width * 0.005, 10);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
export const drawWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
snake: Snake,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(2 * o.sizeCell, 2 * o.sizeCell);
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawSnake(ctx, snake, o);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
const m = 5;
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell);
|
||||
for (let i = 0; i < stack.length; i++) {
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.fillRect(i * m, 0, m, 10);
|
||||
}
|
||||
|
||||
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
|
||||
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// ctx.save();
|
||||
// ctx.translate(o.sizeCell + 100, (grid.height + 4) * o.sizeCell + 100);
|
||||
// ctx.scale(0.6, 0.6);
|
||||
// drawCircleStack(ctx, stack, o);
|
||||
// ctx.restore();
|
||||
};
|
||||
|
||||
export const drawLerpWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number,
|
||||
o: Options
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawSnakeLerp(ctx, snake0, snake1, k, o);
|
||||
|
||||
ctx.translate(0, (grid.height + 2) * o.sizeCell);
|
||||
|
||||
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
|
||||
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
export const getCanvasWorldSize = (grid: Grid, o: { sizeCell: number }) => {
|
||||
const width = o.sizeCell * (grid.width + 2);
|
||||
const height = o.sizeCell * (grid.height + 4) + 30;
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"name": "@snk/draw",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0"
|
||||
"@snk/solver": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/gif-creator/.gitignore
vendored
1
packages/gif-creator/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
out.gif
|
||||
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
|
||||
77
packages/gif-creator/__tests__/benchmark.ts
Normal file
77
packages/gif-creator/__tests__/benchmark.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as fs from "fs";
|
||||
import { performance } from "perf_hooks";
|
||||
import { createSnakeFromCells } from "@snk/types/snake";
|
||||
import { realistic as grid } from "@snk/types/__fixtures__/grid";
|
||||
import { createGif } from "..";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
let snake = createSnakeFromCells(
|
||||
Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 }))
|
||||
);
|
||||
|
||||
// const chain = [snake];
|
||||
// for (let y = -1; y < grid.height; y++) {
|
||||
// snake = nextSnake(snake, 0, 1);
|
||||
// chain.push(snake);
|
||||
|
||||
// for (let x = grid.width - 1; x--; ) {
|
||||
// snake = nextSnake(snake, (y + 100) % 2 ? 1 : -1, 0);
|
||||
// chain.push(snake);
|
||||
// }
|
||||
// }
|
||||
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
(async () => {
|
||||
for (
|
||||
let length = 10;
|
||||
length < chain.length;
|
||||
length += Math.floor((chain.length - 10) / 3 / 10) * 10
|
||||
) {
|
||||
const stats: number[] = [];
|
||||
|
||||
let buffer: Buffer;
|
||||
const start = Date.now();
|
||||
const chainL = chain.slice(0, length);
|
||||
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
|
||||
const s = performance.now();
|
||||
buffer = await createGif(grid, chainL, drawOptions, gifOptions);
|
||||
stats.push(performance.now() - s);
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
"---",
|
||||
`grid dimension: ${grid.width}x${grid.height}`,
|
||||
`chain length: ${length}`,
|
||||
`resulting size: ${(buffer!.length / 1024).toFixed(1)}ko`,
|
||||
`generation duration (mean): ${(
|
||||
stats.reduce((s, x) => x + s) / stats.length
|
||||
).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}ms`,
|
||||
"",
|
||||
].join("\n"),
|
||||
stats
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
`__tests__/__snapshots__/benchmark-output-${length}.gif`,
|
||||
buffer!
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -1,42 +1,71 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createGif } from "..";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { createSnakeFromCells, nextSnake } from "@snk/types/snake";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
|
||||
jest.setTimeout(20 * 1000);
|
||||
|
||||
const upscale = 1;
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
sizeBorderRadius: 2 * upscale,
|
||||
sizeCell: 16 * upscale,
|
||||
sizeDot: 12 * upscale,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
const gifOptions = { frameDuration: 200, step: 1 };
|
||||
|
||||
const gifOptions = { delay: 200 };
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
|
||||
it("should generate gif", async () => {
|
||||
const grid = generateGrid(14, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
} catch (err) {}
|
||||
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
for (const key of [
|
||||
"empty",
|
||||
"simple",
|
||||
"corner",
|
||||
"small",
|
||||
"smallPacked",
|
||||
] as const)
|
||||
it(`should generate ${key} gif`, async () => {
|
||||
const grid = grids[key];
|
||||
|
||||
const commands = computeBestRun(grid, snake, gameOptions).slice(0, 9);
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
|
||||
const gif = await createGif(
|
||||
grid,
|
||||
snake,
|
||||
commands,
|
||||
drawOptions,
|
||||
gameOptions,
|
||||
gifOptions
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
|
||||
});
|
||||
|
||||
it(`should generate swipper`, async () => {
|
||||
const grid = grids.smallFull;
|
||||
let snk = createSnakeFromCells(
|
||||
Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 }))
|
||||
);
|
||||
|
||||
const chain = [snk];
|
||||
for (let y = -1; y < grid.height; y++) {
|
||||
snk = nextSnake(snk, 0, 1);
|
||||
chain.push(snk);
|
||||
|
||||
for (let x = grid.width - 1; x--; ) {
|
||||
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
|
||||
chain.push(snk);
|
||||
}
|
||||
}
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createGif } from "..";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const gifOptions = { delay: 20 };
|
||||
|
||||
const grid = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
const commands = computeBestRun(grid, snake, gameOptions);
|
||||
|
||||
createGif(grid, snake, commands, drawOptions, gameOptions, gifOptions).then(
|
||||
(buffer) => {
|
||||
process.stdout.write(buffer);
|
||||
}
|
||||
);
|
||||
@@ -1,92 +1,94 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import { createCanvas } from "canvas";
|
||||
import { Grid, copyGrid, Color } from "@snk/compute/grid";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import { copySnake } from "@snk/compute/snake";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/compute/step";
|
||||
import * as tmp from "tmp";
|
||||
import { Grid, copyGrid, Color } from "@snk/types/grid";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
Options,
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/solver/step";
|
||||
import tmp from "tmp";
|
||||
import gifsicle from "gifsicle";
|
||||
// @ts-ignore
|
||||
import * as execa from "execa";
|
||||
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
snake0: Point[],
|
||||
commands: Point[],
|
||||
drawOptions: Parameters<typeof drawWorld>[4],
|
||||
gameOptions: Parameters<typeof step>[4],
|
||||
gifOptions: { delay: number }
|
||||
) => {
|
||||
const grid = copyGrid(grid0);
|
||||
const snake = copySnake(snake0);
|
||||
const stack: Color[] = [];
|
||||
|
||||
const width = drawOptions.sizeCell * (grid.width + 4);
|
||||
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
|
||||
import GIFEncoder from "gif-encoder-2";
|
||||
|
||||
const withTmpDir = async <T>(
|
||||
handler: (dir: string) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
|
||||
unsafeCleanup: true,
|
||||
});
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const writeImage = (i: number) => {
|
||||
ctx.clearRect(0, 0, 99999, 99999);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, 99999, 99999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
|
||||
const buffer = canvas.toBuffer("image/png", {
|
||||
compressionLevel: 0,
|
||||
filters: canvas.PNG_FILTER_NONE,
|
||||
});
|
||||
|
||||
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
|
||||
|
||||
fs.writeFileSync(fileName, buffer);
|
||||
};
|
||||
|
||||
try {
|
||||
writeImage(0);
|
||||
return await handler(dir);
|
||||
} finally {
|
||||
cleanUp();
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
step(grid, snake, stack, commands[i], gameOptions);
|
||||
writeImage(i + 1);
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number; step: number }
|
||||
) =>
|
||||
withTmpDir(async (dir) => {
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
const stack: Color[] = [];
|
||||
|
||||
const encoder = new GIFEncoder(width, height, "neuquant", true);
|
||||
encoder.setRepeat(0);
|
||||
encoder.setDelay(gifOptions.frameDuration);
|
||||
encoder.start();
|
||||
|
||||
for (let i = 0; i < chain.length; i += 1) {
|
||||
const snake0 = chain[i];
|
||||
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
|
||||
step(grid, stack, snake0);
|
||||
|
||||
for (let k = 0; k < gifOptions.step; k++) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawLerpWorld(
|
||||
ctx,
|
||||
grid,
|
||||
snake0,
|
||||
snake1,
|
||||
stack,
|
||||
k / gifOptions.step,
|
||||
drawOptions
|
||||
);
|
||||
|
||||
encoder.addFrame(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const outFileName = path.join(dir, "out.gif");
|
||||
const optimizedFileName = path.join(dir, "out.optimized.gif");
|
||||
|
||||
await execa(
|
||||
"gm",
|
||||
[
|
||||
"convert",
|
||||
["-loop", "0"],
|
||||
["-delay", gifOptions.delay.toString()],
|
||||
["-dispose", "2"],
|
||||
// ["-layers", "OptimizeFrame"],
|
||||
["-compress", "LZW"],
|
||||
["-strip"],
|
||||
encoder.finish();
|
||||
fs.writeFileSync(outFileName, encoder.out.getData());
|
||||
|
||||
path.join(dir, "*.png"),
|
||||
outFileName,
|
||||
].flat()
|
||||
);
|
||||
|
||||
await execa(
|
||||
"gifsicle",
|
||||
execFileSync(
|
||||
gifsicle,
|
||||
[
|
||||
//
|
||||
"--optimize=3",
|
||||
"--color-method=diversity",
|
||||
"--colors=18",
|
||||
outFileName,
|
||||
["--output", optimizedFileName],
|
||||
].flat()
|
||||
);
|
||||
|
||||
return fs.readFileSync(optimizedFileName);
|
||||
} finally {
|
||||
cleanUp();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
"name": "@snk/gif-creator",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0",
|
||||
"@snk/draw": "1.0.0",
|
||||
"canvas": "2.6.1",
|
||||
"execa": "4.0.3",
|
||||
"@snk/solver": "1.0.0",
|
||||
"canvas": "2.9.1",
|
||||
"gif-encoder-2": "1.0.5",
|
||||
"gifsicle": "5.3.0",
|
||||
"tmp": "0.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/execa": "2.0.0",
|
||||
"@types/tmp": "0.2.0",
|
||||
"@types/gifsicle": "5.2.0",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@zeit/ncc": "0.22.3"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
|
||||
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/github-user-contribution-service/README.md
Normal file
3
packages/github-user-contribution-service/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @snk/github-user-contribution-service
|
||||
|
||||
Expose github-user-contribution as an endpoint, using vercel.sh
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import { NowRequest, NowResponse } from "@vercel/node";
|
||||
|
||||
export default async (req: NowRequest, res: NowResponse) => {
|
||||
const { userName } = req.query;
|
||||
|
||||
try {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://platane.github.io");
|
||||
res.statusCode = 200;
|
||||
res.json(await getGithubUserContribution(userName as string));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
8
packages/github-user-contribution-service/package.json
Normal file
8
packages/github-user-contribution-service/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@snk/github-user-contribution-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/github-user-contribution": "1.0.0",
|
||||
"@vercel/node": "1.14.0"
|
||||
}
|
||||
}
|
||||
5
packages/github-user-contribution-service/vercel.json
Normal file
5
packages/github-user-contribution-service/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"github": {
|
||||
"silent": 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import { formatParams } from "../formatParams";
|
||||
|
||||
const params = [
|
||||
//
|
||||
[{}, ""],
|
||||
[{ year: 2017 }, "from=2017-01-01&to=2017-12-31"],
|
||||
[{ from: "2017-12-03" }, "from=2017-12-03"],
|
||||
[{ to: "2017-12-03" }, "to=2017-12-03"],
|
||||
] as const;
|
||||
|
||||
params.forEach(([params, res]) =>
|
||||
it(`should format ${JSON.stringify(params)}`, () => {
|
||||
expect(formatParams(params)).toBe(res);
|
||||
})
|
||||
);
|
||||
|
||||
it("should fail if the date is in the future", () => {
|
||||
expect(() => formatParams({ to: "9999-01-01" })).toThrow(Error);
|
||||
});
|
||||
@@ -1,14 +1,54 @@
|
||||
import { getGithubUserContribution } from "..";
|
||||
|
||||
it("should get user contribution", async () => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
describe("getGithubUserContribution", () => {
|
||||
const promise = getGithubUserContribution("platane");
|
||||
|
||||
expect(cells).toBeDefined();
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
it("should resolve", async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("should get colorScheme", async () => {
|
||||
const { colorScheme } = await promise;
|
||||
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should get around 365 cells", async () => {
|
||||
const { cells } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThanOrEqual(365);
|
||||
expect(cells.length).toBeLessThanOrEqual(365 + 7);
|
||||
});
|
||||
|
||||
it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => {
|
||||
const { cells, colorScheme } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
|
||||
const undefinedDays = Array.from({ length: Math.floor(365 / 7) })
|
||||
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
|
||||
.flat()
|
||||
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y));
|
||||
|
||||
expect(undefinedDays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match snapshot for year=2019", async () => {
|
||||
expect(
|
||||
await getGithubUserContribution("platane", { year: 2019 })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
38
packages/github-user-contribution/formatParams.ts
Normal file
38
packages/github-user-contribution/formatParams.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type Options = { from?: string; to?: string } | { year: number };
|
||||
|
||||
export const formatParams = (options: Options = {}) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
const o: any = { ...options };
|
||||
|
||||
if ("year" in options) {
|
||||
o.from = `${options.year}-01-01`;
|
||||
o.to = `${options.year}-12-31`;
|
||||
}
|
||||
|
||||
for (const s of ["from", "to"])
|
||||
if (o[s]) {
|
||||
const value = o[s];
|
||||
|
||||
if (value >= formatDate(new Date()))
|
||||
throw new Error(
|
||||
"Cannot get contribution for a date in the future.\nPlease limit your range to the current UTC day."
|
||||
);
|
||||
|
||||
sp.set(s, value);
|
||||
}
|
||||
|
||||
return sp.toString();
|
||||
};
|
||||
|
||||
const formatDate = (d: Date) => {
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
const date = d.getUTCDate();
|
||||
|
||||
return [
|
||||
year,
|
||||
month.toString().padStart(2, "0"),
|
||||
date.toString().padStart(2, "0"),
|
||||
].join("-");
|
||||
};
|
||||
@@ -1,61 +1,131 @@
|
||||
// import * as https from "https";
|
||||
import fetch from "node-fetch";
|
||||
import * as cheerio from "cheerio";
|
||||
import { formatParams, Options } from "./formatParams";
|
||||
|
||||
// @ts-ignore
|
||||
// import * as cheerio from "cheerio";
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* use options.from=YYYY-MM-DD options.to=YYYY-MM-DD to get the contribution grid for a specific time range
|
||||
* or year=2019 as an alias for from=2019-01-01 to=2019-12-31
|
||||
*
|
||||
* otherwise return use the time range from today minus one year to today ( as seen in github profile page )
|
||||
*
|
||||
* @param userName github user name
|
||||
* @param options
|
||||
*
|
||||
* @example
|
||||
* getGithubUserContribution("platane", { from: "2019-01-01", to: "2019-12-31" })
|
||||
* getGithubUserContribution("platane", { year: 2019 })
|
||||
*
|
||||
*/
|
||||
export const getGithubUserContribution = async (
|
||||
userName: string,
|
||||
options: Options = {}
|
||||
) => {
|
||||
// either use github.com/users/xxxx/contributions for previous years
|
||||
// or github.com/xxxx ( which gives the latest update to today result )
|
||||
const url =
|
||||
"year" in options || "from" in options || "to" in options
|
||||
? `https://github.com/users/${userName}/contributions?` +
|
||||
formatParams(options)
|
||||
: `https://github.com/${userName}`;
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
const res = await fetch(url);
|
||||
|
||||
export const getGithubUserContribution = async (userName: string) => {
|
||||
// const content: string = await new Promise((resolve, reject) => {
|
||||
// const req = https.request(`https://github.com/${userName}`, (res) => {
|
||||
// let data = "";
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
|
||||
// res.on("error", reject);
|
||||
// res.on("data", (chunk) => (data += chunk));
|
||||
// res.on("end", () => resolve(data));
|
||||
// });
|
||||
const resText = await res.text();
|
||||
|
||||
// req.on("error", reject);
|
||||
// req.end();
|
||||
// });
|
||||
return parseUserPage(resText);
|
||||
};
|
||||
|
||||
// const dom = new JSDOM(content);
|
||||
const defaultColorScheme = [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
|
||||
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
|
||||
const parseUserPage = (content: string) => {
|
||||
const $ = cheerio.load(content);
|
||||
|
||||
const colorScheme = Array.from(
|
||||
dom.window.document.querySelectorAll(".legend > li")
|
||||
).map(
|
||||
(element) =>
|
||||
element.getAttribute("style")?.match(/background\-color: +(#\w+)/)?.[1]!
|
||||
);
|
||||
//
|
||||
// "parse" colorScheme
|
||||
const colorScheme = [...defaultColorScheme];
|
||||
|
||||
const cells = Array.from(
|
||||
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
|
||||
)
|
||||
.map((column, x) =>
|
||||
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
|
||||
x,
|
||||
y,
|
||||
count: element.getAttribute("data-count"),
|
||||
date: element.getAttribute("data-date"),
|
||||
color: element.getAttribute("fill"),
|
||||
k: colorScheme.indexOf(element.getAttribute("fill")!),
|
||||
}))
|
||||
)
|
||||
.flat();
|
||||
//
|
||||
// parse cells
|
||||
const rawCells = $(".js-calendar-graph rect[data-count]")
|
||||
.toArray()
|
||||
.map((x) => {
|
||||
const level = +x.attribs["data-level"];
|
||||
const count = +x.attribs["data-count"];
|
||||
const date = x.attribs["data-date"];
|
||||
|
||||
return { colorScheme, cells };
|
||||
const color = colorScheme[level];
|
||||
|
||||
if (!color) throw new Error("could not determine the color of the cell");
|
||||
|
||||
return {
|
||||
svgPosition: getSvgPosition(x),
|
||||
color,
|
||||
count,
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
const xMap: Record<number, true> = {};
|
||||
const yMap: Record<number, true> = {};
|
||||
rawCells.forEach(({ svgPosition: { x, y } }) => {
|
||||
xMap[x] = true;
|
||||
yMap[y] = true;
|
||||
});
|
||||
|
||||
const xRange = Object.keys(xMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
const yRange = Object.keys(yMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
|
||||
const cells = rawCells.map(({ svgPosition, ...c }) => ({
|
||||
...c,
|
||||
x: xRange.indexOf(svgPosition.x),
|
||||
y: yRange.indexOf(svgPosition.y),
|
||||
}));
|
||||
|
||||
return { cells, colorScheme };
|
||||
};
|
||||
|
||||
// returns the position of the svg elements, accounting for it's transform and it's parent transform
|
||||
// ( only accounts for translate transform )
|
||||
const getSvgPosition = (
|
||||
e: cheerio.Element | null
|
||||
): { x: number; y: number } => {
|
||||
if (!e || e.tagName === "svg") return { x: 0, y: 0 };
|
||||
|
||||
const p = getSvgPosition(e.parent as cheerio.Element);
|
||||
|
||||
if (e.attribs.x) p.x += +e.attribs.x;
|
||||
if (e.attribs.y) p.y += +e.attribs.y;
|
||||
|
||||
if (e.attribs.transform) {
|
||||
const m = e.attribs.transform.match(
|
||||
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/
|
||||
);
|
||||
|
||||
if (m) {
|
||||
p.x += +m[1];
|
||||
p.y += +m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
};
|
||||
|
||||
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
|
||||
|
||||
export type Cell = ThenArg<
|
||||
ReturnType<typeof getGithubUserContribution>
|
||||
>["cells"][number];
|
||||
export type Res = ThenArg<ReturnType<typeof getGithubUserContribution>>;
|
||||
|
||||
// "#ebedf0";
|
||||
// "#9be9a8";
|
||||
// "#40c463";
|
||||
// "#30a14e";
|
||||
// "#216e39";
|
||||
export type Cell = Res["cells"][number];
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
"name": "@snk/github-user-contribution",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"jsdom": "16.3.0"
|
||||
"cheerio": "1.0.0-rc.10",
|
||||
"node-fetch": "2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "16.2.3"
|
||||
"@types/node-fetch": "2.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
48
packages/solver/__tests__/getBestRoute-fuzz.spec.ts
Normal file
48
packages/solver/__tests__/getBestRoute-fuzz.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getBestRoute } from "../getBestRoute";
|
||||
import { snake3, snake4 } from "@snk/types/__fixtures__/snake";
|
||||
import {
|
||||
getHeadX,
|
||||
getHeadY,
|
||||
getSnakeLength,
|
||||
Snake,
|
||||
snakeWillSelfCollide,
|
||||
} from "@snk/types/snake";
|
||||
import { createFromSeed } from "@snk/types/__fixtures__/createFromSeed";
|
||||
|
||||
const n = 1000;
|
||||
|
||||
for (const { width, height, snake } of [
|
||||
{ width: 5, height: 5, snake: snake3 },
|
||||
{ width: 5, height: 5, snake: snake4 },
|
||||
])
|
||||
it(`should find solution for ${n} ${width}x${height} generated grids for ${getSnakeLength(
|
||||
snake
|
||||
)} length snake`, () => {
|
||||
const results = Array.from({ length: n }, (_, seed) => {
|
||||
const grid = createFromSeed(seed, width, height);
|
||||
|
||||
try {
|
||||
const chain = getBestRoute(grid, snake);
|
||||
|
||||
assertValidPath(chain);
|
||||
|
||||
return { seed };
|
||||
} catch (error) {
|
||||
return { seed, error };
|
||||
}
|
||||
});
|
||||
|
||||
expect(results.filter((x) => x.error)).toEqual([]);
|
||||
});
|
||||
|
||||
const assertValidPath = (chain: Snake[]) => {
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const dx = getHeadX(chain[i + 1]) - getHeadX(chain[i]);
|
||||
const dy = getHeadY(chain[i + 1]) - getHeadY(chain[i]);
|
||||
|
||||
if (!((Math.abs(dx) === 1 && dy == 0) || (Math.abs(dy) === 1 && dx == 0)))
|
||||
throw new Error(`unexpected direction ${dx},${dy}`);
|
||||
|
||||
if (snakeWillSelfCollide(chain[i], dx, dy)) throw new Error(`self collide`);
|
||||
}
|
||||
};
|
||||
26
packages/solver/__tests__/getBestRoute.spec.ts
Normal file
26
packages/solver/__tests__/getBestRoute.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getBestRoute } from "../getBestRoute";
|
||||
import { Color, createEmptyGrid, setColor } from "@snk/types/grid";
|
||||
import { createSnakeFromCells, snakeToCells } from "@snk/types/snake";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 } from "@snk/types/__fixtures__/snake";
|
||||
|
||||
it("should find best route", () => {
|
||||
const snk0 = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
];
|
||||
|
||||
const grid = createEmptyGrid(5, 5);
|
||||
setColor(grid, 3, 3, 1 as Color);
|
||||
|
||||
const chain = getBestRoute(grid, createSnakeFromCells(snk0))!;
|
||||
|
||||
expect(snakeToCells(chain[1])[1]).toEqual({ x: 0, y: 0 });
|
||||
|
||||
expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 });
|
||||
});
|
||||
|
||||
for (const [gridName, grid] of Object.entries(grids))
|
||||
it(`should find a solution for ${gridName}`, () => {
|
||||
getBestRoute(grid, snake3);
|
||||
});
|
||||
12
packages/solver/__tests__/getPathTo.spec.ts
Normal file
12
packages/solver/__tests__/getPathTo.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createEmptyGrid } from "@snk/types/grid";
|
||||
import { getHeadX, getHeadY } from "@snk/types/snake";
|
||||
import { snake3 } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathTo } from "../getPathTo";
|
||||
|
||||
it("should find it's way in vaccum", () => {
|
||||
const grid = createEmptyGrid(5, 0);
|
||||
|
||||
const path = getPathTo(grid, snake3, 5, -1)!;
|
||||
|
||||
expect([getHeadX(path[0]), getHeadY(path[0])]).toEqual([5, -1]);
|
||||
});
|
||||
19
packages/solver/__tests__/getPathToPose.spec.ts
Normal file
19
packages/solver/__tests__/getPathToPose.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createSnakeFromCells } from "@snk/types/snake";
|
||||
import { getPathToPose } from "../getPathToPose";
|
||||
|
||||
it("should fing path to pose", () => {
|
||||
const snake0 = createSnakeFromCells([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
]);
|
||||
const target = createSnakeFromCells([
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
]);
|
||||
|
||||
const path = getPathToPose(snake0, target);
|
||||
|
||||
expect(path).toBeDefined();
|
||||
});
|
||||
86
packages/solver/__tests__/sortPush.spec.ts
Normal file
86
packages/solver/__tests__/sortPush.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { sortPush } from "../utils/sortPush";
|
||||
|
||||
const sortFn = (a: number, b: number) => a - b;
|
||||
|
||||
it("should sort push length=0", () => {
|
||||
const a: any[] = [];
|
||||
const x = -1;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
it("should sort push under", () => {
|
||||
const a = [1, 2, 3, 4, 5];
|
||||
const x = -1;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
it("should sort push 0", () => {
|
||||
const a = [1, 2, 3, 4, 5];
|
||||
const x = 1;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
it("should sort push end", () => {
|
||||
const a = [1, 2, 3, 4, 5];
|
||||
const x = 5;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
it("should sort push over", () => {
|
||||
const a = [1, 2, 3, 4, 5];
|
||||
const x = 10;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
it("should sort push inside", () => {
|
||||
const a = [1, 2, 3, 4, 5];
|
||||
const x = 1.5;
|
||||
const res = [...a, x].sort(sortFn);
|
||||
|
||||
sortPush(a, x, sortFn);
|
||||
|
||||
expect(a).toEqual(res);
|
||||
});
|
||||
|
||||
describe("benchmark", () => {
|
||||
const n = 200;
|
||||
|
||||
const samples = Array.from({ length: 5000 }, () => [
|
||||
Math.random(),
|
||||
Array.from({ length: n }, () => Math.random()),
|
||||
]);
|
||||
const s0 = samples.map(([x, arr]: any) => [x, arr.slice()]);
|
||||
const s1 = samples.map(([x, arr]: any) => [x, arr.slice()]);
|
||||
|
||||
it("push + sort", () => {
|
||||
for (const [x, arr] of s0) {
|
||||
arr.push(x);
|
||||
arr.sort(sortFn);
|
||||
}
|
||||
});
|
||||
it("sortPush", () => {
|
||||
for (const [x, arr] of s1) {
|
||||
sortPush(arr, x, sortFn);
|
||||
}
|
||||
});
|
||||
});
|
||||
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"
|
||||
}
|
||||
}
|
||||
20
packages/solver/step.ts
Normal file
20
packages/solver/step.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
Color,
|
||||
getColor,
|
||||
Grid,
|
||||
isEmpty,
|
||||
isInside,
|
||||
setColorEmpty,
|
||||
} from "@snk/types/grid";
|
||||
import { getHeadX, getHeadY, Snake } from "@snk/types/snake";
|
||||
|
||||
export const step = (grid: Grid, stack: Color[], snake: Snake) => {
|
||||
const x = getHeadX(snake);
|
||||
const y = getHeadY(snake);
|
||||
const color = getColor(grid, x, y);
|
||||
|
||||
if (isInside(grid, x, y) && !isEmpty(color)) {
|
||||
stack.push(color);
|
||||
setColorEmpty(grid, x, y);
|
||||
}
|
||||
};
|
||||
81
packages/solver/tunnel.ts
Normal file
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;
|
||||
}
|
||||
};
|
||||
2
packages/solver/utils/array.ts
Normal file
2
packages/solver/utils/array.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const arrayEquals = <T>(a: T[], b: T[]) =>
|
||||
a.length === b.length && a.every((_, i) => a[i] === b[i]);
|
||||
22
packages/solver/utils/sortPush.ts
Normal file
22
packages/solver/utils/sortPush.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const sortPush = <T>(arr: T[], x: T, sortFn: (a: T, b: T) => number) => {
|
||||
let a = 0;
|
||||
let b = arr.length;
|
||||
|
||||
if (arr.length === 0 || sortFn(x, arr[a]) <= 0) {
|
||||
arr.unshift(x);
|
||||
return;
|
||||
}
|
||||
|
||||
while (b - a > 1) {
|
||||
const e = Math.ceil((a + b) / 2);
|
||||
|
||||
const s = sortFn(x, arr[e]);
|
||||
|
||||
if (s === 0) a = b = e;
|
||||
else if (s > 0) a = e;
|
||||
else b = e;
|
||||
}
|
||||
|
||||
const e = Math.ceil((a + b) / 2);
|
||||
arr.splice(e, 0, x);
|
||||
};
|
||||
5
packages/svg-creator/README.md
Normal file
5
packages/svg-creator/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# @snk/svg-creator
|
||||
|
||||
Generate a svg file from the grid and snake path.
|
||||
|
||||
Use css style tag to animate the snake and the grid cells. For that reason it only work in browser. Animations are likely to be ignored be native image reader.
|
||||
2
packages/svg-creator/__tests__/__snapshots__/.gitignore
vendored
Normal file
2
packages/svg-creator/__tests__/__snapshots__/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
39
packages/svg-creator/__tests__/createSvg.spec.ts
Normal file
39
packages/svg-creator/__tests__/createSvg.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createSvg } from "..";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
dark: {
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
|
||||
},
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
} catch (err) {}
|
||||
|
||||
for (const [key, grid] of Object.entries(grids))
|
||||
it(`should generate ${key} svg`, async () => {
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
|
||||
const svg = await createSvg(grid, chain, drawOptions, gifOptions);
|
||||
|
||||
expect(svg).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, key + ".svg"), svg);
|
||||
});
|
||||
66
packages/svg-creator/grid.ts
Normal file
66
packages/svg-creator/grid.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Color, Empty } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import { h } from "./utils";
|
||||
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
};
|
||||
|
||||
const percent = (x: number) => (x * 100).toFixed(2);
|
||||
|
||||
export const createGrid = (
|
||||
cells: (Point & { t: number | null; color: Color | Empty })[],
|
||||
{ sizeBorderRadius, sizeDot, sizeCell }: Options,
|
||||
duration: number
|
||||
) => {
|
||||
const svgElements: string[] = [];
|
||||
const styles = [
|
||||
`.c{
|
||||
shape-rendering: geometricPrecision;
|
||||
rx: ${sizeBorderRadius};
|
||||
ry: ${sizeBorderRadius};
|
||||
fill: var(--ce);
|
||||
stroke-width: 1px;
|
||||
stroke: var(--cb);
|
||||
animation: none ${duration}ms linear infinite;
|
||||
}`,
|
||||
];
|
||||
|
||||
let i = 0;
|
||||
for (const { x, y, color, t } of cells) {
|
||||
const id = t && "c" + (i++).toString(36);
|
||||
const s = sizeCell;
|
||||
const d = sizeDot;
|
||||
const m = (s - d) / 2;
|
||||
|
||||
if (t !== null) {
|
||||
const animationName = id;
|
||||
|
||||
styles.push(
|
||||
`@keyframes ${animationName} {` +
|
||||
`${percent(t - 0.0001)}%{fill:var(--c${color})}` +
|
||||
`${percent(t + 0.0001)}%,100%{fill:var(--ce)}` +
|
||||
"}",
|
||||
|
||||
`.c.${id}{fill: var(--c${color}); animation-name: ${animationName}}`
|
||||
);
|
||||
}
|
||||
|
||||
svgElements.push(
|
||||
h("rect", {
|
||||
class: ["c", id].filter(Boolean).join(" "),
|
||||
x: x * s + m,
|
||||
y: y * s + m,
|
||||
width: d,
|
||||
height: d,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { svgElements, styles };
|
||||
};
|
||||
157
packages/svg-creator/index.ts
Normal file
157
packages/svg-creator/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
copyGrid,
|
||||
getColor,
|
||||
isEmpty,
|
||||
isInside,
|
||||
setColorEmpty,
|
||||
} from "@snk/types/grid";
|
||||
import { getHeadX, getHeadY } from "@snk/types/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Grid, Color, Empty } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import { createSnake } from "./snake";
|
||||
import { createGrid } from "./grid";
|
||||
import { createStack } from "./stack";
|
||||
import { h } from "./utils";
|
||||
import * as csso from "csso";
|
||||
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
colorSnake: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
cells?: Point[];
|
||||
dark?: {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder?: string;
|
||||
colorSnake?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const getCellsFromGrid = ({ width, height }: Grid) =>
|
||||
Array.from({ length: width }, (_, x) =>
|
||||
Array.from({ length: height }, (_, y) => ({ x, y }))
|
||||
).flat();
|
||||
|
||||
const createLivingCells = (
|
||||
grid0: Grid,
|
||||
chain: Snake[],
|
||||
drawOptions: Options
|
||||
) => {
|
||||
const cells: (Point & {
|
||||
t: number | null;
|
||||
color: Color | Empty;
|
||||
})[] = (drawOptions.cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
|
||||
x,
|
||||
y,
|
||||
t: null,
|
||||
color: getColor(grid0, x, y),
|
||||
}));
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const snake = chain[i];
|
||||
const x = getHeadX(snake);
|
||||
const y = getHeadY(snake);
|
||||
|
||||
if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) {
|
||||
setColorEmpty(grid, x, y);
|
||||
const cell = cells.find((c) => c.x === x && c.y === y)!;
|
||||
cell.t = i / chain.length;
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
};
|
||||
|
||||
export const createSvg = (
|
||||
grid: Grid,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number }
|
||||
) => {
|
||||
const width = (grid.width + 2) * drawOptions.sizeCell;
|
||||
const height = (grid.height + 5) * drawOptions.sizeCell;
|
||||
|
||||
const duration = gifOptions.frameDuration * chain.length;
|
||||
|
||||
const cells = createLivingCells(grid, chain, drawOptions);
|
||||
|
||||
const elements = [
|
||||
createGrid(cells, drawOptions, duration),
|
||||
createStack(
|
||||
cells,
|
||||
drawOptions,
|
||||
grid.width * drawOptions.sizeCell,
|
||||
(grid.height + 2) * drawOptions.sizeCell,
|
||||
duration
|
||||
),
|
||||
createSnake(chain, drawOptions, duration),
|
||||
];
|
||||
|
||||
const viewBox = [
|
||||
-drawOptions.sizeCell,
|
||||
-drawOptions.sizeCell * 2,
|
||||
width,
|
||||
height,
|
||||
].join(" ");
|
||||
|
||||
const style =
|
||||
generateColorVar(drawOptions) +
|
||||
elements
|
||||
.map((e) => e.styles)
|
||||
.flat()
|
||||
.join("\n");
|
||||
|
||||
const svg = [
|
||||
h("svg", {
|
||||
viewBox,
|
||||
width,
|
||||
height,
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
}).replace("/>", ">"),
|
||||
|
||||
"<style>",
|
||||
optimizeCss(style),
|
||||
"</style>",
|
||||
|
||||
...elements.map((e) => e.svgElements).flat(),
|
||||
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
return optimizeSvg(svg);
|
||||
};
|
||||
|
||||
const optimizeCss = (css: string) => csso.minify(css).css;
|
||||
const optimizeSvg = (svg: string) => svg;
|
||||
|
||||
const generateColorVar = (drawOptions: Options) =>
|
||||
`
|
||||
:root {
|
||||
--cb: ${drawOptions.colorBorder};
|
||||
--cs: ${drawOptions.colorSnake};
|
||||
--ce: ${drawOptions.colorEmpty};
|
||||
${Object.entries(drawOptions.colorDots)
|
||||
.map(([i, color]) => `--c${i}:${color};`)
|
||||
.join("")}
|
||||
}
|
||||
` +
|
||||
(drawOptions.dark
|
||||
? `
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--cb: ${drawOptions.dark.colorBorder || drawOptions.colorBorder};
|
||||
--cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake};
|
||||
--ce: ${drawOptions.dark.colorEmpty};
|
||||
${Object.entries(drawOptions.dark.colorDots)
|
||||
.map(([i, color]) => `--c${i}:${color};`)
|
||||
.join("")}
|
||||
}
|
||||
}
|
||||
`
|
||||
: "");
|
||||
11
packages/svg-creator/package.json
Normal file
11
packages/svg-creator/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@snk/svg-creator",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/solver": "1.0.0",
|
||||
"csso": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/csso": "5.0.0"
|
||||
}
|
||||
}
|
||||
101
packages/svg-creator/snake.ts
Normal file
101
packages/svg-creator/snake.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getSnakeLength, snakeToCells } from "@snk/types/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import { h } from "./utils";
|
||||
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
colorSnake: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
};
|
||||
|
||||
const percent = (x: number) => (x * 100).toFixed(2);
|
||||
|
||||
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
|
||||
|
||||
export const createSnake = (
|
||||
chain: Snake[],
|
||||
{ sizeCell, sizeDot }: Options,
|
||||
duration: number
|
||||
) => {
|
||||
const snakeN = chain[0] ? getSnakeLength(chain[0]) : 0;
|
||||
|
||||
const snakeParts: Point[][] = Array.from({ length: snakeN }, () => []);
|
||||
|
||||
for (const snake of chain) {
|
||||
const cells = snakeToCells(snake);
|
||||
for (let i = cells.length; i--; ) snakeParts[i].push(cells[i]);
|
||||
}
|
||||
|
||||
const svgElements = snakeParts.map((_, i, { length }) => {
|
||||
// compute snake part size
|
||||
const dMin = sizeDot * 0.8;
|
||||
const dMax = sizeCell * 0.9;
|
||||
const iMax = Math.min(4, length);
|
||||
const u = (1 - Math.min(i, iMax) / iMax) ** 2;
|
||||
const s = lerp(u, dMin, dMax);
|
||||
|
||||
const m = (sizeCell - s) / 2;
|
||||
|
||||
const r = Math.min(4.5, (4 * s) / sizeDot);
|
||||
|
||||
return h("rect", {
|
||||
class: `s s${i}`,
|
||||
x: m.toFixed(1),
|
||||
y: m.toFixed(1),
|
||||
width: s.toFixed(1),
|
||||
height: s.toFixed(1),
|
||||
rx: r.toFixed(1),
|
||||
ry: r.toFixed(1),
|
||||
});
|
||||
});
|
||||
|
||||
const transform = ({ x, y }: Point) =>
|
||||
`transform:translate(${x * sizeCell}px,${y * sizeCell}px)`;
|
||||
|
||||
const styles = [
|
||||
`.s{
|
||||
shape-rendering:geometricPrecision;
|
||||
fill:var(--cs);
|
||||
animation: none linear ${duration}ms infinite
|
||||
}`,
|
||||
|
||||
...snakeParts.map((positions, i) => {
|
||||
const id = `s${i}`;
|
||||
const animationName = id;
|
||||
|
||||
return [
|
||||
`@keyframes ${animationName} {` +
|
||||
removeInterpolatedPositions(
|
||||
positions.map((tr, i, { length }) => ({ ...tr, t: i / length }))
|
||||
)
|
||||
.map((p) => `${percent(p.t)}%{${transform(p)}}`)
|
||||
.join("") +
|
||||
"}",
|
||||
|
||||
`.s.${id}{${transform(positions[0])};animation-name: ${animationName}}`,
|
||||
];
|
||||
}),
|
||||
].flat();
|
||||
|
||||
return { svgElements, styles };
|
||||
};
|
||||
|
||||
const removeInterpolatedPositions = <T extends Point>(arr: T[]) =>
|
||||
arr.filter((u, i, arr) => {
|
||||
if (i - 1 < 0 || i + 1 >= arr.length) return true;
|
||||
|
||||
const a = arr[i - 1];
|
||||
const b = arr[i + 1];
|
||||
|
||||
const ex = (a.x + b.x) / 2;
|
||||
const ey = (a.y + b.y) / 2;
|
||||
|
||||
// return true;
|
||||
return !(Math.abs(ex - u.x) < 0.01 && Math.abs(ey - u.y) < 0.01);
|
||||
});
|
||||
80
packages/svg-creator/stack.ts
Normal file
80
packages/svg-creator/stack.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Color, Empty } from "@snk/types/grid";
|
||||
import { h } from "./utils";
|
||||
|
||||
export type Options = {
|
||||
sizeDot: number;
|
||||
};
|
||||
|
||||
const percent = (x: number) => (x * 100).toFixed(2);
|
||||
|
||||
export const createStack = (
|
||||
cells: { t: number | null; color: Color | Empty }[],
|
||||
{ sizeDot }: Options,
|
||||
width: number,
|
||||
y: number,
|
||||
duration: number
|
||||
) => {
|
||||
const svgElements: string[] = [];
|
||||
const styles = [
|
||||
`.u{
|
||||
transform-origin: 0 0;
|
||||
transform: scale(0,1);
|
||||
animation: none linear ${duration}ms infinite;
|
||||
}`,
|
||||
];
|
||||
|
||||
const stack = cells
|
||||
.slice()
|
||||
.filter((a) => a.t !== null)
|
||||
.sort((a, b) => a.t! - b.t!) as any[];
|
||||
|
||||
const blocks: { color: Color; ts: number[] }[] = [];
|
||||
stack.forEach(({ color, t }) => {
|
||||
const latest = blocks[blocks.length - 1];
|
||||
if (latest?.color === color) latest.ts.push(t);
|
||||
else blocks.push({ color, ts: [t] });
|
||||
});
|
||||
|
||||
const m = width / stack.length;
|
||||
let i = 0;
|
||||
let nx = 0;
|
||||
for (const { color, ts } of blocks) {
|
||||
const id = "u" + (i++).toString(36);
|
||||
const animationName = id;
|
||||
const x = (nx * m).toFixed(1);
|
||||
|
||||
nx += ts.length;
|
||||
|
||||
svgElements.push(
|
||||
h("rect", {
|
||||
class: `u ${id}`,
|
||||
height: sizeDot,
|
||||
width: (ts.length * m + 0.6).toFixed(1),
|
||||
x,
|
||||
y,
|
||||
})
|
||||
);
|
||||
|
||||
styles.push(
|
||||
`@keyframes ${animationName} {` +
|
||||
[
|
||||
...ts.map((t, i, { length }) => [
|
||||
{ scale: i / length, t: t - 0.0001 },
|
||||
{ scale: (i + 1) / length, t: t + 0.0001 },
|
||||
]),
|
||||
[{ scale: 1, t: 1 }],
|
||||
]
|
||||
.flat()
|
||||
.map(
|
||||
({ scale, t }) =>
|
||||
`${percent(t)}%{transform:scale(${scale.toFixed(2)},1)}`
|
||||
)
|
||||
.join("\n") +
|
||||
"}",
|
||||
|
||||
`.u.${id}{fill:var(--c${color});animation-name:${animationName};transform-origin:${x}px 0}`
|
||||
);
|
||||
}
|
||||
|
||||
return { svgElements, styles };
|
||||
};
|
||||
8
packages/svg-creator/utils.ts
Normal file
8
packages/svg-creator/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const h = (element: string, attributes: any) =>
|
||||
`<${element} ${toAttribute(attributes)}/>`;
|
||||
|
||||
export const toAttribute = (o: any) =>
|
||||
Object.entries(o)
|
||||
.filter(([, value]) => value !== null)
|
||||
.map(([name, value]) => `${name}="${value}"`)
|
||||
.join(" ");
|
||||
3
packages/types/README.md
Normal file
3
packages/types/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @snk/types
|
||||
|
||||
set of basic types and helpers
|
||||
19
packages/types/__fixtures__/createFromAscii.ts
Normal file
19
packages/types/__fixtures__/createFromAscii.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Color, createEmptyGrid, setColor } from "../grid";
|
||||
|
||||
export const createFromAscii = (ascii: string) => {
|
||||
const a = ascii.split("\n");
|
||||
if (a[0] === "") a.shift();
|
||||
const height = a.length;
|
||||
const width = Math.max(...a.map((r) => r.length));
|
||||
|
||||
const grid = createEmptyGrid(width, height);
|
||||
for (let x = width; x--; )
|
||||
for (let y = height; y--; ) {
|
||||
const c = a[y][x];
|
||||
const color =
|
||||
(c === "#" && 3) || (c === "@" && 2) || (c === "." && 1) || +c;
|
||||
if (c) setColor(grid, x, y, color as Color);
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
11
packages/types/__fixtures__/createFromSeed.ts
Normal file
11
packages/types/__fixtures__/createFromSeed.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ParkMiller from "park-miller";
|
||||
import { Color, createEmptyGrid } from "../grid";
|
||||
import { randomlyFillGrid } from "../randomlyFillGrid";
|
||||
|
||||
export const createFromSeed = (seed: number, width = 5, height = 5) => {
|
||||
const grid = createEmptyGrid(width, height);
|
||||
const pm = new ParkMiller(seed);
|
||||
const random = pm.integerInRange.bind(pm);
|
||||
randomlyFillGrid(grid, { colors: [1, 2] as Color[], emptyP: 2 }, random);
|
||||
return grid;
|
||||
};
|
||||
103
packages/types/__fixtures__/grid.ts
Normal file
103
packages/types/__fixtures__/grid.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import ParkMiller from "park-miller";
|
||||
import { Color, createEmptyGrid, setColor } from "../grid";
|
||||
import { randomlyFillGrid } from "../randomlyFillGrid";
|
||||
import { createFromAscii } from "./createFromAscii";
|
||||
|
||||
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);
|
||||
|
||||
export const enclaveN = createFromAscii(`
|
||||
|
||||
#.#
|
||||
#
|
||||
|
||||
`);
|
||||
export const enclaveBorder = createFromAscii(`
|
||||
#.#
|
||||
#
|
||||
|
||||
`);
|
||||
export const enclaveM = createFromAscii(`
|
||||
|
||||
###
|
||||
# #
|
||||
# . #
|
||||
# #
|
||||
# #
|
||||
`);
|
||||
|
||||
export const enclaveK = createFromAscii(`
|
||||
|
||||
####
|
||||
# .#
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
`);
|
||||
export const enclaveU = createFromAscii(`
|
||||
|
||||
####
|
||||
#..#
|
||||
#..#
|
||||
#.#
|
||||
# # .
|
||||
`);
|
||||
export const closedP = createFromAscii(`
|
||||
|
||||
###
|
||||
##.#
|
||||
## #
|
||||
##
|
||||
`);
|
||||
export const closedU = createFromAscii(`
|
||||
|
||||
####
|
||||
#..#
|
||||
#..#
|
||||
#.#
|
||||
###
|
||||
`);
|
||||
export const closedO = createFromAscii(`
|
||||
|
||||
#######
|
||||
# #
|
||||
# . #
|
||||
# #
|
||||
#######
|
||||
`);
|
||||
export const tunnels = createFromAscii(`
|
||||
|
||||
### ### ###
|
||||
#.# #.# #.#
|
||||
#.# ### # #
|
||||
`);
|
||||
|
||||
const createRandom = (width: number, height: number, emptyP: number) => {
|
||||
const grid = createEmptyGrid(width, height);
|
||||
const pm = new ParkMiller(10);
|
||||
const random = pm.integerInRange.bind(pm);
|
||||
randomlyFillGrid(grid, { colors, emptyP }, random);
|
||||
return grid;
|
||||
};
|
||||
|
||||
// small realistic
|
||||
export const small = createRandom(10, 7, 3);
|
||||
export const smallPacked = createRandom(10, 7, 1);
|
||||
export const smallFull = createRandom(10, 7, 0);
|
||||
|
||||
// small realistic
|
||||
export const realistic = createRandom(52, 7, 3);
|
||||
export const realisticFull = createRandom(52, 7, 0);
|
||||
10
packages/types/__fixtures__/snake.ts
Normal file
10
packages/types/__fixtures__/snake.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSnakeFromCells } from "../snake";
|
||||
|
||||
const create = (length: number) =>
|
||||
createSnakeFromCells(Array.from({ length }, (_, i) => ({ x: i, y: -1 })));
|
||||
|
||||
export const snake1 = create(1);
|
||||
export const snake3 = create(3);
|
||||
export const snake4 = create(4);
|
||||
export const snake5 = create(5);
|
||||
export const snake9 = create(9);
|
||||
@@ -1,12 +1,11 @@
|
||||
import { generateEmptyGrid } from "../generateGrid";
|
||||
import { setColor, getColor, isInside } from "../grid";
|
||||
import { createEmptyGrid, setColor, getColor, isInside, Color } from "../grid";
|
||||
|
||||
it("should set / get cell", () => {
|
||||
const grid = generateEmptyGrid(2, 3);
|
||||
const grid = createEmptyGrid(2, 3);
|
||||
|
||||
expect(getColor(grid, 0, 1)).toBe(null);
|
||||
expect(getColor(grid, 0, 1)).toBe(0);
|
||||
|
||||
setColor(grid, 0, 1, 1);
|
||||
setColor(grid, 0, 1, 1 as Color);
|
||||
|
||||
expect(getColor(grid, 0, 1)).toBe(1);
|
||||
});
|
||||
@@ -20,7 +19,7 @@ test.each([
|
||||
[2, 1, false],
|
||||
[0, 3, false],
|
||||
])("isInside", (x, y, output) => {
|
||||
const grid = generateEmptyGrid(2, 3);
|
||||
const grid = createEmptyGrid(2, 3);
|
||||
|
||||
expect(isInside(grid, x, y)).toBe(output);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user