Compare commits

...

34 Commits

Author SHA1 Message Date
platane
335757dc9d 👷 2020-10-15 00:32:31 +02:00
platane
40c6caa805 🔨 fix image 2020-10-15 00:25:04 +02:00
platane
6db574c4ba 🚀 less step in the gif 2020-10-15 00:23:03 +02:00
platane
523aebc4d5 👷 push on docker hub 2020-10-15 00:22:02 +02:00
platane
1e1967ef61 ⬆️ upgrade github action dependencies 2020-10-14 23:03:23 +02:00
platane
3d16c675bd 🔨 fix typing 2020-10-14 23:03:23 +02:00
platane
8f5c1969a6 🚀 faster solution 2020-10-14 23:03:23 +02:00
platane
fe821f6251 🚀 prune layer algorithm 2020-10-14 23:03:23 +02:00
platane
d7423423f8 👷 upload gif as artifact 2020-10-09 18:44:25 +02:00
platane
03396bae31 🔨 fix benchmark script 2020-10-09 14:23:56 +02:00
platane
b63a1191b4 🔨 fix demo 2020-10-09 14:21:26 +02:00
platane
a9555b092a 🚀 refactor gif creator ( a bit ) 2020-10-09 14:20:12 +02:00
platane
1f9dda0ca6 🔨 fix demo page 2020-10-09 12:44:15 +02:00
platane
202bd7cacb 🚀 gif creator benchmark 2020-10-09 12:44:15 +02:00
platane
bc18120a98 🚀 use webpack.config.ts 2020-10-09 12:23:50 +02:00
platane
bb0750e8ba 🚀 demo + spring 2020-10-09 12:23:50 +02:00
platane
16a47349be 🚀 benchmark ? 2020-10-09 12:23:50 +02:00
platane
b0784fbaca 👷 fix script 2020-10-09 12:23:50 +02:00
platane
d5bdc84680 ⬆️ bump dependencies 2020-10-09 12:23:50 +02:00
platane
9c758febe7 👷 fix script 2020-10-09 12:23:50 +02:00
platane
2125640716 👷 fix script 2020-10-09 12:23:50 +02:00
platane
64f0b872aa 🚀 refactor demo 2020-10-09 12:23:50 +02:00
platane
9b92697ef9 🚀 refactor 2020-10-09 12:23:50 +02:00
platane
8d8956229c 🚀 refactor get available routes 2020-10-09 12:23:50 +02:00
platane
2499529b1d 🚀 optimize stuff I guess 2020-08-03 22:55:14 +02:00
platane
3625bdb819 🚀 refactor getBestRoute 2020-07-30 18:33:42 +02:00
platane
9ab55aaad6 🚀 getAvailableRoute 2020-07-30 16:27:04 +02:00
platane
48d89528d5 🚀 upscale demo resolution 2020-07-21 18:16:01 +02:00
platane
e637604df1 🚀 benchmark 2020-07-21 18:15:41 +02:00
platane
1898ec16e4 🚀 improve command computation 2020-07-21 01:03:29 +02:00
platane
fd9d7dadf6 🚀 smarter snake 2020-07-21 00:34:22 +02:00
platane
73bfce908e 🔨 fix demo 2020-07-20 23:02:23 +02:00
platane
dd23c1630e 🚿 clean up 2020-07-20 22:37:58 +02:00
platane
7377068a9a 📓 add readme 2020-07-20 10:23:32 +02:00
55 changed files with 2390 additions and 1157 deletions

View File

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

View File

@@ -1,6 +1,6 @@
name: main
on: [push, pull_request]
on: [push]
jobs:
test:
@@ -8,17 +8,31 @@ jobs:
steps:
- run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v1
- uses: actions/setup-node@v1.4.2
- uses: actions/checkout@v2.3.3
- uses: actions/setup-node@v1.4.4
with:
node-version: 14
- uses: bahmutov/npm-install@v1.4.1
- uses: bahmutov/npm-install@v1.4.3
- run: yarn type
- run: yarn lint
- run: yarn test --ci
- run: yarn build:action
test-benchmark:
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v2.3.3
- uses: actions/setup-node@v1.4.4
with:
node-version: 14
- uses: bahmutov/npm-install@v1.4.3
- run: ( cd packages/compute ; yarn benchmark )
- run: ( cd packages/gif-creator ; yarn benchmark )
test-action:
runs-on: ubuntu-latest
@@ -38,9 +52,60 @@ jobs:
ls -l ${{ steps.snake-gif.outputs.gif_out_path }}
test -f ${{ steps.snake-gif.outputs.gif_out_path }}
- uses: crazy-max/ghaction-github-pages@v2.1.1
- uses: crazy-max/ghaction-github-pages@v2.1.3
with:
target_branch: output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
deploy-ghpages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2.3.3
- uses: actions/setup-node@v1.4.4
with:
node-version: 14
- uses: bahmutov/npm-install@v1.4.3
- run: yarn build:demo
env:
BASE_PATHNAME: "snk"
- uses: crazy-max/ghaction-github-pages@v2.1.3
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 }}
build-docker-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2.3.3
- uses: actions/setup-node@v1.4.4
with:
node-version: 14
- uses: bahmutov/npm-install@v1.4.3
- run: yarn build:action
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v2
id: docker_build
with:
push: ${{ github.ref == 'refs/heads/master' }}
tags: platane/snk:latest
file: packages/action/Dockerfile
context: packages/action

View File

