Compare commits

..

15 Commits
main ... v0.0.8

Author SHA1 Message Date
platane
efae6f21b5 aaaaaa 2020-07-19 21:22:24 +02:00
platane
ae99398406 aaaaaa 2020-07-19 21:16:34 +02:00
platane
505d6ab7f0 aaaaaa 2020-07-19 21:15:44 +02:00
platane
93fa60a844 aaaaaa 2020-07-19 21:10:21 +02:00
platane
e5fbdf8cfe aaaaaa 2020-07-19 21:09:33 +02:00
platane
f2452ed852 aaaaaa 2020-07-19 20:37:57 +02:00
platane
c4d7bdf2bd aaaaaa 2020-07-19 20:35:32 +02:00
platane
9b05b7fdaa aaaaaa 2020-07-19 20:20:48 +02:00
platane
ccbe4e530c aaaa 2020-07-19 20:04:40 +02:00
platane
bc6405aca3 aaaa 2020-07-19 20:03:12 +02:00
platane
fc4e8d4436 aaaa 2020-07-19 19:42:37 +02:00
platane
978ec843d3 aaaa 2020-07-19 19:37:54 +02:00
platane
c713fd8dd3 aaaabbb 2020-07-19 19:26:56 +02:00
platane
c14ae566e0 build 2020-07-19 19:26:56 +02:00
platane
724bc749a2 aaaaa 2020-07-19 19:26:56 +02:00
125 changed files with 7811 additions and 37243 deletions

29
.github/workflows/deploy.yml vendored Normal file
View File

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

View File

@@ -1,118 +1,29 @@
name: main
on: [push]
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
# - run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v1
# - uses: actions/setup-node@v1.4.2
# with:
# node-version: 14
- run: bun install --frozen-lockfile
# - uses: bahmutov/npm-install@v1.4.1
- run: npm run type
- run: npm run lint
- run: bun test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test-action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: update action.yml to use image from local Dockerfile
run: |
sed -i "s/image: .*/image: Dockerfile/" action.yml
# - run: yarn type
# - run: yarn lint
# - run: yarn test --ci
# - run: yarn build:lib
- name: generate-snake-game-from-github-contribution-grid
id: generate-snake
uses: ./
uses: Platane/snk@master
with:
github_user_name: platane
outputs: |
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists
run: |
ls dist
test -f dist/github-contribution-grid-snake.svg
test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake.gif
- uses: crazy-max/ghaction-github-pages@v4.1.0
with:
target_branch: output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test-action-svg-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install --frozen-lockfile
- name: build svg-only action
run: |
npm run build:action
rm -r svg-only/dist
mv packages/action/dist svg-only/dist
- name: generate-snake-game-from-github-contribution-grid
id: generate-snake
uses: ./svg-only
with:
github_user_name: platane
outputs: |
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake-blue.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists
run: |
ls dist
test -f dist/github-contribution-grid-snake.svg
test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake-blue.svg
- uses: crazy-max/ghaction-github-pages@v4.1.0
with:
target_branch: output-svg-only
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy-ghpages:
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install --frozen-lockfile
- run: npm run build:demo
env:
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://github-user-contribution.platane.workers.dev/github-user-contribution/
- uses: actions/upload-pages-artifact@v3
with:
path: packages/demo/dist
- uses: actions/deploy-pages@v4
if: success() && github.ref == 'refs/heads/main'
- run: bunx wrangler deploy
if: success() && github.ref == 'refs/heads/main'
working-directory: packages/github-user-contribution-service
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- run: ls
- run: ls

View File

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

View File

