Compare commits

..

14 Commits

Author SHA1 Message Date
platane
047c7191f3 aaaa 2020-07-19 19:15:41 +02:00
platane
10d162247f aaaa 2020-07-19 19:13:17 +02:00
platane
13f9f66ae0 aaaaa 2020-07-19 19:12:05 +02:00
platane
7e80be60db aaaa 2020-07-19 19:09:26 +02:00
platane
16eb314428 aaaa 2020-07-19 18:51:08 +02:00
platane
72507d3b16 aaaa 2020-07-19 18:47:45 +02:00
platane
a7ffc4959e aaaa 2020-07-19 18:45:07 +02:00
platane
8271057335 :feat: improve demo and add test script for action 2020-07-19 18:42:56 +02:00
platane
6d9d6bd0f7 🔨 clean up ts config 2020-07-19 18:42:09 +02:00
platane
ae451ddb81 🔨 fix deploy script 2020-07-19 18:32:14 +02:00
platane
aa304de936 🔨 fix deployment script 2020-07-19 18:27:44 +02:00
platane
49260a4367 🔨 fix demo build 2020-07-19 18:24:48 +02:00
platane
d23468549b build 2020-07-19 18:16:11 +02:00
platane
75496b6100 aaaaa 2020-07-19 18:15:45 +02:00
148 changed files with 179035 additions and 61002 deletions

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

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

View File

@@ -1,87 +1,27 @@
name: main
on: [push]
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
# - run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
node-version: 14
- run: yarn type
- run: yarn lint
- run: yarn test --ci
- uses: bahmutov/npm-install@v1
test-benchmark:
runs-on: ubuntu-latest
# - run: yarn type
# - run: yarn lint
# - run: yarn test --ci
# - run: yarn build:lib
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: ( cd packages/gif-creator ; yarn benchmark )
test-action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: update action.yml to use image from local Dockerfile
run: |
sed -i "s/image: .*/image: Dockerfile/" action.yml
- name: generate-snake-game-from-github-contribution-grid
id: generate-snake
uses: ./
- uses: actions/github-contribution-grid-snake@v1
with:
github_user_name: platane
outputs: |
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
- name: ensure the generated file exists
run: |
ls dist
test -f dist/github-contribution-grid-snake.svg
test -f dist/github-contribution-grid-snake-dark.svg
test -f dist/github-contribution-grid-snake.gif
- uses: crazy-max/ghaction-github-pages@v2.5.0
with:
target_branch: output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy-ghpages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
cache: yarn
node-version: 16
- run: yarn install --frozen-lockfile
- run: yarn build:demo
env:
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
- uses: crazy-max/ghaction-github-pages@v2.6.0
if: success() && github.ref.name == 'main'
with:
target_branch: gh-pages
build_dir: packages/demo/dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
- run: ls

View File

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

1
.gitignore vendored
View File

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

1
.nvmrc
View File

@@ -1 +0,0 @@
16

View File

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

View File

