Compare commits

..

1 Commits

Author SHA1 Message Date
Platane
372b3e70f5 📦 1.0.2-rc.2 2022-03-24 10:10:04 +00:00
103 changed files with 71827 additions and 34112 deletions

View File

@@ -7,23 +7,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: bun install --frozen-lockfile
- run: yarn type
- run: yarn lint
- run: yarn test --ci
- run: npm run type
- run: npm run lint
- run: bun test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test-benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: ( cd packages/gif-creator ; yarn benchmark )
test-action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: update action.yml to use image from local Dockerfile
- name: update action.yml
run: |
sed -i "s/image: .*/image: Dockerfile/" action.yml
@@ -32,87 +45,40 @@ jobs:
uses: ./
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
gif_out_path: dist/github-contribution-grid-snake.gif
svg_out_path: dist/github-contribution-grid-snake.svg
- name: ensure the generated file exists
run: |
ls dist
test -f dist/github-contribution-grid-snake.svg
test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake.gif
test -f ${{ steps.generate-snake.outputs.gif_out_path }}
test -f ${{ steps.generate-snake.outputs.svg_out_path }}
- uses: crazy-max/ghaction-github-pages@v4.1.0
- uses: crazy-max/ghaction-github-pages@v2.5.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
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
path: packages/demo/dist
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- 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
- run: yarn build:demo
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
- uses: crazy-max/ghaction-github-pages@v2.6.0
if: success() && github.ref == 'refs/heads/master'
with:
target_branch: gh-pages
build_dir: packages/demo/dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}

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