@@ -1,86 +0,0 @@
name: release
on:
workflow_dispatch:
inputs:
version:
description: |
New version for the release
If the version is in format <major>.<minor>.<patch> a new release is emitted.
Otherwise for other format ( for example <major>.<minor>.<patch>-beta.1 ), a prerelease is emitted.
default: "0.0.1"
required: true
type: string
description:
description: "Version description"
type: string
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build and publish the docker image
uses: docker/build-push-action@v4
id: docker-build
with:
push: true
tags: |
platane/snk:${{ github.sha }}
platane/snk:${{ github.event.inputs.version }}
- name: update action.yml to point to the newly created docker image
run: |
sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml
- uses: oven-sh/setup-bun@v1
- run: bun install --frozen-lockfile
- name: build svg-only action
run: |
npm run build:action
rm -r svg-only/dist
mv packages/action/dist svg-only/dist
- name: bump package version
run: npm version --no-git-tag-version --new-version ${{ github.event.inputs.version }}
- name: push new build, tag version and push
id: push-tags
run: |
VERSION=${{ github.event.inputs.version }}
git config --global user.email "bot@platane.me"
git config --global user.name "release bot"
git add package.json svg-only/dist action.yml
git commit -m "📦 $VERSION"
git tag v$VERSION
git push origin main --tags
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
git tag v$( echo $VERSION | cut -d. -f 1-1 )
git tag v$( echo $VERSION | cut -d. -f 1-2 )
git push origin --tags --force
echo "prerelease=false" >> $GITHUB_OUTPUT
else
echo "prerelease=true" >> $GITHUB_OUTPUT
fi
- uses: ncipollo/release-action@v1.15.0
with:
tag: v${{ github.event.inputs.version }}
body: ${{ github.event.inputs.description }}
prerelease: ${{ steps.push-tags.outputs.prerelease }}

7
.gitignore vendored
View File

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

1
.nvmrc
View File

@@ -1 +0,0 @@
20

View File

@@ -1,27 +1,16 @@
FROM oven/bun:1.2.2-slim as builder
FROM node:14-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
COPY tsconfig.json package.json yarn.lock ./platane-aa/
COPY packages ./platane-aa/packages/
COPY tsconfig.json ./
RUN ( cd ./platane-aa ; yarn install --frozen-lockfile )
COPY packages packages
RUN ( cd ./platane-aa ; yarn build:action )
RUN bun install --no-cache
CMD ["find", "/github"]
# CMD ["node", "./generate-snake-game-from-github-contribution-grid/packages/action/dist/index.js"]
RUN bun run build:action
FROM oven/bun:1.2.2-slim
WORKDIR /action-release
RUN bun add canvas@3.1.0 gifsicle@5.3.0 --no-lockfile --no-cache
COPY --from=builder /app/packages/action/dist/ /action-release/
CMD ["bun", "/action-release/index.js"]

View File