@@ -1,60 +1,3 @@
# snk
[![GitHub release](https://img.shields.io/github/release/platane/snk.svg?style=flat-square)](https://github.com/platane/snk/releases/latest)
[![GitHub marketplace](https://img.shields.io/badge/marketplace-snake-blue?logo=github&style=flat-square)](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square)
![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)
Generates a snake game from a github user contributions graph
![](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg)
Pull a github user's contribution graph.
Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion.
Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image.
Available as github action. Automatically generate a new image at the end of the day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
## Usage
**github action**
```yaml
- uses: Platane/snk@v2.0.0-rc.1
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.
outputs: |
dist/github-snake.svg
dist/github-snake.svg?palette=github-dark
dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
If you are only interested in generating a svg, you can use this other faster action: `uses: Platane/snk/svg-only@v2.0.0-rc.1`
**interactive demo**
<a href="https://platane.github.io/snk">
<img height="300px" src="https://user-images.githubusercontent.com/1659820/121798244-7c86d700-cc25-11eb-8c1c-b8e65556ac0d.gif" ></img>
</a>
[platane.github.io/snk](https://platane.github.io/snk)
**local**
```
npm install
npm run dev:demo
```
## Implementation
[solver algorithm](./packages/solver/README.md)
Generates a snake game from a github user contributions grid and output a screen capture as gif

View File

@@ -1,31 +1,23 @@
name: "generate-snake-game-from-github-contribution-grid"
description: "Generates a snake game from a github user contributions grid. Output the animation as gif or svg"
description: "Generates a snake game from a github user contributions grid and output a screen capture as gif"
author: "platane"
outputs:
gif_out_path:
description: "path of the generated gif"
runs:
using: docker
image: docker://platane/snk@sha256:339ea30dd50fc9566cd9b7244729b5ea12f14afea44585770212b96d3389748b
using: "docker"
image: "Dockerfile"
args:
- ${{ inputs.github_user_name }}
- ${{ inputs.gif_out_path }}
inputs:
github_user_name:
description: "github user name"
required: true
outputs:
gif_out_path:
description: "path of the generated gif"
required: false
default: null
description: |
list of files to generate.
Generates one file per line. Each output can be customized with query string.
following this pattern: path/to/file.<gif or svg>?palette=<github or github-dark>&color_snake=<color>&color_dots=<color>,<color>,<color>,<color>,<color>
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 the empty cell color, the second one is the lightest shade, the third one is the second lightest shade ect ...
example:
outputs: |
dark.svg?palette=github-dark&color_snake=blue
light.svg?color_snake=#7845ab
ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
default: "./github-contribution-grid-snake.gif"

View File

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

3
packages/action/.gitignore vendored Normal file
View File

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

View File

@@ -1,13 +0,0 @@
# @snk/action
Contains the github action code.
## Implementation
### Docker
Because the gif generation requires some native libs, we cannot use a node.js action.
Use a docker action instead, the image is created from the [Dockerfile](../../Dockerfile).
It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs )

View File

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

View File

@@ -1,66 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should parse /out.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 1`] = `
Object {
"drawOptions": Object {
"colorDotBorder": "#1b1f230a",
"colorDots": Array [
"#bfd6f6",
"#8dbdff",
"#64a1f4",
"#4b91f1",
"#3c7dd9",
],
"colorEmpty": "#bfd6f6",
"colorSnake": "orange",
"dark": undefined,
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "/out.svg",
"format": "svg",
"gifOptions": Object {
"frameDuration": 100,
"step": 1,
},
}
`;
exports[`should parse path/to/out.gif 1`] = `
Object {
"drawOptions": Object {
"colorDotBorder": "#1b1f230a",
"colorDots": Array [
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
],
"colorEmpty": "#ebedf0",
"colorSnake": "purple",
"dark": Object {
"colorDotBorder": "#1b1f230a",
"colorDots": Array [
"#161b22",
"#01311f",
"#034525",
"#0f6d31",
"#00c647",
],
"colorEmpty": "#161b22",
"colorSnake": "purple",
},
"sizeCell": 16,
"sizeDot": 12,
"sizeDotBorderRadius": 2,
},
"filename": "path/to/out.gif",
"format": "gif",
"gifOptions": Object {
"frameDuration": 100,
"step": 1,
},
}
`;

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import { parseEntry } from "../outputsOptions";
[
"path/to/out.gif",
"/out.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9",
].forEach((entry) =>
it(`should parse ${entry}`, () => {
expect(parseEntry(entry)).toMatchSnapshot();
})
);

BIN
packages/action/dist/build/Release/canvas.node vendored Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

174125
packages/action/dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

84
packages/action/dist/index1.js vendored Normal file
View File

@@ -0,0 +1,84 @@
const Canvas = require('./lib/canvas')
const Image = require('./lib/image')
const CanvasRenderingContext2D = require('./lib/context2d')
const parseFont = require('./lib/parse-font')
const packageJson = require('./package.json')
const bindings = require('./lib/bindings')
const fs = require('fs')
const PNGStream = require('./lib/pngstream')
const PDFStream = require('./lib/pdfstream')
const JPEGStream = require('./lib/jpegstream')
const DOMMatrix = require('./lib/DOMMatrix').DOMMatrix
const DOMPoint = require('./lib/DOMMatrix').DOMPoint
function createCanvas (width, height, type) {
return new Canvas(width, height, type)
}
function createImageData (array, width, height) {
return new bindings.ImageData(array, width, height)
}
function loadImage (src) {
return new Promise((resolve, reject) => {
const image = new Image()
function cleanup () {
image.onload = null
image.onerror = null
}
image.onload = () => { cleanup(); resolve(image) }
image.onerror = (err) => { cleanup(); reject(err) }
image.src = src
})
}
/**
* Resolve paths for registerFont. Must be called *before* creating a Canvas
* instance.
* @param src {string} Path to font file.
* @param fontFace {{family: string, weight?: string, style?: string}} Object
* specifying font information. `weight` and `style` default to `"normal"`.
*/
function registerFont (src, fontFace) {
// TODO this doesn't need to be on Canvas; it should just be a static method
// of `bindings`.
return Canvas._registerFont(fs.realpathSync(src), fontFace)
}
module.exports = {
Canvas,
Context2d: CanvasRenderingContext2D, // Legacy/compat export
CanvasRenderingContext2D,
CanvasGradient: bindings.CanvasGradient,
CanvasPattern: bindings.CanvasPattern,
Image,
ImageData: bindings.ImageData,
PNGStream,
PDFStream,
JPEGStream,
DOMMatrix,
DOMPoint,
registerFont,
parseFont,
createCanvas,
createImageData,
loadImage,
backends: bindings.Backends,
/** Library version. */
version: packageJson.version,
/** Cairo version. */
cairoVersion: bindings.cairoVersion,
/** jpeglib version. */
jpegVersion: bindings.jpegVersion,
/** gif_lib version. */
gifVersion: bindings.gifVersion ? bindings.gifVersion.replace(/[^.\d]/g, '') : undefined,
/** freetype version. */
freetypeVersion: bindings.freetypeVersion
}

60
packages/action/dist/xhr-sync-worker.js vendored Normal file
View File

@@ -0,0 +1,60 @@
"use strict";
/* eslint-disable no-process-exit */
const util = require("util");
const { JSDOM } = require("../../../..");
const { READY_STATES } = require("./xhr-utils");
const idlUtils = require("../generated/utils");
const tough = require("tough-cookie");
const dom = new JSDOM();
const xhr = new dom.window.XMLHttpRequest();
const xhrImpl = idlUtils.implForWrapper(xhr);
const chunks = [];
process.stdin.on("data", chunk => {
chunks.push(chunk);
});
process.stdin.on("end", () => {
const buffer = Buffer.concat(chunks);
const flag = JSON.parse(buffer.toString());
if (flag.body && flag.body.type === "Buffer" && flag.body.data) {
flag.body = Buffer.from(flag.body.data);
}
if (flag.cookieJar) {
flag.cookieJar = tough.CookieJar.fromJSON(flag.cookieJar);
}
flag.synchronous = false;
Object.assign(xhrImpl.flag, flag);
const { properties } = xhrImpl;
xhrImpl.readyState = READY_STATES.OPENED;
try {
xhr.addEventListener("loadend", () => {
if (properties.error) {
properties.error = properties.error.stack || util.inspect(properties.error);
}
process.stdout.write(JSON.stringify({
responseURL: xhrImpl.responseURL,
status: xhrImpl.status,
statusText: xhrImpl.statusText,
properties
}), () => {
process.exit(0);
});
}, false);
xhr.send(flag.body);
} catch (error) {
properties.error += error.stack || util.inspect(error);
process.stdout.write(JSON.stringify({
responseURL: xhrImpl.responseURL,
status: xhrImpl.status,
statusText: xhrImpl.statusText,
properties
}), () => {
process.exit(0);
});
}
});

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import { Options 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))(\?.*)?$/);
if (!m) return null;
const [_, filename, format, query] = m;
const sp = new URLSearchParams(query || "");
const drawOptions: DrawOptions = {
sizeDotBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
...palettes["default"],
};
const gifOptions = { step: 1, frameDuration: 100 };
{
const palette = palettes[sp.get("palette")!];
if (palette) {
Object.assign(drawOptions, palette);
drawOptions.dark = palette.dark && { ...palette.dark };
}
}
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 = {
...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, gifOptions };
};

View File

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

View File

@@ -1,29 +0,0 @@
import { Options as DrawOptions } from "@snk/svg-creator";
export const palettes: 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
palettes["github"] = {
...palettes["github-light"],
dark: { ...palettes["github-dark"] },
};
palettes["default"] = palettes["github"];

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

1
packages/demo/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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