@@ -1,19 +0,0 @@
FROM node:14-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
&& rm -rf /var/lib/apt/lists/*
COPY tsconfig.json package.json yarn.lock /github/snk/
COPY packages /github/snk/packages
RUN ( \
cd /github/snk \
&& find . \
&& yarn install --frozen-lockfile \
&& yarn build:action \
&& mv packages/action/dist/* . \
&& rm -rf packages tsconfig.json package.json yarn.lock node_modules \
)
CMD ["node", "/github/snk/index.js"]

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# snk
![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)
![](https://raw.githubusercontent.com/Platane/snk/output/github-contribution-grid-snake.gif)
Generates a snake game from a github user contributions grid and output a screen capture as gif
- [demo](https://platane.github.io/snk/index.html)
- [github action](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)

View File

@@ -8,7 +8,7 @@ outputs:
runs:
using: "docker"
image: "Dockerfile"
image: "docker://platane/snk:latest"
inputs:
github_user_name:

View File

@@ -5,11 +5,12 @@
"private": true,
"repository": "github:platane/snk",
"devDependencies": {
"@types/jest": "26.0.4",
"jest": "26.1.0",
"prettier": "2.0.5",
"ts-jest": "26.1.2",
"typescript": "3.9.6"
"@types/jest": "26.0.14",
"@types/node": "14.11.8",
"jest": "26.5.2",
"prettier": "2.1.2",
"ts-jest": "26.4.1",
"typescript": "4.0.3"
},
"workspaces": [
"packages/**"
@@ -18,6 +19,7 @@
"type": "tsc --noEmit",
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/action/dist/**' '!packages/demo/dist/**' '!packages/demo/webpack.config.js'",
"test": "jest --verbose --passWithNoTests --no-cache",
"dev:demo": "( cd packages/demo ; yarn dev )",
"build:demo": "( cd packages/demo ; yarn build )",
"build:action": "( cd packages/action ; yarn build )"
}

View File

@@ -0,0 +1,13 @@
FROM node:14-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /github/snk
RUN npm install canvas@2.6.1
COPY dist /github/snk/
CMD ["node", "/github/snk/index.js"]

View File

@@ -1,31 +1,21 @@
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 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;
};
import { getGithubUserContribution } from "@snk/github-user-contribution";
import { createGif } from "@snk/gif-creator";
import { createSnake } from "@snk/compute/snake";
import { getBestRoute } from "@snk/compute/getBestRoute";
import { userContributionToGrid } from "./userContributionToGrid";
export const generateContributionSnake = async (userName: string) => {
const { cells, colorScheme } = await getGithubUserContribution(userName);
const grid0 = userContributionToGrid(cells);
const snake0 = [
const snake0 = createSnake([
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
]);
const drawOptions = {
sizeBorderRadius: 2,
@@ -37,20 +27,11 @@ export const generateContributionSnake = async (userName: string) => {
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { frameDuration: 100, step: 1 };
const gifOptions = { delay: 10 };
const chain = getBestRoute(grid0, snake0)!;
const commands = computeBestRun(grid0, snake0, gameOptions);
const buffer = await createGif(
grid0,
snake0,
commands,
drawOptions,
gameOptions,
gifOptions
);
const buffer = await createGif(grid0, chain, drawOptions, gifOptions);
return buffer;
};

View File

@@ -2,7 +2,7 @@
"name": "@snk/action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "1.2.4",
"@actions/core": "1.2.6",
"@snk/gif-creator": "1.0.0",
"@snk/github-user-contribution": "1.0.0"
},
@@ -10,7 +10,7 @@
"@zeit/ncc": "0.22.3"
},
"scripts": {
"build": "ncc build ./index.ts --out dist",
"build": "ncc build --external canvas --out dist ./index.ts ",
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
}
}

View File

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

View File

@@ -0,0 +1,54 @@
// @ts-ignore
import * as ParkMiller from "park-miller";
import { Color, createEmptyGrid, setColor } from "@snk/compute/grid";
import { fillRandomGrid } from "../generateGrid";
const colors = [1, 2, 3] as Color[];
// empty small grid
export const empty = createEmptyGrid(5, 5);
// empty small grid with a unique color at the middle
export const simple = createEmptyGrid(5, 5);
setColor(simple, 2, 2, 1 as Color);
// empty small grid with color at each corner
export const corner = createEmptyGrid(5, 5);
setColor(corner, 0, 4, 1 as Color);
setColor(corner, 4, 0, 1 as Color);
setColor(corner, 4, 4, 1 as Color);
setColor(corner, 0, 0, 1 as Color);
// enclaved color
export const enclave = createEmptyGrid(7, 7);
setColor(enclave, 3, 4, 2 as Color);
setColor(enclave, 2, 3, 2 as Color);
setColor(enclave, 2, 4, 2 as Color);
setColor(enclave, 4, 4, 2 as Color);
setColor(enclave, 4, 3, 2 as Color);
setColor(enclave, 3, 3, 1 as Color);
setColor(enclave, 5, 5, 1 as Color);
// enclaved color
export const enclaveBorder = createEmptyGrid(7, 7);
setColor(enclaveBorder, 1, 0, 3 as Color);
setColor(enclaveBorder, 2, 1, 3 as Color);
setColor(enclaveBorder, 3, 0, 3 as Color);
setColor(enclaveBorder, 2, 0, 1 as Color);
const create = (width: number, height: number, emptyP: number) => {
const grid = createEmptyGrid(width, height);
const random = new ParkMiller(10);
const rand = (a: number, b: number) => random.integerInRange(a, b - 1);
fillRandomGrid(grid, { colors, emptyP }, rand);
return grid;
};
// small realistic
export const small = create(10, 7, 3);
export const smallPacked = create(10, 7, 1);
export const smallFull = create(10, 7, 0);
// small realistic
export const realistic = create(52, 7, 3);
export const realisticFull = create(52, 7, 0);

View File

@@ -0,0 +1,10 @@
// @ts-ignore
import { createSnake } from "../snake";
const create = (length: number) =>
createSnake(Array.from({ length }, (_, i) => ({ x: i, y: -1 })));
export const snake1 = create(1);
export const snake3 = create(3);
export const snake5 = create(5);
export const snake9 = create(9);

View File

@@ -0,0 +1,29 @@
import { realistic as grid } from "../__fixtures__/grid";
import { snake3 } from "../__fixtures__/snake";
import { performance } from "perf_hooks";
import { getAvailableRoutes } from "../getAvailableRoutes";
import { getBestRoute } from "../getBestRoute";
{
const m = 100;
const s = performance.now();
for (let k = m; k--; ) {
const solutions = [];
getAvailableRoutes(grid, snake3, (snakes) => {
solutions.push(snakes);
return false;
});
}
console.log("getAvailableRoutes", (performance.now() - s) / m, "ms");
}
{
const m = 10;
const s = performance.now();
for (let k = m; k--; ) {
getBestRoute(grid, snake3);
}
console.log("getBestRoute", (performance.now() - s) / m, "ms");
}

View File

@@ -0,0 +1,19 @@
import { getBestRoute } from "../getBestRoute";
import { Color, createEmptyGrid, setColor } from "../grid";
import { createSnake, snakeToCells } from "../snake";
it("should find best route", () => {
const snk0 = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
];
const grid = createEmptyGrid(5, 5);
setColor(grid, 3, 3, 1 as Color);
const chain = getBestRoute(grid, createSnake(snk0))!;
expect(snakeToCells(chain[0])[1]).toEqual({ x: 0, y: 0 });
expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 });
});

View File

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

View File

@@ -1,24 +1,43 @@
import { snakeSelfCollide } from "../snake";
import {
createSnake,
nextSnake,
snakeToCells,
snakeWillSelfCollide,
} 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);
it("should convert to point", () => {
const snk0 = [
{ x: 1, y: -1 },
{ x: 1, y: 0 },
{ x: 0, y: 0 },
];
expect(snakeToCells(createSnake(snk0))).toEqual(snk0);
});
it("should return next snake", () => {
const snk0 = [
{ x: 1, y: 1 },
{ x: 1, y: 0 },
{ x: 0, y: 0 },
];
const snk1 = [
{ x: 2, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 0 },
];
expect(snakeToCells(nextSnake(createSnake(snk0), 1, 0))).toEqual(snk1);
});
it("should test snake collision", () => {
const snk0 = [
{ x: 1, y: 1 },
{ x: 1, y: 0 },
{ x: 0, y: 0 },
];
expect(snakeWillSelfCollide(createSnake(snk0), 1, 0)).toBe(false);
expect(snakeWillSelfCollide(createSnake(snk0), 0, -1)).toBe(true);
});

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
import { copyGrid, isEmpty, setColorEmpty } from "./grid";
import { getHeadX, getHeadY, snakeEquals } from "./snake";
import { sortPush } from "./utils/sortPush";
import { arrayEquals } from "./utils/array";
import { getAvailableRoutes } from "./getAvailableRoutes";
import type { Snake } from "./snake";
import type { Grid } from "./grid";
import type { Point } from "./point";
type M = {
snake: Snake;
chain: Snake[];
chunk: Point[];
grid: Grid;
parent: M | null;
w: number;
h: number;
f: number;
};
const unwrap = (o: M | null): Snake[] =>
!o ? [] : [...o.chain, ...unwrap(o.parent)];
const createGetHeuristic = (grid: Grid, chunk0: Point[]) => {
const n = grid.data.reduce((sum, x: any) => sum + +!isEmpty(x), 0);
const area = grid.width * grid.height;
const k =
Math.sqrt((2 * area) / chunk0.length) * 1 + (n - chunk0.length) / area;
return (chunk: any[]) => chunk.length * k;
};
export const getAvailableWhiteListedRoutes = (
grid: Grid,
snake: Snake,
whiteList: Point[]
) => {
let solution: Snake[] | null;
getAvailableRoutes(grid, snake, (chain) => {
const hx = getHeadX(chain[0]);
const hy = getHeadY(chain[0]);
if (!whiteList.some(({ x, y }) => hx === x && hy === y)) return false;
solution = chain;
return true;
});
// @ts-ignore
return solution;
};
export const cleanLayer = (grid0: Grid, snake0: Snake, chunk0: Point[]) => {
const getH = createGetHeuristic(grid0, chunk0);
const next = {
grid: grid0,
snake: snake0,
chain: [snake0],
chunk: chunk0,
parent: null,
h: getH(chunk0),
f: getH(chunk0),
w: 0,
};
const openList: M[] = [next];
const closeList: M[] = [next];
while (openList.length) {
const o = openList.shift()!;
if (o.chunk.length === 0) return unwrap(o).slice(0, -1);
const chain = getAvailableWhiteListedRoutes(o.grid, o.snake, o.chunk);
if (chain) {
const snake = chain[0];
const x = getHeadX(snake);
const y = getHeadY(snake);
const chunk = o.chunk.filter((u) => u.x !== x || u.y !== y);
if (
!closeList.some(
(u) => snakeEquals(u.snake, snake) && arrayEquals(u.chunk, chunk)
)
) {
const grid = copyGrid(o.grid);
setColorEmpty(grid, x, y);
const h = getH(chunk);
const w = o.w + chain.length;
const f = h + w;
const next = { snake, chain, chunk, grid, parent: o, h, w, f };
sortPush(openList, next, (a, b) => a.f - b.f);
closeList.push(next);
}
}
}
};
// export const getAvailableWhiteListedRoutes = (
// grid: Grid,
// snake: Snake,
// whiteList0: Point[],
// n = 3
// ) => {
// const whiteList = whiteList0.slice();
// const solutions: Snake[][] = [];
// getAvailableRoutes(grid, snake, (chain) => {
// const hx = getHeadX(chain[0]);
// const hy = getHeadY(chain[0]);
// const i = whiteList.findIndex(({ x, y }) => hx === x && hy === y);
// if (i >= 0) {
// whiteList.splice(i, 1);
// solutions.push(chain);
// if (solutions.length >= n || whiteList.length === 0) return true;
// }
// return false;
// });
// return solutions;
// };

View File

@@ -1,27 +1,21 @@
import { Grid, Color } from "./grid";
import { Grid, Color, setColor, setColorEmpty } from "./grid";
const rand = (a: number, b: number) => Math.floor(Math.random() * (b - a)) + a;
const defaultRand = (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 fillRandomGrid = (
grid: Grid,
{
colors = [1, 2, 3] as Color[],
emptyP = 2,
}: { colors?: Color[]; emptyP?: number } = {},
rand = defaultRand
) => {
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const k = rand(-emptyP, colors.length);
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;
if (k >= 0) setColor(grid, x, y, colors[k]);
else setColorEmpty(grid, x, y);
}
};

View File

@@ -0,0 +1,57 @@
import { isInsideLarge, getColor, isInside, isEmpty } from "./grid";
import { around4 } from "./point";
import {
getHeadX,
getHeadY,
nextSnake,
snakeEquals,
snakeWillSelfCollide,
} from "./snake";
import { sortPush } from "./utils/sortPush";
import type { Snake } from "./snake";
import type { Grid, Color } from "./grid";
/**
* get routes leading to non-empty cells until onSolution returns true
*/
export const getAvailableRoutes = (
grid: Grid,
snake0: Snake,
onSolution: (snakes: Snake[], color: Color) => boolean
) => {
const openList: Snake[][] = [[snake0]];
const closeList: Snake[] = [];
while (openList.length) {
const c = openList.shift()!;
const [snake] = c;
const cx = getHeadX(snake);
const cy = getHeadY(snake);
for (let i = 0; i < around4.length; i++) {
const { x: dx, y: dy } = around4[i];
const nx = cx + dx;
const ny = cy + dy;
if (
isInsideLarge(grid, 1, nx, ny) &&
!snakeWillSelfCollide(snake, dx, dy)
) {
const nsnake = nextSnake(snake, dx, dy);
if (!closeList.some((s) => snakeEquals(nsnake, s))) {
const color = isInside(grid, nx, ny) && getColor(grid, nx, ny);
if (!color || isEmpty(color)) {
sortPush(openList, [nsnake, ...c], (a, b) => a.length - b.length);
closeList.push(nsnake);
} else {
if (onSolution([nsnake, ...c.slice(0, -1)], color)) return;
}
}
}
}
}
};