@@ -1,93 +1,3 @@
# snk
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/platane/platane/main.yml?label=action&style=flat-square)](https://github.com/Platane/Platane/actions/workflows/main.yml)
[![GitHub release](https://img.shields.io/github/release/platane/snk.svg?style=flat-square)](https://github.com/platane/snk/releases/latest)
[![GitHub marketplace](https://img.shields.io/badge/marketplace-snake-blue?logo=github&style=flat-square)](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square)
![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)
Generates a snake game from a github user contributions graph
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake-dark.svg"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
/>
<img
alt="github contribution grid snake animation"
src="https://raw.githubusercontent.com/platane/snk/output/github-contribution-grid-snake.svg"
/>
</picture>
Pull a github user's contribution graph.
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. It can automatically generate a new image each day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
## Usage
**github action**
```yaml
- uses: Platane/snk@v3
with:
# github user name to read the contribution graph from (**required**)
# using action context var `github.repository_owner` or specified user
github_user_name: ${{ github.repository_owner }}
# list of files to generate.
# one file per line. Each output can be customized with options as query string.
#
# supported options:
# - palette: A preset of color, one of [github, github-dark, github-light]
# - color_snake: Color of the snake
# - color_dots: Coma separated list of dots color.
# The first one is 0 contribution, then it goes from the low contribution to the highest.
# Exactly 5 colors are expected.
outputs: |
dist/github-snake.svg
dist/github-snake-dark.svg?palette=github-dark
dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L26-L33)
If you are only interested in generating a svg, consider using this faster action: `uses: Platane/snk/svg-only@v3`
**dark mode**
For **dark mode** support on github, use this [special syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to) in your readme.
```html
<picture>
<source media="(prefers-color-scheme: dark)" srcset="github-snake-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="github-snake.svg" />
<img alt="github-snake" src="github-snake.svg" />
</picture>
```
**interactive demo**
<a href="https://platane.github.io/snk">
<img height="300px" src="https://user-images.githubusercontent.com/1659820/121798244-7c86d700-cc25-11eb-8c1c-b8e65556ac0d.gif" ></img>
</a>
[platane.github.io/snk](https://platane.github.io/snk)
**local**
```
npm install
npm run dev:demo
```
## Implementation
[solver algorithm](./packages/solver/README.md)
Generates a snake game from a github user contributions grid and output a screen capture as gif

View File

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

1489
bun.lock

File diff suppressed because it is too large Load Diff

5
jest.config.js Normal file
View File

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

View File

@@ -1,22 +1,24 @@
{
"name": "snk",
"description": "Generates a snake game from a github user contributions grid",
"version": "3.3.0",
"description": "Generates a snake game from a github user contributions grid and output a screen capture as gif",
"version": "1.0.0",
"private": true,
"repository": "github:platane/snk",
"devDependencies": {
"@types/bun": "1.2.2",
"prettier": "3.5.1",
"typescript": "5.7.3"
"@types/jest": "26.0.4",
"jest": "26.1.0",
"prettier": "2.0.5",
"ts-jest": "26.1.2",
"typescript": "3.9.6"
},
"workspaces": [
"packages/*"
"packages/**"
],
"scripts": {
"type": "tsc --noEmit",
"lint": "prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**' '!svg-only/dist/**'",
"dev:demo": "( cd packages/demo ; npm run dev )",
"build:demo": "( cd packages/demo ; npm run build )",
"build:action": "( cd packages/action ; npm run build )"
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/action/dist/**' '!packages/demo/dist/**' '!packages/demo/webpack.config.js'",
"test": "jest --verbose --passWithNoTests --no-cache",
"build:demo": "( cd packages/demo ; yarn build )",
"build:action": "( cd packages/action ; yarn build )"
}
}

View File

@@ -1,13 +0,0 @@
# @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 )

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { generateContributionSnake } from "../generateContributionSnake";
generateContributionSnake("platane").then((buffer) => {
process.stdout.write(buffer);
});

View File

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

View File

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

View File

@@ -1,52 +1,56 @@
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";
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
import type { AnimationOptions } from "@snk/gif-creator";
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";
export const generateContributionSnake = async (
userName: string,
outputs: ({
format: "svg" | "gif";
drawOptions: DrawOptions;
animationOptions: AnimationOptions;
} | null)[],
options: { githubToken: string },
) => {
console.log("🎣 fetching github user contribution");
const cells = await getGithubUserContribution(userName, options);
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 = userContributionToGrid(cells);
const snake = snake4;
const grid = generateEmptyGrid(width, height);
for (const c of cells) setColor(grid, c.x, c.y, c.k === 0 ? null : c.k);
console.log("📡 computing best route");
const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
return Promise.all(
outputs.map(async (out, i) => {
if (!out) return;
const { format, drawOptions, animationOptions } = out;
switch (format) {
case "svg": {
console.log(`🖌 creating svg (outputs[${i}])`);
const { createSvg } = await import("@snk/svg-creator");
return createSvg(grid, cells, chain, drawOptions, animationOptions);
}
case "gif": {
console.log(`📹 creating gif (outputs[${i}])`);
const { createGif } = await import("@snk/gif-creator");
return await createGif(
grid,
cells,
chain,
drawOptions,
animationOptions,
);
}
}
}),
);
return grid;
};
export const generateContributionSnake = async (userName: string) => {
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 drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: colorScheme,
colorEmpty: colorScheme[0],
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { delay: 10 };
const commands = computeBestRun(grid0, snake0, gameOptions);
const buffer = await createGif(
grid0,
snake0,
commands,
drawOptions,
gameOptions,
gifOptions
);
return buffer;
};

View File

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

View File

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

View File

@@ -2,17 +2,15 @@
"name": "@snk/action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "1.11.1",
"@actions/core": "1.2.4",
"@snk/gif-creator": "1.0.0",
"@snk/github-user-contribution": "1.0.0",
"@snk/solver": "1.0.0",
"@snk/svg-creator": "1.0.0",
"@snk/types": "1.0.0"
"@snk/github-user-contribution": "1.0.0"
},
"devDependencies": {
"@vercel/ncc": "0.38.3"
"@zeit/ncc": "0.22.3"
},
"scripts": {
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts"
"build": "ncc build ./index.ts --out dist",
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
}
}

View File

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

View File

@@ -1,16 +0,0 @@
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[]) => {
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
const grid = createEmptyGrid(width, height);
for (const c of cells) {
if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color);
else setColorEmpty(grid, c.x, c.y);
}
return grid;
};

View File

@@ -0,0 +1,26 @@
import { generateEmptyGrid } from "../generateGrid";
import { setColor, getColor, isInside } from "../grid";
it("should set / get cell", () => {
const grid = generateEmptyGrid(2, 3);
expect(getColor(grid, 0, 1)).toBe(null);
setColor(grid, 0, 1, 1);
expect(getColor(grid, 0, 1)).toBe(1);
});
test.each([
[0, 1, true],
[1, 2, true],
[-1, 1, false],
[0, -1, false],
[2, 1, false],
[0, 3, false],
])("isInside", (x, y, output) => {
const grid = generateEmptyGrid(2, 3);
expect(isInside(grid, x, y)).toBe(output);
});

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
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;
};

30
packages/compute/grid.ts Normal file
View File

@@ -0,0 +1,30 @@
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;
};

44
packages/compute/index.ts Normal file
View File

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

View File

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

View File

@@ -5,6 +5,4 @@ export const around4 = [
{ x: 0, y: -1 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
] as const;
export const pointEquals = (a: Point, b: Point) => a.x === b.x && a.y === b.y;
];

24
packages/compute/snake.ts Normal file
View File

@@ -0,0 +1,24 @@
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 }));

48
packages/compute/step.ts Normal file
View File

@@ -0,0 +1,48 @@
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 Normal file
View File

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

View File

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

View File

@@ -1,99 +0,0 @@
import { Color, Grid } from "@snk/types/grid";
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
import { Snake } from "@snk/types/snake";
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
export const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorDotBorder: "#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, null, snake, stack, drawOptions);
};
const drawLerp = (
grid: Grid,
snake0: Snake,
snake1: Snake,
stack: Color[],
k: number,
) => {
ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, null, snake0, snake1, stack, k, drawOptions);
};
const highlightCell = (x: number, y: number, color = "orange") => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
};
return {
draw,
drawLerp,
highlightCell,
canvas,
getPointedCell: getPointedCell(canvas),
ctx,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
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();
});

View File

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

81
packages/demo/index.ts Normal file
View File

@@ -0,0 +1,81 @@
// 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();

View File

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

View File

@@ -2,26 +2,18 @@
"name": "@snk/demo",
"version": "1.0.0",
"dependencies": {
"@snk/action": "1.0.0",
"@snk/draw": "1.0.0",
"@snk/github-user-contribution": "1.0.0",
"@snk/solver": "1.0.0",
"@snk/svg-creator": "1.0.0",
"@snk/types": "1.0.0"
"@snk/compute": "1.0.0"
},
"devDependencies": {
"@types/dat.gui": "0.7.13",
"dat.gui": "0.7.9",
"dotenv": "16.4.7",
"html-webpack-plugin": "5.6.3",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"webpack": "5.98.0",
"webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.0"
"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"
},
"scripts": {
"build": "webpack",
"dev": "webpack serve"
"prepare": "tsc webpack.config.ts",
"build": "yarn prepare ; webpack",
"dev": "yarn prepare ; webpack-dev-server --port ${PORT-3000}"
}
}

View File

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

View File

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

View File

@@ -1,88 +1,47 @@
import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin";
import webpack from "webpack";
import { getGithubUserContribution } from "@snk/github-user-contribution";
import type { Configuration as WebpackConfiguration } from "webpack";
import {
ExpressRequestHandler,
type Configuration as WebpackDevServerConfiguration,
} from "webpack-dev-server";
import { config } from "dotenv";
config({ path: __dirname + "/../../.env" });
import * as path from "path";
const demos: string[] = require("./demo.json");
// @ts-ignore
import * as HtmlWebpackPlugin from "html-webpack-plugin";
import type { Configuration } from "webpack";
const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
open: { target: demos[1] + ".html" },
setupMiddlewares: (ms) => [
...ms,
(async (req, res, next) => {
const userName = req.url.match(
/\/api\/github-user-contribution\/(\w+)/,
)?.[1];
if (userName)
res.send(
await getGithubUserContribution(userName, {
githubToken: process.env.GITHUB_TOKEN!,
}),
);
else next();
}) as ExpressRequestHandler,
],
};
const basePathname = (process.env.BASE_PATHNAME || "")
.split("/")
.filter(Boolean);
const webpackConfiguration: WebpackConfiguration = {
const config: Configuration = {
mode: "development",
entry: Object.fromEntries(
demos.map((demo: string) => [demo, `./demo.${demo}`]),
),
target: ["web", "es2019"],
entry: "./index",
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: /\.ts$/,
test: /\.(js|ts)$/,
loader: "ts-loader",
options: {
transpileOnly: true,
compilerOptions: {
lib: ["dom", "es2020"],
target: "es2019",
},
},
},
],
},
plugins: [
...demos.map(
(demo) =>
new HtmlWebpackPlugin({
title: "snk - " + demo,
filename: `${demo}.html`,
chunks: [demo],
}),
),
// game
new HtmlWebpackPlugin({
title: "snk - " + demos[0],
filename: `index.html`,
chunks: [demos[0]],
}),
new webpack.EnvironmentPlugin({
GITHUB_USER_CONTRIBUTION_API_ENDPOINT:
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT ??
"/api/github-user-contribution/",
title: "demo",
filename: "index.html",
meta: {
viewport: "width=device-width, initial-scale=1, shrink-to-fit=no",
},
}),
],
devtool: false,
stats: "errors-only",
// @ts-ignore
devServer: {},
};
export default {
...webpackConfiguration,
devServer: webpackDevServerConfiguration,
};
export default config;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
import { pathRoundedRect } from "./pathRoundedRect";
import { snakeToCells } from "@snk/types/snake";
import type { Snake } from "@snk/types/snake";
type Options = {
colorSnake: string;
sizeCell: number;
};
export const drawSnake = (
ctx: CanvasRenderingContext2D,
snake: Snake,
o: Options,
) => {
const cells = snakeToCells(snake);
for (let i = 0; i < cells.length; i++) {
const u = (i + 1) * 0.6;
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u);
ctx.beginPath();
pathRoundedRect(
ctx,
o.sizeCell - u * 2,
o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25,
);
ctx.fill();
ctx.restore();
}
};
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
export const drawSnakeLerp = (
ctx: CanvasRenderingContext2D,
snake0: Snake,
snake1: Snake,
k: number,
o: Options,
) => {
const m = 0.8;
const n = snake0.length / 2;
for (let i = 0; i < n; i++) {
const u = (i + 1) * 0.6 * (o.sizeCell / 16);
const a = (1 - m) * (i / Math.max(n - 1, 1));
const ki = clamp((k - a) / m, 0, 1);
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
ctx.beginPath();
pathRoundedRect(
ctx,
o.sizeCell - u * 2,
o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25,
);
ctx.fill();
ctx.restore();
}
};

View File

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

View File

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

View File

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

1
packages/gif-creator/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -1,84 +0,0 @@
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 { AnimationOptions, createGif } from "..";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { getPathToPose } from "@snk/solver/getPathToPose";
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
let snake = 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: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorDotBorder: "#1b1f230a",
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
const animationOptions: AnimationOptions = { 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: Uint8Array;
const start = Date.now();
const chainL = chain.slice(0, length);
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
const s = performance.now();
buffer = await createGif(
grid,
null,
chainL,
drawOptions,
animationOptions,
);
stats.push(performance.now() - s);
}
console.log(
[
"---",
`grid dimension: ${grid.width}x${grid.height}`,
`chain length: ${length}`,
`resulting size: ${(buffer!.length / 1024).toFixed(1)}ko`,
`generation duration (mean): ${(
stats.reduce((s, x) => x + s) / stats.length
).toLocaleString(undefined, {
maximumFractionDigits: 0,
})}ms`,
"",
].join("\n"),
stats,
);
fs.writeFileSync(
`__tests__/__snapshots__/benchmark-output-${length}.gif`,
buffer!,
);
}
})();

View File

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

View File

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

View File

@@ -1,99 +1,92 @@
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { createCanvas } from "canvas";
import { Grid, copyGrid, Color } from "@snk/types/grid";
import { Snake } from "@snk/types/snake";
import {
Options as DrawOptions,
drawLerpWorld,
getCanvasWorldSize,
} from "@snk/draw/drawWorld";
import type { Point } from "@snk/types/point";
import { step } from "@snk/solver/step";
import tmp from "tmp";
import gifsicle from "gifsicle";
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";
// @ts-ignore
import GIFEncoder from "gif-encoder-2";
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;
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 {
return await handler(dir);
} finally {
cleanUp();
}
};
writeImage(0);
export type AnimationOptions = { frameDuration: number; step: number };
export const createGif = async (
grid0: Grid,
cells: Point[] | null,
chain: Snake[],
drawOptions: DrawOptions,
animationOptions: AnimationOptions,
) =>
withTmpDir(async (dir) => {
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d") as any as CanvasRenderingContext2D;
const grid = copyGrid(grid0);
const stack: Color[] = [];
const encoder = new GIFEncoder(width, height, "neuquant", true);
encoder.setRepeat(0);
encoder.setDelay(animationOptions.frameDuration);
encoder.start();
for (let i = 0; i < chain.length; i += 1) {
const snake0 = chain[i];
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
step(grid, stack, snake0);
for (let k = 0; k < animationOptions.step; k++) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
drawLerpWorld(
ctx,
grid,
cells,
snake0,
snake1,
stack,
k / animationOptions.step,
drawOptions,
);
encoder.addFrame(ctx);
}
for (let i = 0; i < commands.length; i++) {
step(grid, snake, stack, commands[i], gameOptions);
writeImage(i + 1);
}
const outFileName = path.join(dir, "out.gif");
const optimizedFileName = path.join(dir, "out.optimized.gif");
encoder.finish();
fs.writeFileSync(outFileName, encoder.out.getData());
await execa(
"gm",
[
"convert",
["-loop", "0"],
["-delay", gifOptions.delay.toString()],
["-dispose", "2"],
// ["-layers", "OptimizeFrame"],
["-compress", "LZW"],
["-strip"],
execFileSync(
gifsicle,
path.join(dir, "*.png"),
outFileName,
].flat()
);
await execa(
"gifsicle",
[
//
"--optimize=3",
"--color-method=diversity",
"--colors=18",
outFileName,
["--output", optimizedFileName],
].flat(),
].flat()
);
return new Uint8Array(fs.readFileSync(optimizedFileName));
});
return fs.readFileSync(optimizedFileName);
} finally {
cleanUp();
}
};

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
{
"name": "@snk/github-user-contribution-service",
"version": "1.0.0",
"dependencies": {
"@snk/github-user-contribution": "1.0.0"
},
"devDependencies": {
"wrangler": "3.109.2",
"@cloudflare/workers-types": "4.20250214.0"
},
"scripts": {
"deploy": "wrangler deploy"
}
}

View File

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

View File

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

View File

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

View File

@@ -1,101 +1,61 @@
/**
* 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,
o: { githubToken: string },
) => {
const query = /* GraphQL */ `
query ($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar {
weeks {
contributionDays {
contributionCount
contributionLevel
weekday
date
}
}
}
}
}
}
`;
const variables = { login: userName };
// import * as https from "https";
const res = await fetch("https://api.github.com/graphql", {
headers: {
Authorization: `bearer ${o.githubToken}`,
"Content-Type": "application/json",
"User-Agent": "me@platane.me",
},
method: "POST",
body: JSON.stringify({ variables, query }),
});
// @ts-ignore
// import * as cheerio from "cheerio";
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText));
import { JSDOM } from "jsdom";
const { data, errors } = (await res.json()) as {
data: GraphQLRes;
errors?: { message: string }[];
};
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 (errors?.[0]) throw errors[0];
// res.on("error", reject);
// res.on("data", (chunk) => (data += chunk));
// res.on("end", () => resolve(data));
// });
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(
({ contributionDays }, x) =>
contributionDays.map((d) => ({
x,
y: d.weekday,
date: d.date,
count: d.contributionCount,
level:
(d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
0,
})),
// req.on("error", reject);
// req.end();
// });
// const dom = new JSDOM(content);
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
const colorScheme = Array.from(
dom.window.document.querySelectorAll(".legend > li")
).map(
(element) =>
element.getAttribute("style")?.match(/background\-color: +(#\w+)/)?.[1]!
);
const cells = Array.from(
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
)
.map((column, x) =>
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
x,
y,
count: element.getAttribute("data-count"),
date: element.getAttribute("data-date"),
color: element.getAttribute("fill"),
k: colorScheme.indexOf(element.getAttribute("fill")!),
}))
)
.flat();
return { colorScheme, cells };
};
type GraphQLRes = {
user: {
contributionsCollection: {
contributionCalendar: {
weeks: {
contributionDays: {
contributionCount: number;
contributionLevel:
| "FOURTH_QUARTILE"
| "THIRD_QUARTILE"
| "SECOND_QUARTILE"
| "FIRST_QUARTILE"
| "NONE";
date: string;
weekday: number;
}[];
}[];
};
};
};
};
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;
export type Cell = ThenArg<
ReturnType<typeof getGithubUserContribution>
>["cells"][number];
export type Cell = Res[number];
// "#ebedf0";
// "#9be9a8";
// "#40c463";
// "#30a14e";
// "#216e39";

View File

@@ -1,4 +1,10 @@
{
"name": "@snk/github-user-contribution",
"version": "1.0.0"
"version": "1.0.0",
"dependencies": {
"jsdom": "16.3.0"
},
"devDependencies": {
"@types/jsdom": "16.2.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,152 +0,0 @@
import {
Empty,
getColor,
isEmpty,
isInside,
setColorEmpty,
} from "@snk/types/grid";
import { getHeadX, getHeadY, getSnakeLength } from "@snk/types/snake";
import { getBestTunnel } from "./getBestTunnel";
import { fillOutside, Outside } from "./outside";
import { getTunnelPath } from "./tunnel";
import { getPathTo } from "./getPathTo";
import type { Snake } from "@snk/types/snake";
import type { Color, Grid } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
type T = Point & { tunnel: Point[]; priority: number };
export const clearResidualColoredLayer = (
grid: Grid,
outside: Outside,
snake0: Snake,
color: Color,
) => {
const snakeN = getSnakeLength(snake0);
const tunnels = getTunnellablePoints(grid, outside, snakeN, color);
// sort
tunnels.sort((a, b) => b.priority - a.priority);
const chain: Snake[] = [snake0];
while (tunnels.length) {
// get the best next tunnel
let t = getNextTunnel(tunnels, chain[0]);
// goes to the start of the tunnel
chain.unshift(...getPathTo(grid, chain[0], t[0].x, t[0].y)!);
// goes to the end of the tunnel
chain.unshift(...getTunnelPath(chain[0], t));
// update grid
for (const { x, y } of t) setEmptySafe(grid, x, y);
// update outside
fillOutside(outside, grid);
// update tunnels
for (let i = tunnels.length; i--; )
if (isEmpty(getColor(grid, tunnels[i].x, tunnels[i].y)))
tunnels.splice(i, 1);
else {
const t = tunnels[i];
const tunnel = getBestTunnel(grid, outside, t.x, t.y, color, snakeN);
if (!tunnel) tunnels.splice(i, 1);
else {
t.tunnel = tunnel;
t.priority = getPriority(grid, color, tunnel);
}
}
// re-sort
tunnels.sort((a, b) => b.priority - a.priority);
}
chain.pop();
return chain;
};
const getNextTunnel = (ts: T[], snake: Snake) => {
let minDistance = Infinity;
let closestTunnel: Point[] | null = null;
const x = getHeadX(snake);
const y = getHeadY(snake);
const priority = ts[0].priority;
for (let i = 0; ts[i] && ts[i].priority === priority; i++) {
const t = ts[i].tunnel;
const d = distanceSq(t[0].x, t[0].y, x, y);
if (d < minDistance) {
minDistance = d;
closestTunnel = t;
}
}
return closestTunnel!;
};
/**
* get all the tunnels for all the cells accessible
*/
export const getTunnellablePoints = (
grid: Grid,
outside: Outside,
snakeN: number,
color: Color,
) => {
const points: T[] = [];
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
if (!isEmpty(c) && c < color) {
const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN);
if (tunnel) {
const priority = getPriority(grid, color, tunnel);
points.push({ x, y, priority, tunnel });
}
}
}
return points;
};
/**
* get the score of the tunnel
* prioritize tunnel with maximum color smaller than <color> and with minimum <color>
* with some tweaks
*/
export const getPriority = (grid: Grid, color: Color, tunnel: Point[]) => {
let nColor = 0;
let nLess = 0;
for (let i = 0; i < tunnel.length; i++) {
const { x, y } = tunnel[i];
const c = getColorSafe(grid, x, y);
if (!isEmpty(c) && i === tunnel.findIndex((p) => p.x === x && p.y === y)) {
if (c === color) nColor += 1;
else nLess += color - c;
}
}
if (nColor === 0) return 99999;
return nLess / nColor;
};
const distanceSq = (ax: number, ay: number, bx: number, by: number) =>
(ax - bx) ** 2 + (ay - by) ** 2;
const getColorSafe = (grid: Grid, x: number, y: number) =>
isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty);
const setEmptySafe = (grid: Grid, x: number, y: number) => {
if (isInside(grid, x, y)) setColorEmpty(grid, x, y);
};

View File

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

View File

@@ -1,113 +0,0 @@
import { copyGrid, getColor, isInside, setColorEmpty } from "@snk/types/grid";
import { around4 } from "@snk/types/point";
import { sortPush } from "./utils/sortPush";
import {
createSnakeFromCells,
getHeadX,
getHeadY,
nextSnake,
snakeEquals,
snakeWillSelfCollide,
} from "@snk/types/snake";
import { isOutside } from "./outside";
import { trimTunnelEnd, trimTunnelStart } from "./tunnel";
import type { Outside } from "./outside";
import type { Snake } from "@snk/types/snake";
import type { Empty, Color, Grid } from "@snk/types/grid";
import type { Point } from "@snk/types/point";
const getColorSafe = (grid: Grid, x: number, y: number) =>
isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty);
const setEmptySafe = (grid: Grid, x: number, y: number) => {
if (isInside(grid, x, y)) setColorEmpty(grid, x, y);
};
type M = { snake: Snake; parent: M | null; w: number };
const unwrap = (m: M | null): Point[] =>
!m
? []
: [...unwrap(m.parent), { x: getHeadX(m.snake), y: getHeadY(m.snake) }];
/**
* returns the path to reach the outside which contains the least color cell
*/
const getSnakeEscapePath = (
grid: Grid,
outside: Outside,
snake0: Snake,
color: Color,
) => {
const openList: M[] = [{ snake: snake0, w: 0 } as any];
const closeList: Snake[] = [];
while (openList[0]) {
const o = openList.shift()!;
const x = getHeadX(o.snake);
const y = getHeadY(o.snake);
if (isOutside(outside, x, y)) return unwrap(o);
for (const a of around4) {
const c = getColorSafe(grid, x + a.x, y + a.y);
if (c <= color && !snakeWillSelfCollide(o.snake, a.x, a.y)) {
const snake = nextSnake(o.snake, a.x, a.y);
if (!closeList.some((s0) => snakeEquals(s0, snake))) {
const w = o.w + 1 + +(c === color) * 1000;
sortPush(openList, { snake, w, parent: o }, (a, b) => a.w - b.w);
closeList.push(snake);
}
}
}
}
return null;
};
/**
* compute the best tunnel to get to the cell and back to the outside ( best = less usage of <color> )
*
* notice that it's one of the best tunnels, more with the same score could exist
*/
export const getBestTunnel = (
grid: Grid,
outside: Outside,
x: number,
y: number,
color: Color,
snakeN: number,
) => {
const c = { x, y };
const snake0 = createSnakeFromCells(Array.from({ length: snakeN }, () => c));
const one = getSnakeEscapePath(grid, outside, snake0, color);
if (!one) return null;
// get the position of the snake if it was going to leave the x,y cell
const snakeICells = one.slice(0, snakeN);
while (snakeICells.length < snakeN)
snakeICells.push(snakeICells[snakeICells.length - 1]);
const snakeI = createSnakeFromCells(snakeICells);
// remove from the grid the colors that one eat
const gridI = copyGrid(grid);
for (const { x, y } of one) setEmptySafe(gridI, x, y);
const two = getSnakeEscapePath(gridI, outside, snakeI, color);
if (!two) return null;
one.shift();
one.reverse();
one.push(...two);
trimTunnelStart(grid, one);
trimTunnelEnd(grid, one);
return one;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
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);
}
};

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
export const sortPush = <T>(arr: T[], x: T, sortFn: (a: T, b: T) => number) => {
let a = 0;
let b = arr.length;
if (arr.length === 0 || sortFn(x, arr[a]) <= 0) {
arr.unshift(x);
return;
}
while (b - a > 1) {
const e = Math.ceil((a + b) / 2);
const s = sortFn(x, arr[e]);
if (s === 0) a = b = e;
else if (s > 0) a = e;
else b = e;
}
const e = Math.ceil((a + b) / 2);
arr.splice(e, 0, x);
};

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
import { it, expect } from "bun:test";
import * as fs from "fs";
import * as path from "path";
import { createSvg, DrawOptions as DrawOptions } from "..";
import * as grids from "@snk/types/__fixtures__/grid";
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { AnimationOptions } from "@snk/gif-creator";
const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorDotBorder: "#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 animationOptions: AnimationOptions = { 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,
null,
chain,
drawOptions,
animationOptions,
);
expect(svg).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".svg"), svg);
});

View File

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

View File

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

View File

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

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