@@ -4,36 +4,35 @@ 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.
description: "Version"
default: "0.0.1"
required: true
type: string
description:
description: "Version description"
type: string
prerelease:
description: "Prerelease"
default: false
required: true
type: boolean
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v2
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v2
- uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build and publish the docker image
uses: docker/build-push-action@v4
- uses: docker/build-push-action@v2
id: docker-build
with:
push: true
@@ -41,46 +40,36 @@ jobs:
platane/snk:${{ github.sha }}
platane/snk:${{ github.event.inputs.version }}
- name: update action.yml to point to the newly created docker image
- name: update action.yml
run: |
sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml
sed -i "s/image: .*/image: docker:\/\/platane\/snk:${{ steps.docker-build.outputs.digest }}/" action.yml
- uses: oven-sh/setup-bun@v1
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: bun install --frozen-lockfile
- name: bump version
run: yarn version --no-git-tag-version --new-version ${{ github.event.inputs.version }}
- name: build svg-only action
run: |
npm run build:action
rm -r svg-only/dist
mv packages/action/dist svg-only/dist
yarn install --frozen-lockfile
yarn build:demo
mv packages/demo/dist/* svg-only/
- 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
- name: push new commit
uses: EndBug/add-and-commit@v7
with:
add: package.json svg-only action.yml
message: 📦 ${{ github.event.inputs.version }}
tag: v${{ github.event.inputs.version }}
- uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ github.event.inputs.version }}
# release_name: Release ${{ github.event.inputs.version }}
body: ${{ github.event.inputs.description }}
prerelease: ${{ steps.push-tags.outputs.prerelease }}
prerelease: ${{ github.event.inputs.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

2
.nvmrc
View File

@@ -1 +1 @@
20
16

View File

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

View File

@@ -1,76 +1,41 @@
# 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>
![](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg)
Pull a github user's contribution graph.
Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion.
Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image.
Available as github action. 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)
Available as github action. Automatically generate a new image at the end of the day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
## Usage
**github action**
```yaml
- uses: Platane/snk@v3
- uses: Platane/snk@master
with:
# github user name to read the contribution graph from (**required**)
# using action context var `github.repository_owner` or specified user
github_user_name: ${{ github.repository_owner }}
# 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
# path of the generated gif file
# If left empty, the gif file will not be generated
gif_out_path: dist/github-snake.gif
# path of the generated svg file
# If left empty, the svg file will not be generated
svg_out_path: dist/github-snake.svg
```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#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>
```
> [example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
**interactive demo**

View File

@@ -4,31 +4,23 @@ author: "platane"
runs:
using: docker
image: docker://platane/snk@sha256:96390294299275740e5963058c9784c60c5393b3b8b16082dcf41b240db791f9
image: docker://platane/snk:sha256:a8082dd35baa9da91c686c7a7f25eb4f7216f5ab0cafe8cdec486994539bbe9d
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 file. If left empty, the gif file will not be generated."
required: false
default: ${{ github.token }}
outputs:
default: null
svg_out_path:
description: "path of the generated svg file. If left empty, the svg file will not be generated."
required: false
description: |
list of files to generate.
one file per line. Each output can be customized with options as query string.
default: null
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
outputs:
gif_out_path:
description: "path of the generated gif"
svg_out_path:
description: "path of the generated svg"

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,26 @@
{
"name": "snk",
"description": "Generates a snake game from a github user contributions grid",
"version": "3.3.0",
"version": "1.0.2-rc.2",
"private": true,
"repository": "github:platane/snk",
"devDependencies": {
"@types/bun": "1.2.2",
"prettier": "3.5.1",
"typescript": "5.7.3"
"@types/jest": "27.4.1",
"@types/node": "16.11.7",
"jest": "27.5.1",
"prettier": "2.6.0",
"ts-jest": "27.1.3",
"typescript": "4.6.2"
},
"workspaces": [
"packages/*"
"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/*/dist/**'",
"test": "jest --verbose --passWithNoTests --no-cache",
"dev:demo": "( cd packages/demo ; yarn dev )",
"build:demo": "( cd packages/demo ; yarn build )",
"build:action": "( cd packages/action ; yarn build )"
}
}

View File

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

View File

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

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

View File

@@ -1,8 +1,8 @@
import * as fs from "fs";
import * as path from "path";
import { it, expect } from "bun:test";
import { generateContributionSnake } from "../generateContributionSnake";
import { parseOutputsOption } from "../outputsOptions";
jest.setTimeout(2 * 60 * 1000);
const silent = (handler: () => void | Promise<void>) => async () => {
const originalConsoleLog = console.log;
@@ -17,29 +17,22 @@ const silent = (handler: () => void | Promise<void>) => async () => {
it(
"should generate contribution snake",
silent(async () => {
const entries = [
path.join(__dirname, "__snapshots__/out.svg"),
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
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!,
console.log = () => undefined;
const buffer = await generateContributionSnake("platane", {
svg: true,
gif: true,
});
expect(results[0]).toBeDefined();
expect(results[1]).toBeDefined();
expect(results[2]).toBeDefined();
expect(buffer.svg).toBeDefined();
expect(buffer.gif).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 },
console.log("💾 writing to", outputSvg);
fs.writeFileSync(outputSvg, buffer.svg);
console.log("💾 writing to", outputGif);
fs.writeFileSync(outputGif, buffer.gif);
})
);

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

View File

@@ -1,35 +1,31 @@
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");
const format = {
svg: core.getInput("svg_out_path"),
gif: core.getInput("gif_out_path"),
};
const { generateContributionSnake } = await import(
"./generateContributionSnake"
const { svg, gif } = await generateContributionSnake(
userName,
format as any
);
const results = await generateContributionSnake(userName, outputs, {
githubToken,
});
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);
}
});
if (svg) {
fs.mkdirSync(path.dirname(format.svg), { recursive: true });
fs.writeFileSync(format.svg, svg);
core.setOutput("svg_out_path", format.svg);
}
if (gif) {
fs.mkdirSync(path.dirname(format.gif), { recursive: true });
fs.writeFileSync(format.gif, gif);
core.setOutput("gif_out_path", format.gif);
}
} catch (e: any) {
core.setFailed(`Action failed with "${e.message}"`);
}

View File

@@ -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,16 @@
"name": "@snk/action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "1.11.1",
"@actions/core": "1.6.0",
"@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",
"ts-node": "10.7.0"
},
"scripts": {
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts"
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts",
"dev": "ts-node __tests__/dev.ts"
}
}

View File

@@ -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

@@ -2,13 +2,17 @@ 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[]) => {
export const userContributionToGrid = (
cells: Cell[],
colorScheme: string[]
) => {
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
const grid = createEmptyGrid(width, height);
for (const c of cells) {
if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color);
const k = colorScheme.indexOf(c.color);
if (k > 0) setColor(grid, c.x, c.y, k as Color);
else setColorEmpty(grid, c.x, c.y);
}

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,18 @@
import { getBestRoute } from "@snk/solver/getBestRoute";
import { Color, copyGrid, Grid } from "@snk/types/grid";
import { step } from "@snk/solver/step";
import { isStableAndBound, stepSpring } from "./springUtils";
import type { Res } from "@snk/github-user-contribution";
import type { Snake } from "@snk/types/snake";
import type { Point } from "@snk/types/point";
import { Res } from "@snk/github-user-contribution";
import { Snake } from "@snk/types/snake";
import {
drawLerpWorld,
getCanvasWorldSize,
Options as DrawOptions,
Options,
} from "@snk/draw/drawWorld";
import { userContributionToGrid } from "@snk/action/userContributionToGrid";
import { createSvg } from "@snk/svg-creator";
import { createRpcClient } from "./worker-utils";
import type { API as WorkerAPI } from "./demo.interactive.worker";
import { AnimationOptions } from "@snk/gif-creator";
import { basePalettes } from "@snk/action/palettes";
import { userContributionToGrid } from "../action/userContributionToGrid";
import { snake4 as snake } from "@snk/types/__fixtures__/snake";
import { getPathToPose } from "@snk/solver/getPathToPose";
import { createSvg } from "../svg-creator";
const createForm = ({
onSubmit,
@@ -49,24 +47,15 @@ const createForm = ({
form.addEventListener("submit", (event) => {
event.preventDefault();
onSubmit(input.value)
.finally(() => {
clearTimeout(timeout);
})
.catch((err) => {
label.innerText = "error :(";
throw err;
});
onSubmit(input.value).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);
});
//
@@ -86,7 +75,6 @@ const createGithubProfile = () => {
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";
@@ -119,19 +107,12 @@ const createGithubProfile = () => {
const createViewer = ({
grid0,
chain,
cells,
drawOptions,
}: {
grid0: Grid;
chain: Snake[];
cells: Point[];
drawOptions: Options;
}) => {
const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
...basePalettes["github-light"],
};
//
// canvas
const canvas = document.createElement("canvas");
@@ -169,7 +150,7 @@ const createViewer = ({
const k = spring.x % 1;
ctx.clearRect(0, 0, 9999, 9999);
drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k, drawOptions);
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
if (!stable) animationFrame = requestAnimationFrame(loop);
};
@@ -177,12 +158,12 @@ const createViewer = ({
//
// controls
const input = document.createElement("input");
const input = document.createElement("input") as any;
input.type = "range";
input.value = "0";
input.step = "1";
input.min = "0";
input.max = "" + chain.length;
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;
@@ -196,51 +177,12 @@ const createViewer = ({
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, {
const svgString = createSvg(grid0, 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";
@@ -248,12 +190,9 @@ const createViewer = ({
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">` +
`<a href="${svgImageUri}" download="github-user-contribution.svg">` +
svgString +
"<a/>",
"<a/>"
);
e.preventDefault();
});
@@ -277,29 +216,29 @@ const createViewer = ({
const onSubmit = async (userName: string) => {
const res = await fetch(
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName,
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName
);
const cells = (await res.json()) as Res;
const { cells, colorScheme } = (await res.json()) as Res;
const grid = userContributionToGrid(cells);
const chain = await getChain(grid);
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: colorScheme as any,
colorEmpty: colorScheme[0],
colorSnake: "purple",
cells,
};
const grid = userContributionToGrid(cells, colorScheme);
const chain = getBestRoute(grid, snake)!;
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
dispose();
createViewer({ grid0: grid, chain, cells });
createViewer({ grid0: grid, chain, drawOptions });
};
const worker = new Worker(
new URL(
"./demo.interactive.worker.ts",
// @ts-ignore
import.meta.url,
),
);
const { getChain } = createRpcClient<WorkerAPI>(worker);
const profile = createGithubProfile();
const { dispose } = createForm({
onSubmit,

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,18 +1,15 @@
import "./menu";
import { getBestRoute } from "@snk/solver/getBestRoute";
import { createSvg } from "@snk/svg-creator";
import { createSvg } from "../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 svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 });
const container = document.createElement("div");
container.innerHTML = svg;

View File

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

View File

@@ -2,23 +2,20 @@
"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"
"canvas": "2.9.1",
"gifsicle": "5.3.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"
"@types/dat.gui": "0.7.7",
"dat.gui": "0.7.7",
"html-webpack-plugin": "5.5.0",
"ts-loader": "9.2.6",
"ts-node": "10.7.0",
"webpack": "5.70.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.7.4"
},
"scripts": {
"build": "webpack",

View File

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

View File

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

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

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

View File

@@ -6,36 +6,36 @@ 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;
cells?: Point[];
};
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)) {
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) {
const c = getColor(grid, x, y);
// @ts-ignore
const color = !c ? o.colorEmpty : o.colorDots[c];
ctx.save();
ctx.translate(
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
);
ctx.fillStyle = color;
ctx.strokeStyle = o.colorDotBorder;
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();

View File

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

View File

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

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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,3 @@
# @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
```
Expose github-user-contribution as an endpoint, using vercel.sh

View File

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

View File

@@ -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

@@ -2,13 +2,7 @@
"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"
"@snk/github-user-contribution": "1.0.0",
"@vercel/node": "1.14.0"
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
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";

View File

@@ -1,4 +1,3 @@
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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,65 +1,64 @@
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";
import { h } from "./utils";
export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorDotBorder: string;
colorBorder: string;
sizeCell: number;
sizeDot: number;
sizeDotBorderRadius: number;
sizeBorderRadius: number;
};
const percent = (x: number) => (x * 100).toFixed(2);
export const createGrid = (
cells: (Point & { t: number | null; color: Color | Empty })[],
{ sizeDotBorderRadius, sizeDot, sizeCell }: Options,
duration: number,
{ sizeBorderRadius, sizeDot, sizeCell }: Options,
duration: number
) => {
const svgElements: string[] = [];
const styles = [
`.c{
shape-rendering: geometricPrecision;
rx: ${sizeBorderRadius};
ry: ${sizeBorderRadius};
fill: var(--ce);
stroke-width: 1px;
stroke: var(--cb);
animation: none ${duration}ms linear infinite;
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;
const s = sizeCell;
const d = sizeDot;
const m = (s - d) / 2;
if (t !== null && id) {
if (t !== null) {
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)` },
]),
`@keyframes ${animationName} {` +
`${percent(t - 0.0001)}%{fill:var(--c${color})}` +
`${percent(t + 0.0001)}%,100%{fill:var(--ce)}` +
"}",
`.c.${id}{
fill: var(--c${color});
animation-name: ${animationName}
}`,
`.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,
}),
x: x * s + m,
y: y * s + m,
width: d,
height: d,
})
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ As a drawback, it can not generate gif image.
## Build process
dist file are built and push on release, by the release action.
file is built and push on release, by the release action.

View File

@@ -3,33 +3,18 @@ description: "Generates a snake game from a github user contributions grid. Outp
author: "platane"
runs:
using: node20
main: dist/index.js
using: node16
main: index.js
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."
svg_out_path:
description: "path of the generated svg file. If left empty, the svg file will not be generated."
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.
default: null
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.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
outputs:
svg_out_path:
description: "path of the generated svg"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

27795
svg-only/dist/index.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - getBestRoute</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="f76fcb720ebb4f9b7094.js"></script></head>
<body>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - getBestTunnel</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="58f1a430b6e288bd0c55.js"></script></head>
<body>
</body>
</html>

9
svg-only/getPathTo.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - getPathTo</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="c10a4b0e515483329a49.js"></script></head>
<body>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - getPathToPose</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="7587fb72ce536dc8c3d6.js"></script></head>
<body>
</body>
</html>

9
svg-only/index.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - interactive</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="65edc0d25be3835439de.js"></script></head>
<body>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - interactive</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="65edc0d25be3835439de.js"></script></head>
<body>
</body>
</html>

9
svg-only/outside.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>snk - outside</title>
<meta name="viewport" content="width=device-width, initial-scale=1"><script defer src="7b4f83fb850c5eebc0f6.js"></script></head>
<body>
</body>
</html>

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