View File

@@ -0,0 +1,22 @@
import { copyGrid, extractColors } from "./grid";
import type { Snake } from "./snake";
import type { Grid } from "./grid";
import { pruneLayer } from "./pruneLayer";
import { cleanLayer } from "./cleanLayer";
export const getBestRoute = (grid0: Grid, snake0: Snake) => {
const grid = copyGrid(grid0);
const colors = extractColors(grid0);
const snakeN = snake0.length / 2;
const chain: Snake[] = [snake0];
for (const color of colors) {
const gridN = copyGrid(grid);
const chunk = pruneLayer(grid, color, snakeN);
const c = cleanLayer(gridN, chain[0], chunk);
if (c) chain.unshift(...c);
}
return chain.reverse().slice(1);
};

View File

@@ -1,9 +1,10 @@
export type Color = number;
export type Color = (1 | 2 | 3 | 4 | 5 | 6) & { _tag: "__Color__" };
export type Empty = 0 & { _tag: "__Empty__" };
export type Grid = {
width: number;
height: number;
data: (Color | null)[];
data: Uint8Array;
};
export const getIndex = (grid: Grid, x: number, y: number) =>
@@ -15,16 +16,86 @@ export const isInside = (grid: Grid, x: number, y: number) =>
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 = ({ width, height, data }: Grid) => ({
width,
height,
data: Uint8Array.from(data),
});
export const copyGrid = (grid: Grid) => ({ ...grid, data: grid.data.slice() });
export const getColor = (grid: Grid, x: number, y: number) =>
grid.data[getIndex(grid, x, y)] as Color | Empty;
export const isEmpty = (color: Color | Empty): color is Empty => color === 0;
export const setColor = (
grid: Grid,
x: number,
y: number,
color: Color | null
color: Color | Empty
) => {
grid.data[getIndex(grid, x, y)] = color;
grid.data[getIndex(grid, x, y)] = color || 0;
};
export const setColorEmpty = (grid: Grid, x: number, y: number) => {
setColor(grid, x, y, 0 as Empty);
};
/**
* return true if the grid is empty
*/
export const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === 0);
/**
* extract colors
* return a list of the colors found in the grid
*/
export const extractColors = (grid: Grid): Color[] => {
const colors = new Set<Color>();
grid.data.forEach((c: any) => {
if (!isEmpty(c)) colors.add(c);
});
return Array.from(colors.keys()).sort();
};
/**
* extract colors count
* return a list of the colors and their occurrences found in the grid
*/
export const extractColorCount = (grid: Grid) => {
const colors = new Map<Color, number>();
grid.data.forEach((c: any) => {
if (!isEmpty(c)) colors.set(c, 1 + (colors.get(c) || 0));
});
return Array.from(colors.entries()).map(([color, count]) => ({
color,
count,
}));
};
/**
* return true if the both are equals
*/
export const gridEquals = (a: Grid, b: Grid) =>
a.data.every((_, i) => a.data[i] === b.data[i]);
/**
* return a unique string for the grid
*/
export const getGridKey = ({ data }: Grid) => {
let key = "";
const n = 5;
const radius = 1 << n;
for (let k = 0; k < data.length; k += n) {
let u = 0;
for (let i = n; i--; ) u += (1 << i) * +!!data[k + i];
key += u.toString(radius);
}
return key;
};
export const createEmptyGrid = (width: number, height: number) => ({
width,
height,
data: new Uint8Array(width * height),
});

View File

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

View File

@@ -1,4 +1,11 @@
{
"name": "@snk/compute",
"version": "1.0.0"
"version": "1.0.0",
"devDependencies": {
"@zeit/ncc": "0.22.3",
"park-miller": "1.1.0"
},
"scripts": {
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
}
}

View File

@@ -5,4 +5,6 @@ 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;

View File

@@ -0,0 +1,85 @@
import { getColor, isEmpty, isInside, setColorEmpty } from "./grid";
import { around4 } from "./point";
import { sortPush } from "./utils/sortPush";
import type { Color, Grid } from "./grid";
import type { Point } from "./point";
type M = Point & { parent: M | null; h: number };
const unwrap = (grid: Grid, m: M | null): Point[] =>
m ? [...unwrap(grid, m.parent), m] : [];
const getEscapePath = (
grid: Grid,
x: number,
y: number,
color: Color,
forbidden: Point[] = []
) => {
const openList: M[] = [{ x, y, h: 0, parent: null }];
const closeList: Point[] = [];
while (openList.length) {
const c = openList.shift()!;
if (c.y === -1 || c.y === grid.height) return unwrap(grid, c);
for (const a of around4) {
const x = c.x + a.x;
const y = c.y + a.y;
if (!forbidden.some((cl) => cl.x === x && cl.y === y)) {
if (!isInside(grid, x, y))
return unwrap(grid, { x, y, parent: c } as any);
const u = getColor(grid, x, y);
if (
(isEmpty(u) || u <= color) &&
!closeList.some((cl) => cl.x === x && cl.y === y)
) {
const h = Math.abs(grid.height / 2 - y);
const o = { x, y, parent: c, h };
sortPush(openList, o, (a, b) => a.h - b.h);
closeList.push(o);
openList.push(o);
}
}
}
}
return null;
};
const isFree = (
grid: Grid,
x: number,
y: number,
color: Color,
snakeN: number
) => {
const one = getEscapePath(grid, x, y, color);
if (!one) return false;
const two = getEscapePath(grid, x, y, color, one.slice(0, snakeN));
return !!two;
};
export const pruneLayer = (grid: Grid, color: Color, snakeN: number) => {
const chunk: Point[] = [];
for (let x = grid.width; x--; )
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
if (!isEmpty(c) && c <= color && isFree(grid, x, y, color, snakeN)) {
setColorEmpty(grid, x, y);
chunk.push({ x, y });
}
}
return chunk;
};

View File

@@ -1,24 +1,46 @@
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;
export type Snake = Uint8Array & { _tag: "__Snake__" };
for (let i = 0; i < Math.min(options.maxSnakeLength, snake.length); i++)
if (snake[i].x === hx && snake[i].y === hy) return true;
export const getHeadX = (snake: Snake) => snake[0] - 2;
export const getHeadY = (snake: Snake) => snake[1] - 2;
export const snakeEquals = (a: Snake, b: Snake) => {
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
};
export const nextSnake = (snake: Snake, dx: number, dy: number) => {
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 as Snake;
};
export const snakeWillSelfCollide = (snake: Snake, dx: number, dy: number) => {
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;
};
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;
export const snakeToCells = (snake: Snake) =>
Array.from({ length: snake.length / 2 }, (_, i) => ({
x: snake[i * 2 + 0] - 2,
y: snake[i * 2 + 1] - 2,
}));
return false;
export const createSnake = (points: Point[]) => {
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 as Snake;
};
export const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
export const copySnake = (snake: Snake) => snake.slice() as Snake;

View File

@@ -1,48 +1,20 @@
import { Grid, Color, getColor, isInside, setColor } from "./grid";
import { Point } from "./point";
import {
Color,
getColor,
Grid,
isEmpty,
isInside,
setColorEmpty,
} from "./grid";
import { getHeadX, getHeadY, Snake } from "./snake";
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 step = (grid: Grid, stack: Color[], snake: Snake) => {
const x = getHeadX(snake);
const y = getHeadY(snake);
const color = getColor(grid, x, y);
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 });
if (isInside(grid, x, y) && !isEmpty(color)) {
stack.push(color);
setColorEmpty(grid, x, y);
}
};
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);
};

View File

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

View File

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

59
packages/demo/canvas.ts Normal file
View File

@@ -0,0 +1,59 @@
import { Color, Grid } from "@snk/compute/grid";
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
import { Snake } from "@snk/compute/snake";
export const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
sizeDot: 12,
colorBorder: "#1b1f230a",
colorDots: {
1: "#9be9a8",
2: "#40c463",
3: "#30a14e",
4: "#216e39",
5: "orange",
},
colorEmpty: "#ebedf0",
colorSnake: "purple",
};
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";
document.body.appendChild(canvas);
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);
};
return { draw, drawLerp, canvas, ctx };
};

View File

@@ -0,0 +1,62 @@
import { createCanvas } from "./canvas";
import { snakeToCells } from "@snk/compute/snake";
import { GUI } from "dat.gui";
import { grid, snake } from "./sample";
import { getAvailableRoutes } from "@snk/compute/getAvailableRoutes";
import type { Point } from "@snk/compute/point";
import type { Snake } from "@snk/compute/snake";
//
// compute
const routes: Snake[][] = [];
getAvailableRoutes(grid, snake, (chain) => {
routes.push(chain);
return routes.length > 10;
});
const config = { routeN: 0, routeK: 0 };
//
// draw
const { canvas, ctx, draw } = createCanvas(grid);
document.body.appendChild(canvas);
draw(grid, snake, []);
let cancel: number;
const mod = (x: number, m: number) => ((x % m) + m) % m;
const onChange = () => {
const t = Math.floor(Date.now() / 300);
cancelAnimationFrame(cancel);
cancel = requestAnimationFrame(onChange);
const chain = routes[config.routeN] || [snake];
draw(grid, chain[mod(-t, chain.length)], []);
const cells: Point[] = [];
chain.forEach((s) => cells.push(...snakeToCells(s)));
ctx.fillStyle = "orange";
ctx.fillRect(0, 0, 1, 1);
cells
.filter((x, i, arr) => i === arr.indexOf(x))
.forEach((c) => {
ctx.beginPath();
ctx.fillRect((1 + c.x + 0.5) * 16 - 2, (2 + c.y + 0.5) * 16 - 2, 4, 4);
});
};
//
// ui
const gui = new GUI();
gui.add(config, "routeN", 0, routes.length - 1, 1).onChange(onChange);
onChange();

View File

@@ -0,0 +1,57 @@
import { createCanvas } from "./canvas";
import { getBestRoute } from "@snk/compute/getBestRoute";
import { Color, copyGrid } from "../compute/grid";
import { grid, snake } from "./sample";
import { step } from "@snk/compute/step";
import { isStableAndBound, stepSpring } from "./springUtils";
const chain = [snake, ...getBestRoute(grid, snake)!];
//
// draw
const spring = { x: 0, v: 0, target: 0 };
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
let animationFrame: number;
const { canvas, drawLerp } = createCanvas(grid);
document.body.appendChild(canvas);
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
const loop = () => {
cancelAnimationFrame(animationFrame);
stepSpring(spring, springParams, spring.target);
const stable = isStableAndBound(spring, spring.target);
const grid0 = copyGrid(grid);
const stack0: Color[] = [];
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
step(grid0, stack0, chain[i]);
const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)];
const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)];
const k = spring.x % 1;
drawLerp(grid0, snake0, snake1, stack0, k);
if (!stable) animationFrame = requestAnimationFrame(loop);
};
loop();
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 = "90%";
input.addEventListener("input", () => {
spring.target = +input.value;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(loop);
});
document.body.append(input);
document.body.addEventListener("click", () => input.focus());

View File

@@ -0,0 +1,21 @@
import * as grid from "@snk/compute/__fixtures__/grid";
const container = document.createElement("div");
container.style.fontFamily = "helvetica";
document.body.appendChild(container);
for (const demo of require("./demo.json").filter((x: any) => x !== "index")) {
const title = document.createElement("h1");
title.innerText = demo;
container.appendChild(title);
for (const g of Object.keys(grid)) {
const a = document.createElement("a");
a.style.display = "block";
a.innerText = `${demo} - ${g}`;
a.href = `./${demo}.html?grid=${g}`;
container.appendChild(a);
}
}

1
packages/demo/demo.json Normal file
View File

@@ -0,0 +1 @@
["index", "getAvailableRoutes", "getBestRoute", "pruneLayer"]

View File

@@ -0,0 +1,48 @@
import { createCanvas } from "./canvas";
import { Color, copyGrid } from "../compute/grid";
import { grid, snake } from "./sample";
import { pruneLayer } from "@snk/compute/pruneLayer";
const colors = [1, 2, 3] as Color[];
const snakeN = snake.length / 2;
const layers = [{ grid, chunk: [] as { x: number; y: number }[] }];
let grid0 = copyGrid(grid);
for (const color of colors) {
const chunk = pruneLayer(grid0, color, snakeN);
layers.push({ chunk, grid: copyGrid(grid0) });
}
const { canvas, ctx, draw } = createCanvas(grid);
document.body.appendChild(canvas);
let k = 0;
const loop = () => {
const { grid, chunk } = layers[k];
draw(grid, snake, []);
ctx.fillStyle = "orange";
chunk.forEach(({ x, y }) => {
ctx.beginPath();
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
});
};
loop();
const input = document.createElement("input") as any;
input.type = "range";
input.value = 0;
input.step = 1;
input.min = 0;
input.max = layers.length - 1;
input.style.width = "90%";
input.addEventListener("input", () => {
k = +input.value;
loop();
});
document.body.append(input);
document.body.addEventListener("click", () => input.focus());

View File

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

View File

@@ -2,18 +2,22 @@
"name": "@snk/demo",
"version": "1.0.0",
"dependencies": {
"@snk/compute": "1.0.0"
"@snk/compute": "1.0.0",
"@snk/draw": "1.0.0"
},
"devDependencies": {
"webpack": "4.43.0",
"@types/dat.gui": "0.7.5",
"@types/webpack": "4.41.22",
"dat.gui": "0.7.7",
"html-webpack-plugin": "4.5.0",
"ts-loader": "8.0.4",
"ts-node": "9.0.0",
"webpack": "4.44.2",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.0",
"ts-loader": "8.0.1",
"html-webpack-plugin": "4.3.0"
"webpack-dev-server": "3.11.0"
},
"scripts": {
"prepare": "tsc webpack.config.ts",
"build": "yarn prepare ; webpack",
"dev": "yarn prepare ; webpack-dev-server --port ${PORT-3000}"
"build": "webpack",
"dev": "webpack-dev-server --port ${PORT-3000}"
}
}

14
packages/demo/sample.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Grid } from "@snk/compute/grid";
import { Snake } from "@snk/compute/snake";
import * as grids from "@snk/compute/__fixtures__/grid";
import * as snakes from "@snk/compute/__fixtures__/snake";
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

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

View File

@@ -1,16 +1,20 @@
import * as path from "path";
// @ts-ignore
import * as HtmlWebpackPlugin from "html-webpack-plugin";
import type { Configuration } from "webpack";
const basePathname = (process.env.BASE_PATHNAME || "")
.split("/")
.filter(Boolean);
const demos: string[] = require("./demo.json");
const config: Configuration = {
mode: "development",
entry: "./index",
entry: Object.fromEntries(
demos.map((demo: string) => [demo, `./demo.${demo}`])
),
resolve: { extensions: [".ts", ".js"] },
output: {
path: path.join(__dirname, "dist"),
@@ -21,27 +25,31 @@ const config: Configuration = {
rules: [
{
exclude: /node_modules/,
test: /\.(js|ts)$/,
test: /\.ts$/,
loader: "ts-loader",
options: {
compilerOptions: {
lib: ["dom", "es2020"],
target: "es2020",
module: "es2020",
moduleResolution: "node",
},
},
},
],
},
plugins: [
// game
new HtmlWebpackPlugin({
title: "demo",
filename: "index.html",
meta: {
viewport: "width=device-width, initial-scale=1, shrink-to-fit=no",
},
}),
...demos.map(
(demo) =>
new HtmlWebpackPlugin({
filename: `${demo}.html`,
chunks: [demo],
})
),
],
devtool: false,
stats: "errors-only",
// @ts-ignore
devServer: {},
};
export default config;

View File

@@ -0,0 +1,86 @@
import { Color } from "@snk/compute/grid";
import { pathRoundedRect } from "./pathRoundedRect";
import { Point } from "@snk/compute/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

@@ -19,7 +19,7 @@ export const drawGrid = (
for (let y = grid.height; y--; ) {
const c = getColor(grid, x, y);
// @ts-ignore
const color = c === null ? o.colorEmpty : o.colorDots[c];
const color = !c ? o.colorEmpty : o.colorDots[c];
ctx.save();
ctx.translate(
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,

View File

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

View File

@@ -0,0 +1,38 @@
import { performance } from "perf_hooks";
import { createSnake, nextSnake } from "@snk/compute/snake";
import { realistic as grid } from "@snk/compute/__fixtures__/grid";
import { createGif } from "..";
let snake = createSnake(Array.from({ length: 6 }, (_, 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 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 };
(async () => {
const m = 3;
const s = performance.now();
for (let k = m; k--; )
await createGif(grid, chain.slice(0, 50), drawOptions, gifOptions);
console.log((performance.now() - s) / m, "ms");
})();

View File

@@ -1,6 +1,12 @@
import * as fs from "fs";
import * as path from "path";
import { createGif } from "..";
import { generateGrid } from "@snk/compute/generateGrid";
import { computeBestRun } from "@snk/compute";
import * as grids from "@snk/compute/__fixtures__/grid";
import { snake3 as snake } from "@snk/compute/__fixtures__/snake";
import { createSnake, nextSnake } from "@snk/compute/snake";
import { getBestRoute } from "@snk/compute/getBestRoute";
jest.setTimeout(20 * 1000);
const drawOptions = {
sizeBorderRadius: 2,
@@ -12,31 +18,52 @@ const drawOptions = {
colorSnake: "purple",
};
const gameOptions = { maxSnakeLength: 5 };
const gifOptions = { frameDuration: 200, step: 1 };
const gifOptions = { delay: 200 };
const dir = path.resolve(__dirname, "__snapshots__");
it("should generate gif", async () => {
const grid = generateGrid(14, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
try {
fs.mkdirSync(dir);
} catch (err) {}
const snake = [
{ x: 4, y: -1 },
{ x: 3, y: -1 },
{ x: 2, y: -1 },
{ x: 1, y: -1 },
{ x: 0, y: -1 },
];
for (const key of [
"empty",
"simple",
"corner",
"small",
"smallPacked",
"enclave",
] as const)
it(`should generate ${key} gif`, async () => {
const grid = grids[key];
const commands = computeBestRun(grid, snake, gameOptions).slice(0, 9);
const chain = [snake, ...getBestRoute(grid, snake)!];
const gif = await createGif(
grid,
snake,
commands,
drawOptions,
gameOptions,
gifOptions
);
const gif = await createGif(grid, chain, drawOptions, gifOptions);
expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
});
it(`should generate swipper`, async () => {
const grid = grids.smallFull;
let snk = createSnake(Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 })));
const chain = [snk];
for (let y = -1; y < grid.height; y++) {
snk = nextSnake(snk, 0, 1);
chain.push(snk);
for (let x = grid.width - 1; x--; ) {
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
chain.push(snk);
}
}
const gif = await createGif(grid, chain, drawOptions, gifOptions);
expect(gif).toBeDefined();
fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
});

View File

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

View File

@@ -2,58 +2,73 @@ import * as fs from "fs";
import * as path from "path";
import { createCanvas } from "canvas";
import { Grid, copyGrid, Color } from "@snk/compute/grid";
import { Point } from "@snk/compute/point";
import { copySnake } from "@snk/compute/snake";
import { drawWorld } from "@snk/draw/drawWorld";
import { Snake } from "@snk/compute/snake";
import { Options, drawLerpWorld } from "@snk/draw/drawWorld";
import { step } from "@snk/compute/step";
import * as tmp from "tmp";
// @ts-ignore
import * as execa from "execa";
export const createGif = async (
grid0: Grid,
snake0: Point[],
commands: Point[],
drawOptions: Parameters<typeof drawWorld>[4],
gameOptions: Parameters<typeof step>[4],
gifOptions: { delay: number }
) => {
const grid = copyGrid(grid0);
const snake = copySnake(snake0);
const stack: Color[] = [];
const width = drawOptions.sizeCell * (grid.width + 4);
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
const withTmpDir = async <T>(
handler: (dir: string) => Promise<T>
): Promise<T> => {
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
unsafeCleanup: true,
});
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!;
const writeImage = (i: number) => {
ctx.clearRect(0, 0, 99999, 99999);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 99999, 99999);
drawWorld(ctx, grid, snake, stack, drawOptions);
const buffer = canvas.toBuffer("image/png", {
compressionLevel: 0,
filters: canvas.PNG_FILTER_NONE,
});
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
fs.writeFileSync(fileName, buffer);
};
try {
writeImage(0);
return await handler(dir);
} finally {
cleanUp();
}
};
for (let i = 0; i < commands.length; i++) {
step(grid, snake, stack, commands[i], gameOptions);
writeImage(i + 1);
export const createGif = async (
grid0: Grid,
chain: Snake[],
drawOptions: Options,
gifOptions: { frameDuration: number; step: number }
) =>
withTmpDir(async (dir) => {
const width = drawOptions.sizeCell * (grid0.width + 2);
const height = drawOptions.sizeCell * (grid0.height + 4) + 100;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!;
const grid = copyGrid(grid0);
const stack: Color[] = [];
for (let i = 0; i < chain.length; i += 1) {
const snake0 = chain[i];
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
step(grid, stack, snake0);
for (let k = 0; k < gifOptions.step; k++) {
ctx.clearRect(0, 0, 99999, 99999);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 99999, 99999);
drawLerpWorld(
ctx,
grid,
snake0,
snake1,
stack,
k / gifOptions.step,
drawOptions
);
const buffer = canvas.toBuffer("image/png", {
compressionLevel: 0,
filters: canvas.PNG_FILTER_NONE,
});
const fileName = path.join(
dir,
`${(i * gifOptions.step + k).toString().padStart(4, "0")}.png`
);
fs.writeFileSync(fileName, buffer);
}
}
const outFileName = path.join(dir, "out.gif");
@@ -64,7 +79,7 @@ export const createGif = async (
[
"convert",
["-loop", "0"],
["-delay", gifOptions.delay.toString()],
["-delay", (gifOptions.frameDuration / 10).toString()],
["-dispose", "2"],
// ["-layers", "OptimizeFrame"],
["-compress", "LZW"],
@@ -86,7 +101,4 @@ export const createGif = async (
);
return fs.readFileSync(optimizedFileName);
} finally {
cleanUp();
}
};
});

View File

@@ -14,6 +14,6 @@
"@zeit/ncc": "0.22.3"
},
"scripts": {
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
}
}

View File

@@ -3,7 +3,7 @@ import { getGithubUserContribution } from "..";
it("should get user contribution", async () => {
const { cells, colorScheme } = await getGithubUserContribution("platane");
expect(cells).toBeDefined();
expect(cells.length).toBeGreaterThan(300);
expect(colorScheme).toEqual([
"#ebedf0",
"#9be9a8",

View File

@@ -1,26 +1,11 @@
// import * as https from "https";
// @ts-ignore
// import * as cheerio from "cheerio";
import { JSDOM } from "jsdom";
/**
* get the contribution grid from a github user page
*
* @param userName
*/
export const getGithubUserContribution = async (userName: string) => {
// const content: string = await new Promise((resolve, reject) => {
// const req = https.request(`https://github.com/${userName}`, (res) => {
// let data = "";
// res.on("error", reject);
// res.on("data", (chunk) => (data += chunk));
// res.on("end", () => resolve(data));
// });
// req.on("error", reject);
// req.end();
// });
// const dom = new JSDOM(content);
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
const colorScheme = Array.from(
@@ -34,14 +19,14 @@ export const getGithubUserContribution = async (userName: string) => {
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
)
.map((column, x) =>
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
x,
y,
count: element.getAttribute("data-count"),
date: element.getAttribute("data-date"),
color: element.getAttribute("fill"),
k: colorScheme.indexOf(element.getAttribute("fill")!),
}))
Array.from(column.querySelectorAll("rect")).map((element, y) => {
const count = +element.getAttribute("data-count")!;
const date = element.getAttribute("data-date")!;
const color = element.getAttribute("fill")!;
const k = colorScheme.indexOf(color);
return { x, y, count, date, color, k };
})
)
.flat();
@@ -53,9 +38,3 @@ type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
export type Cell = ThenArg<
ReturnType<typeof getGithubUserContribution>
>["cells"][number];
// "#ebedf0";
// "#9be9a8";
// "#40c463";
// "#30a14e";
// "#216e39";

View File

@@ -2,9 +2,9 @@
"name": "@snk/github-user-contribution",
"version": "1.0.0",
"dependencies": {
"jsdom": "16.3.0"
"jsdom": "16.4.0"
},
"devDependencies": {
"@types/jsdom": "16.2.3"
"@types/jsdom": "16.2.4"
}
}

1346
yarn.lock

File diff suppressed because it is too large Load Diff