├── .nvmrc ├── packages ├── gif-creator │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── .gitignore │ │ ├── createGif.spec.ts │ │ └── benchmark.ts │ ├── README.md │ ├── package.json │ └── index.ts ├── svg-creator │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── .gitignore │ │ ├── minifyCss.spec.ts │ │ └── createSvg.spec.ts │ ├── package.json │ ├── README.md │ ├── xml-utils.ts │ ├── css-utils.ts │ ├── grid.ts │ ├── stack.ts │ ├── snake.ts │ └── index.ts ├── action │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── .gitignore │ │ │ └── outputsOptions.spec.ts.snap │ │ ├── generateContributionSnake.spec.ts │ │ └── outputsOptions.spec.ts │ ├── README.md │ ├── package.json │ ├── userContributionToGrid.ts │ ├── palettes.ts │ ├── index.ts │ ├── generateContributionSnake.ts │ └── outputsOptions.ts ├── draw │ ├── README.md │ ├── package.json │ ├── pathRoundedRect.ts │ ├── drawGrid.ts │ ├── drawSnake.ts │ ├── drawCircleStack.ts │ └── drawWorld.ts ├── types │ ├── README.md │ ├── package.json │ ├── point.ts │ ├── __fixtures__ │ │ ├── snake.ts │ │ ├── createFromSeed.ts │ │ ├── createFromAscii.ts │ │ └── grid.ts │ ├── __tests__ │ │ ├── grid.spec.ts │ │ └── snake.spec.ts │ ├── randomlyFillGrid.ts │ ├── grid.ts │ └── snake.ts ├── github-user-contribution-service │ ├── vercel.json │ ├── README.md │ ├── package.json │ └── api │ │ └── github-user-contribution │ │ └── [userName].ts ├── demo │ ├── README.md │ ├── demo.json │ ├── sample.ts │ ├── demo.interactive.worker.ts │ ├── package.json │ ├── demo.svg.ts │ ├── menu.ts │ ├── demo.getBestRoute.ts │ ├── demo.getPathToPose.ts │ ├── demo.outside.ts │ ├── demo.getPathTo.ts │ ├── springUtils.ts │ ├── worker-utils.ts │ ├── demo.getBestTunnel.ts │ ├── webpack.config.ts │ ├── canvas.ts │ └── demo.interactive.ts ├── solver │ ├── utils │ │ ├── array.ts │ │ └── sortPush.ts │ ├── package.json │ ├── __tests__ │ │ ├── getPathTo.spec.ts │ │ ├── getPathToPose.spec.ts │ │ ├── getBestRoute.spec.ts │ │ ├── getBestRoute-fuzz.spec.ts │ │ └── sortPush.spec.ts │ ├── step.ts │ ├── getBestRoute.ts │ ├── outside.ts │ ├── README.md │ ├── getPathTo.ts │ ├── tunnel.ts │ ├── getPathToPose.ts │ ├── getBestTunnel.ts │ ├── clearCleanColoredLayer.ts │ └── clearResidualColoredLayer.ts └── github-user-contribution │ ├── package.json │ ├── README.md │ ├── __tests__ │ └── getGithubUserContribution.spec.ts │ └── index.ts ├── .gitignore ├── svg-only ├── README.md ├── action.yml └── dist │ ├── 142.index.js │ ├── 340.index.js │ └── 407.index.js ├── tsconfig.json ├── Dockerfile ├── package.json ├── action.yml ├── .github └── workflows │ ├── manual-run.yml │ ├── release.yml │ └── main.yml └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /packages/gif-creator/__tests__/__snapshots__/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /packages/svg-creator/__tests__/__snapshots__/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /packages/action/__tests__/__snapshots__/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !*.snap -------------------------------------------------------------------------------- /packages/draw/README.md: -------------------------------------------------------------------------------- 1 | # @snk/draw 2 | 3 | Draw grids and snakes on a canvas. 4 | -------------------------------------------------------------------------------- /packages/types/README.md: -------------------------------------------------------------------------------- 1 | # @snk/types 2 | 3 | set of basic types and helpers 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | yarn-error.log* 4 | dist 5 | !svg-only/dist 6 | build 7 | .env -------------------------------------------------------------------------------- /packages/github-user-contribution-service/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/demo/README.md: -------------------------------------------------------------------------------- 1 | # @snk/demo 2 | 3 | Contains various demo to test and validate some pieces of the algorithm. 4 | -------------------------------------------------------------------------------- /packages/solver/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const arrayEquals = (a: T[], b: T[]) => 2 | a.length === b.length && a.every((_, i) => a[i] === b[i]); 3 | -------------------------------------------------------------------------------- /packages/draw/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/draw", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@snk/solver": "1.0.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/solver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/solver", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "park-miller": "1.1.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/types", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "park-miller": "1.1.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/github-user-contribution-service/README.md: -------------------------------------------------------------------------------- 1 | # @snk/github-user-contribution-service 2 | 3 | Expose github-user-contribution as an endpoint, using vercel.sh 4 | -------------------------------------------------------------------------------- /packages/gif-creator/README.md: -------------------------------------------------------------------------------- 1 | # @snk/gif-creator 2 | 3 | Generate a gif file from the grid and snake path. 4 | 5 | Relies on graphics magic and gifsicle binaries. 6 | -------------------------------------------------------------------------------- /packages/demo/demo.json: -------------------------------------------------------------------------------- 1 | [ 2 | "interactive", 3 | "getBestRoute", 4 | "getBestTunnel", 5 | "outside", 6 | "getPathToPose", 7 | "getPathTo", 8 | "svg" 9 | ] 10 | -------------------------------------------------------------------------------- /packages/github-user-contribution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/github-user-contribution", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "dotenv": "16.4.5" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/svg-creator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/svg-creator", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@snk/solver": "1.0.0" 6 | }, 7 | "devDependencies": {} 8 | } 9 | -------------------------------------------------------------------------------- /packages/github-user-contribution-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/github-user-contribution-service", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@snk/github-user-contribution": "1.0.0", 6 | "@vercel/node": "3.2.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /svg-only/README.md: -------------------------------------------------------------------------------- 1 | # svg-only 2 | 3 | Another action running purely on js (without Docker). 4 | 5 | As a drawback, it can not generate gif image. 6 | 7 | ## Build process 8 | 9 | dist file are built and push on release, by the release action. 10 | -------------------------------------------------------------------------------- /packages/svg-creator/README.md: -------------------------------------------------------------------------------- 1 | # @snk/svg-creator 2 | 3 | Generate a svg file from the grid and snake path. 4 | 5 | Use css style tag to animate the snake and the grid cells. For that reason it only work in browser. Animations are likely to be ignored be native image reader. 6 | -------------------------------------------------------------------------------- /packages/types/point.ts: -------------------------------------------------------------------------------- 1 | export type Point = { x: number; y: number }; 2 | 3 | export const around4 = [ 4 | { x: 1, y: 0 }, 5 | { x: 0, y: -1 }, 6 | { x: -1, y: 0 }, 7 | { x: 0, y: 1 }, 8 | ] as const; 9 | 10 | export const pointEquals = (a: Point, b: Point) => a.x === b.x && a.y === b.y; 11 | -------------------------------------------------------------------------------- /packages/svg-creator/xml-utils.ts: -------------------------------------------------------------------------------- 1 | export const h = (element: string, attributes: any) => 2 | `<${element} ${toAttribute(attributes)}/>`; 3 | 4 | export const toAttribute = (o: any) => 5 | Object.entries(o) 6 | .filter(([, value]) => value !== null) 7 | .map(([name, value]) => `${name}="${value}"`) 8 | .join(" "); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "ES2020"], 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node" 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/types/__fixtures__/snake.ts: -------------------------------------------------------------------------------- 1 | import { createSnakeFromCells } from "../snake"; 2 | 3 | const create = (length: number) => 4 | createSnakeFromCells(Array.from({ length }, (_, i) => ({ x: i, y: -1 }))); 5 | 6 | export const snake1 = create(1); 7 | export const snake3 = create(3); 8 | export const snake4 = create(4); 9 | export const snake5 = create(5); 10 | export const snake9 = create(9); 11 | -------------------------------------------------------------------------------- /packages/draw/pathRoundedRect.ts: -------------------------------------------------------------------------------- 1 | export const pathRoundedRect = ( 2 | ctx: CanvasRenderingContext2D, 3 | width: number, 4 | height: number, 5 | borderRadius: number 6 | ) => { 7 | ctx.moveTo(borderRadius, 0); 8 | ctx.arcTo(width, 0, width, height, borderRadius); 9 | ctx.arcTo(width, height, 0, height, borderRadius); 10 | ctx.arcTo(0, height, 0, 0, borderRadius); 11 | ctx.arcTo(0, 0, width, 0, borderRadius); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/action/README.md: -------------------------------------------------------------------------------- 1 | # @snk/action 2 | 3 | Contains the github action code. 4 | 5 | ## Implementation 6 | 7 | ### Docker 8 | 9 | Because the gif generation requires some native libs, we cannot use a node.js action. 10 | 11 | Use a docker action instead, the image is created from the [Dockerfile](../../Dockerfile). 12 | 13 | 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 ) 14 | -------------------------------------------------------------------------------- /packages/solver/__tests__/getPathTo.spec.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyGrid } from "@snk/types/grid"; 2 | import { getHeadX, getHeadY } from "@snk/types/snake"; 3 | import { snake3 } from "@snk/types/__fixtures__/snake"; 4 | import { getPathTo } from "../getPathTo"; 5 | 6 | it("should find it's way in vaccum", () => { 7 | const grid = createEmptyGrid(5, 0); 8 | 9 | const path = getPathTo(grid, snake3, 5, -1)!; 10 | 11 | expect([getHeadX(path[0]), getHeadY(path[0])]).toEqual([5, -1]); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/types/__fixtures__/createFromSeed.ts: -------------------------------------------------------------------------------- 1 | import ParkMiller from "park-miller"; 2 | import { Color, createEmptyGrid } from "../grid"; 3 | import { randomlyFillGrid } from "../randomlyFillGrid"; 4 | 5 | export const createFromSeed = (seed: number, width = 5, height = 5) => { 6 | const grid = createEmptyGrid(width, height); 7 | const pm = new ParkMiller(seed); 8 | const random = pm.integerInRange.bind(pm); 9 | randomlyFillGrid(grid, { colors: [1, 2] as Color[], emptyP: 2 }, random); 10 | return grid; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/gif-creator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/gif-creator", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@snk/draw": "1.0.0", 6 | "@snk/solver": "1.0.0", 7 | "canvas": "2.11.2", 8 | "gif-encoder-2": "1.0.5", 9 | "gifsicle": "5.3.0", 10 | "tmp": "0.2.3" 11 | }, 12 | "devDependencies": { 13 | "@types/gifsicle": "5.2.2", 14 | "@types/tmp": "0.2.6", 15 | "@vercel/ncc": "0.38.1" 16 | }, 17 | "scripts": { 18 | "benchmark": "ncc run __tests__/benchmark.ts --quiet" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/solver/step.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | getColor, 4 | Grid, 5 | isEmpty, 6 | isInside, 7 | setColorEmpty, 8 | } from "@snk/types/grid"; 9 | import { getHeadX, getHeadY, Snake } from "@snk/types/snake"; 10 | 11 | export const step = (grid: Grid, stack: Color[], snake: Snake) => { 12 | const x = getHeadX(snake); 13 | const y = getHeadY(snake); 14 | const color = getColor(grid, x, y); 15 | 16 | if (isInside(grid, x, y) && !isEmpty(color)) { 17 | stack.push(color); 18 | setColorEmpty(grid, x, y); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /packages/svg-creator/__tests__/minifyCss.spec.ts: -------------------------------------------------------------------------------- 1 | import { minifyCss } from "../css-utils"; 2 | 3 | it("should minify css", () => { 4 | expect( 5 | minifyCss(` 6 | .c { 7 | color : red ; 8 | } 9 | 10 | `) 11 | ).toBe(".c{color:red}"); 12 | 13 | expect( 14 | minifyCss(` 15 | .c { 16 | top : 0; 17 | color : red ; 18 | } 19 | 20 | # { 21 | animation: linear 10; 22 | } 23 | 24 | `) 25 | ).toBe(".c{top:0;color:red}#{animation:linear 10}"); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/solver/utils/sortPush.ts: -------------------------------------------------------------------------------- 1 | export const sortPush = (arr: T[], x: T, sortFn: (a: T, b: T) => number) => { 2 | let a = 0; 3 | let b = arr.length; 4 | 5 | if (arr.length === 0 || sortFn(x, arr[a]) <= 0) { 6 | arr.unshift(x); 7 | return; 8 | } 9 | 10 | while (b - a > 1) { 11 | const e = Math.ceil((a + b) / 2); 12 | 13 | const s = sortFn(x, arr[e]); 14 | 15 | if (s === 0) a = b = e; 16 | else if (s > 0) a = e; 17 | else b = e; 18 | } 19 | 20 | const e = Math.ceil((a + b) / 2); 21 | arr.splice(e, 0, x); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/demo/sample.ts: -------------------------------------------------------------------------------- 1 | import * as grids from "@snk/types/__fixtures__/grid"; 2 | import * as snakes from "@snk/types/__fixtures__/snake"; 3 | import type { Snake } from "@snk/types/snake"; 4 | import type { Grid } from "@snk/types/grid"; 5 | 6 | const sp = new URLSearchParams(window.location.search); 7 | 8 | const gLabel = sp.get("grid") || "simple"; 9 | const sLabel = sp.get("snake") || "snake3"; 10 | 11 | //@ts-ignore 12 | export const grid: Grid = grids[gLabel] || grids.simple; 13 | //@ts-ignore 14 | export const snake: Snake = snakes[sLabel] || snakes.snake3; 15 | -------------------------------------------------------------------------------- /packages/solver/__tests__/getPathToPose.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSnakeFromCells } from "@snk/types/snake"; 2 | import { getPathToPose } from "../getPathToPose"; 3 | 4 | it("should fing path to pose", () => { 5 | const snake0 = createSnakeFromCells([ 6 | { x: 0, y: 0 }, 7 | { x: 1, y: 0 }, 8 | { x: 2, y: 0 }, 9 | ]); 10 | const target = createSnakeFromCells([ 11 | { x: 1, y: 0 }, 12 | { x: 2, y: 0 }, 13 | { x: 3, y: 0 }, 14 | ]); 15 | 16 | const path = getPathToPose(snake0, target); 17 | 18 | expect(path).toBeDefined(); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/demo/demo.interactive.worker.ts: -------------------------------------------------------------------------------- 1 | import { getBestRoute } from "@snk/solver/getBestRoute"; 2 | import { getPathToPose } from "@snk/solver/getPathToPose"; 3 | import { snake4 as snake } from "@snk/types/__fixtures__/snake"; 4 | import type { Grid } from "@snk/types/grid"; 5 | import { createRpcServer } from "./worker-utils"; 6 | 7 | const getChain = (grid: Grid) => { 8 | const chain = getBestRoute(grid, snake)!; 9 | chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); 10 | 11 | return chain; 12 | }; 13 | 14 | const api = { getChain }; 15 | export type API = typeof api; 16 | 17 | createRpcServer(api); 18 | -------------------------------------------------------------------------------- /packages/types/__tests__/grid.spec.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyGrid, setColor, getColor, isInside, Color } from "../grid"; 2 | 3 | it("should set / get cell", () => { 4 | const grid = createEmptyGrid(2, 3); 5 | 6 | expect(getColor(grid, 0, 1)).toBe(0); 7 | 8 | setColor(grid, 0, 1, 1 as Color); 9 | 10 | expect(getColor(grid, 0, 1)).toBe(1); 11 | }); 12 | 13 | test.each([ 14 | [0, 1, true], 15 | [1, 2, true], 16 | 17 | [-1, 1, false], 18 | [0, -1, false], 19 | [2, 1, false], 20 | [0, 3, false], 21 | ])("isInside", (x, y, output) => { 22 | const grid = createEmptyGrid(2, 3); 23 | 24 | expect(isInside(grid, x, y)).toBe(output); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/action/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/action", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@actions/core": "1.10.1", 6 | "@snk/gif-creator": "1.0.0", 7 | "@snk/github-user-contribution": "1.0.0", 8 | "@snk/solver": "1.0.0", 9 | "@snk/svg-creator": "1.0.0", 10 | "@snk/types": "1.0.0" 11 | }, 12 | "devDependencies": { 13 | "@vercel/ncc": "0.38.1", 14 | "dotenv": "16.4.5" 15 | }, 16 | "scripts": { 17 | "build": "ncc build --external canvas --external gifsicle --out dist ./index.ts", 18 | "run:build": "INPUT_GITHUB_USER_NAME=platane INPUT_OUTPUTS='dist/out.svg' node dist/index.js" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/action/userContributionToGrid.ts: -------------------------------------------------------------------------------- 1 | import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid"; 2 | import type { Cell } from "@snk/github-user-contribution"; 3 | import type { Color } from "@snk/types/grid"; 4 | 5 | export const userContributionToGrid = (cells: Cell[]) => { 6 | const width = Math.max(0, ...cells.map((c) => c.x)) + 1; 7 | const height = Math.max(0, ...cells.map((c) => c.y)) + 1; 8 | 9 | const grid = createEmptyGrid(width, height); 10 | for (const c of cells) { 11 | if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color); 12 | else setColorEmpty(grid, c.x, c.y); 13 | } 14 | 15 | return grid; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/github-user-contribution/README.md: -------------------------------------------------------------------------------- 1 | # @snk/github-user-contribution 2 | 3 | Get the github user contribution graph 4 | 5 | ## Usage 6 | 7 | ```js 8 | const { cells, colorScheme } = await getGithubUserContribution("platane"); 9 | 10 | // colorScheme = [ 11 | // "#ebedf0", 12 | // "#9be9a8", 13 | // ... 14 | // ] 15 | // cells = [ 16 | // { 17 | // x: 3, 18 | // y: 0, 19 | // count: 3, 20 | // color: '#ebedf0', 21 | // date:'2019-01-18' 22 | // }, 23 | // ... 24 | // ] 25 | ``` 26 | 27 | ## Implementation 28 | 29 | Based on the html page. Which is very unstable. We might switch to using github api but afaik it's a bit complex. 30 | -------------------------------------------------------------------------------- /packages/types/__fixtures__/createFromAscii.ts: -------------------------------------------------------------------------------- 1 | import { Color, createEmptyGrid, setColor } from "../grid"; 2 | 3 | export const createFromAscii = (ascii: string) => { 4 | const a = ascii.split("\n"); 5 | if (a[0] === "") a.shift(); 6 | const height = a.length; 7 | const width = Math.max(...a.map((r) => r.length)); 8 | 9 | const grid = createEmptyGrid(width, height); 10 | for (let x = width; x--; ) 11 | for (let y = height; y--; ) { 12 | const c = a[y][x]; 13 | const color = 14 | (c === "#" && 3) || (c === "@" && 2) || (c === "." && 1) || +c; 15 | if (c) setColor(grid, x, y, color as Color); 16 | } 17 | 18 | return grid; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/types/randomlyFillGrid.ts: -------------------------------------------------------------------------------- 1 | import { Grid, Color, setColor, setColorEmpty } from "./grid"; 2 | 3 | const defaultRand = (a: number, b: number) => 4 | Math.floor(Math.random() * (b - a + 1)) + a; 5 | 6 | export const randomlyFillGrid = ( 7 | grid: Grid, 8 | { 9 | colors = [1, 2, 3] as Color[], 10 | emptyP = 2, 11 | }: { colors?: Color[]; emptyP?: number } = {}, 12 | rand = defaultRand 13 | ) => { 14 | for (let x = grid.width; x--; ) 15 | for (let y = grid.height; y--; ) { 16 | const k = rand(-emptyP, colors.length - 1); 17 | 18 | if (k >= 0) setColor(grid, x, y, colors[k]); 19 | else setColorEmpty(grid, x, y); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | 7 | COPY tsconfig.json ./ 8 | 9 | COPY packages packages 10 | 11 | RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \ 12 | && yarn install --frozen-lockfile \ 13 | && rm -r "$YARN_CACHE_FOLDER" 14 | 15 | RUN yarn build:action 16 | 17 | 18 | 19 | 20 | 21 | FROM node:20-slim 22 | 23 | WORKDIR /action-release 24 | 25 | RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \ 26 | && yarn add canvas@2.11.2 gifsicle@5.3.0 --no-lockfile \ 27 | && rm -r "$YARN_CACHE_FOLDER" 28 | 29 | COPY --from=builder /app/packages/action/dist/ /action-release/ 30 | 31 | CMD ["node", "/action-release/index.js"] 32 | 33 | -------------------------------------------------------------------------------- /packages/demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snk/demo", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@snk/action": "1.0.0", 6 | "@snk/draw": "1.0.0", 7 | "@snk/github-user-contribution": "1.0.0", 8 | "@snk/solver": "1.0.0", 9 | "@snk/svg-creator": "1.0.0", 10 | "@snk/types": "1.0.0" 11 | }, 12 | "devDependencies": { 13 | "dotenv": "16.4.5", 14 | "@types/dat.gui": "0.7.13", 15 | "dat.gui": "0.7.9", 16 | "html-webpack-plugin": "5.6.0", 17 | "ts-loader": "9.5.1", 18 | "ts-node": "10.9.2", 19 | "webpack": "5.92.1", 20 | "webpack-cli": "5.1.4", 21 | "webpack-dev-server": "5.0.4" 22 | }, 23 | "scripts": { 24 | "build": "webpack", 25 | "dev": "webpack serve" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/demo/demo.svg.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { getBestRoute } from "@snk/solver/getBestRoute"; 3 | import { createSvg } from "@snk/svg-creator"; 4 | import { grid, snake } from "./sample"; 5 | import { drawOptions } from "./canvas"; 6 | import { getPathToPose } from "@snk/solver/getPathToPose"; 7 | import type { AnimationOptions } from "@snk/gif-creator"; 8 | 9 | const chain = getBestRoute(grid, snake); 10 | chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); 11 | 12 | (async () => { 13 | const svg = await createSvg(grid, null, chain, drawOptions, { 14 | frameDuration: 200, 15 | } as AnimationOptions); 16 | 17 | const container = document.createElement("div"); 18 | container.innerHTML = svg; 19 | document.body.appendChild(container); 20 | })(); 21 | -------------------------------------------------------------------------------- /packages/action/palettes.ts: -------------------------------------------------------------------------------- 1 | import { DrawOptions as DrawOptions } from "@snk/svg-creator"; 2 | 3 | export const basePalettes: Record< 4 | string, 5 | Pick< 6 | DrawOptions, 7 | "colorDotBorder" | "colorEmpty" | "colorSnake" | "colorDots" | "dark" 8 | > 9 | > = { 10 | "github-light": { 11 | colorDotBorder: "#1b1f230a", 12 | colorDots: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"], 13 | colorEmpty: "#ebedf0", 14 | colorSnake: "purple", 15 | }, 16 | "github-dark": { 17 | colorDotBorder: "#1b1f230a", 18 | colorEmpty: "#161b22", 19 | colorDots: ["#161b22", "#01311f", "#034525", "#0f6d31", "#00c647"], 20 | colorSnake: "purple", 21 | }, 22 | }; 23 | 24 | // aliases 25 | export const palettes = { ...basePalettes }; 26 | palettes["github"] = palettes["github-light"]; 27 | palettes["default"] = palettes["github"]; 28 | -------------------------------------------------------------------------------- /packages/solver/__tests__/getBestRoute.spec.ts: -------------------------------------------------------------------------------- 1 | import { getBestRoute } from "../getBestRoute"; 2 | import { Color, createEmptyGrid, setColor } from "@snk/types/grid"; 3 | import { createSnakeFromCells, snakeToCells } from "@snk/types/snake"; 4 | import * as grids from "@snk/types/__fixtures__/grid"; 5 | import { snake3 } from "@snk/types/__fixtures__/snake"; 6 | 7 | it("should find best route", () => { 8 | const snk0 = [ 9 | { x: 0, y: 0 }, 10 | { x: 1, y: 0 }, 11 | ]; 12 | 13 | const grid = createEmptyGrid(5, 5); 14 | setColor(grid, 3, 3, 1 as Color); 15 | 16 | const chain = getBestRoute(grid, createSnakeFromCells(snk0))!; 17 | 18 | expect(snakeToCells(chain[1])[1]).toEqual({ x: 0, y: 0 }); 19 | 20 | expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 }); 21 | }); 22 | 23 | for (const [gridName, grid] of Object.entries(grids)) 24 | it(`should find a solution for ${gridName}`, () => { 25 | getBestRoute(grid, snake3); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/solver/getBestRoute.ts: -------------------------------------------------------------------------------- 1 | import { copyGrid } from "@snk/types/grid"; 2 | import { createOutside } from "./outside"; 3 | import { clearResidualColoredLayer } from "./clearResidualColoredLayer"; 4 | import { clearCleanColoredLayer } from "./clearCleanColoredLayer"; 5 | import type { Color, Grid } from "@snk/types/grid"; 6 | import type { Snake } from "@snk/types/snake"; 7 | 8 | export const getBestRoute = (grid0: Grid, snake0: Snake) => { 9 | const grid = copyGrid(grid0); 10 | const outside = createOutside(grid); 11 | const chain: Snake[] = [snake0]; 12 | 13 | for (const color of extractColors(grid)) { 14 | if (color > 1) 15 | chain.unshift( 16 | ...clearResidualColoredLayer(grid, outside, chain[0], color) 17 | ); 18 | chain.unshift(...clearCleanColoredLayer(grid, outside, chain[0], color)); 19 | } 20 | 21 | return chain.reverse(); 22 | }; 23 | 24 | const extractColors = (grid: Grid): Color[] => { 25 | // @ts-ignore 26 | let maxColor = Math.max(...grid.data); 27 | return Array.from({ length: maxColor }, (_, i) => (i + 1) as Color); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/github-user-contribution-service/api/github-user-contribution/[userName].ts: -------------------------------------------------------------------------------- 1 | import { getGithubUserContribution } from "@snk/github-user-contribution"; 2 | import { VercelRequest, VercelResponse } from "@vercel/node"; 3 | 4 | export default async (req: VercelRequest, res: VercelResponse) => { 5 | const { userName } = req.query; 6 | 7 | try { 8 | // handle CORS 9 | { 10 | const allowedOrigins = [ 11 | "https://platane.github.io", 12 | "https://platane.me", 13 | ]; 14 | 15 | const allowedOrigin = allowedOrigins.find( 16 | (o) => o === req.headers.origin 17 | ); 18 | if (allowedOrigin) 19 | res.setHeader("Access-Control-Allow-Origin", allowedOrigin); 20 | } 21 | res.setHeader("Cache-Control", "max-age=21600, s-maxage=21600"); 22 | res.statusCode = 200; 23 | res.json( 24 | await getGithubUserContribution(userName as string, { 25 | githubToken: process.env.GITHUB_TOKEN!, 26 | }) 27 | ); 28 | } catch (err) { 29 | console.error(err); 30 | res.statusCode = 500; 31 | res.end(); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /packages/types/__tests__/snake.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSnakeFromCells, 3 | nextSnake, 4 | snakeToCells, 5 | snakeWillSelfCollide, 6 | } from "../snake"; 7 | 8 | it("should convert to point", () => { 9 | const snk0 = [ 10 | { x: 1, y: -1 }, 11 | { x: 1, y: 0 }, 12 | { x: 0, y: 0 }, 13 | ]; 14 | 15 | expect(snakeToCells(createSnakeFromCells(snk0))).toEqual(snk0); 16 | }); 17 | 18 | it("should return next snake", () => { 19 | const snk0 = [ 20 | { x: 1, y: 1 }, 21 | { x: 1, y: 0 }, 22 | { x: 0, y: 0 }, 23 | ]; 24 | 25 | const snk1 = [ 26 | { x: 2, y: 1 }, 27 | { x: 1, y: 1 }, 28 | { x: 1, y: 0 }, 29 | ]; 30 | 31 | expect(snakeToCells(nextSnake(createSnakeFromCells(snk0), 1, 0))).toEqual( 32 | snk1 33 | ); 34 | }); 35 | 36 | it("should test snake collision", () => { 37 | const snk0 = [ 38 | { x: 1, y: 1 }, 39 | { x: 1, y: 0 }, 40 | { x: 0, y: 0 }, 41 | ]; 42 | 43 | expect(snakeWillSelfCollide(createSnakeFromCells(snk0), 1, 0)).toBe(false); 44 | expect(snakeWillSelfCollide(createSnakeFromCells(snk0), 0, -1)).toBe(true); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snk", 3 | "description": "Generates a snake game from a github user contributions grid", 4 | "version": "3.2.0", 5 | "private": true, 6 | "repository": "github:platane/snk", 7 | "devDependencies": { 8 | "@sucrase/jest-plugin": "3.0.0", 9 | "@types/jest": "29.5.12", 10 | "@types/node": "20.14.10", 11 | "jest": "29.7.0", 12 | "prettier": "2.8.8", 13 | "sucrase": "3.35.0", 14 | "typescript": "5.5.3" 15 | }, 16 | "workspaces": [ 17 | "packages/*" 18 | ], 19 | "jest": { 20 | "testEnvironment": "node", 21 | "testMatch": [ 22 | "**/__tests__/**/?(*.)+(spec|test).ts" 23 | ], 24 | "transform": { 25 | "\\.(ts|tsx)$": "@sucrase/jest-plugin" 26 | } 27 | }, 28 | "scripts": { 29 | "type": "tsc --noEmit", 30 | "lint": "prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**' '!svg-only/dist/**'", 31 | "test": "jest --verbose --no-cache", 32 | "dev:demo": "( cd packages/demo ; npm run dev )", 33 | "build:demo": "( cd packages/demo ; npm run build )", 34 | "build:action": "( cd packages/action ; npm run build )" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/demo/menu.ts: -------------------------------------------------------------------------------- 1 | import { GUI } from "dat.gui"; 2 | import * as grids from "@snk/types/__fixtures__/grid"; 3 | import * as snakes from "@snk/types/__fixtures__/snake"; 4 | import { grid, snake } from "./sample"; 5 | 6 | const demos: string[] = require("./demo.json"); 7 | 8 | export const gui = new GUI(); 9 | 10 | const config = { 11 | snake: Object.entries(snakes).find(([_, s]) => s === snake)![0], 12 | grid: Object.entries(grids).find(([_, s]) => s === grid)![0], 13 | demo: demos[0], 14 | }; 15 | { 16 | const d = window.location.pathname.match(/(\w+)\.html/); 17 | if (d && demos.includes(d[1])) config.demo = d[1]; 18 | } 19 | 20 | const onChange = () => { 21 | const search = new URLSearchParams({ 22 | snake: config.snake, 23 | grid: config.grid, 24 | }).toString(); 25 | 26 | const url = new URL( 27 | config.demo + ".html?" + search, 28 | window.location.href 29 | ).toString(); 30 | 31 | window.location.href = url; 32 | }; 33 | 34 | gui.add(config, "demo", demos).onChange(onChange); 35 | gui.add(config, "grid", Object.keys(grids)).onChange(onChange); 36 | gui.add(config, "snake", Object.keys(snakes)).onChange(onChange); 37 | -------------------------------------------------------------------------------- /packages/demo/demo.getBestRoute.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { createCanvas } from "./canvas"; 3 | import { getBestRoute } from "@snk/solver/getBestRoute"; 4 | import { Color, copyGrid } from "@snk/types/grid"; 5 | import { grid, snake } from "./sample"; 6 | import { step } from "@snk/solver/step"; 7 | 8 | const chain = getBestRoute(grid, snake)!; 9 | 10 | // 11 | // draw 12 | let k = 0; 13 | 14 | const { canvas, draw } = createCanvas(grid); 15 | document.body.appendChild(canvas); 16 | 17 | const onChange = () => { 18 | const gridN = copyGrid(grid); 19 | const stack: Color[] = []; 20 | for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]); 21 | 22 | draw(gridN, chain[k], stack); 23 | }; 24 | onChange(); 25 | 26 | const input = document.createElement("input") as any; 27 | input.type = "range"; 28 | input.value = 0; 29 | input.step = 1; 30 | input.min = 0; 31 | input.max = chain.length - 1; 32 | input.style.width = "90%"; 33 | input.addEventListener("input", () => { 34 | k = +input.value; 35 | onChange(); 36 | }); 37 | document.body.append(input); 38 | window.addEventListener("click", (e) => { 39 | if (e.target === document.body || e.target === document.body.parentElement) 40 | input.focus(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/github-user-contribution/__tests__/getGithubUserContribution.spec.ts: -------------------------------------------------------------------------------- 1 | import { getGithubUserContribution } from ".."; 2 | import { config } from "dotenv"; 3 | config({ path: __dirname + "/../../../.env" }); 4 | 5 | describe("getGithubUserContribution", () => { 6 | const promise = getGithubUserContribution("platane", { 7 | githubToken: process.env.GITHUB_TOKEN!, 8 | }); 9 | 10 | it("should resolve", async () => { 11 | await promise; 12 | }); 13 | 14 | it("should get around 365 cells", async () => { 15 | const cells = await promise; 16 | 17 | expect(cells.length).toBeGreaterThanOrEqual(365); 18 | expect(cells.length).toBeLessThanOrEqual(365 + 7); 19 | }); 20 | 21 | it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => { 22 | const cells = await promise; 23 | 24 | expect(cells.length).toBeGreaterThan(300); 25 | 26 | const undefinedDays = Array.from({ length: Math.floor(365 / 7) }) 27 | .map((x) => Array.from({ length: 7 }).map((y) => ({ x, y }))) 28 | .flat() 29 | .filter(({ x, y }) => cells.some((c: any) => c.x === x && c.y === y)); 30 | 31 | expect(undefinedDays).toEqual([]); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/demo/demo.getPathToPose.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { createCanvas } from "./canvas"; 3 | import { createSnakeFromCells, snakeToCells } from "@snk/types/snake"; 4 | import { grid, snake } from "./sample"; 5 | import { getPathToPose } from "@snk/solver/getPathToPose"; 6 | 7 | const { canvas, ctx, draw, highlightCell } = createCanvas(grid); 8 | canvas.style.pointerEvents = "auto"; 9 | 10 | const target = createSnakeFromCells( 11 | snakeToCells(snake).map((p) => ({ ...p, x: p.x - 1 })) 12 | ); 13 | 14 | let chain = [snake, ...getPathToPose(snake, target)!]; 15 | 16 | let i = 0; 17 | const onChange = () => { 18 | ctx.clearRect(0, 0, 9999, 9999); 19 | 20 | draw(grid, chain[i], []); 21 | chain 22 | .map(snakeToCells) 23 | .flat() 24 | .forEach(({ x, y }) => highlightCell(x, y)); 25 | }; 26 | 27 | onChange(); 28 | 29 | const inputI = document.createElement("input") as any; 30 | inputI.type = "range"; 31 | inputI.value = 0; 32 | inputI.max = chain ? chain.length - 1 : 0; 33 | inputI.step = 1; 34 | inputI.min = 0; 35 | inputI.style.width = "90%"; 36 | inputI.style.padding = "20px 0"; 37 | inputI.addEventListener("input", () => { 38 | i = +inputI.value; 39 | onChange(); 40 | }); 41 | document.body.append(inputI); 42 | -------------------------------------------------------------------------------- /packages/action/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as core from "@actions/core"; 4 | import { parseOutputsOption } from "./outputsOptions"; 5 | 6 | (async () => { 7 | try { 8 | const userName = core.getInput("github_user_name"); 9 | const outputs = parseOutputsOption( 10 | core.getMultilineInput("outputs") ?? [ 11 | core.getInput("gif_out_path"), 12 | core.getInput("svg_out_path"), 13 | ] 14 | ); 15 | const githubToken = 16 | process.env.GITHUB_TOKEN ?? core.getInput("github_token"); 17 | 18 | const { generateContributionSnake } = await import( 19 | "./generateContributionSnake" 20 | ); 21 | const results = await generateContributionSnake(userName, outputs, { 22 | githubToken, 23 | }); 24 | 25 | outputs.forEach((out, i) => { 26 | const result = results[i]; 27 | if (out?.filename && result) { 28 | console.log(`💾 writing to ${out?.filename}`); 29 | fs.mkdirSync(path.dirname(out?.filename), { recursive: true }); 30 | fs.writeFileSync(out?.filename, result); 31 | } 32 | }); 33 | } catch (e: any) { 34 | core.setFailed(`Action failed with "${e.message}"`); 35 | } 36 | })(); 37 | -------------------------------------------------------------------------------- /packages/demo/demo.outside.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { createCanvas } from "./canvas"; 3 | import { grid } from "./sample"; 4 | import type { Color } from "@snk/types/grid"; 5 | import { createOutside, isOutside } from "@snk/solver/outside"; 6 | 7 | const { canvas, ctx, draw, highlightCell } = createCanvas(grid); 8 | document.body.appendChild(canvas); 9 | 10 | let k = 0; 11 | 12 | const onChange = () => { 13 | ctx.clearRect(0, 0, 9999, 9999); 14 | 15 | draw(grid, [] as any, []); 16 | 17 | const outside = createOutside(grid, k as Color); 18 | 19 | for (let x = outside.width; x--; ) 20 | for (let y = outside.height; y--; ) 21 | if (isOutside(outside, x, y)) highlightCell(x, y); 22 | }; 23 | 24 | onChange(); 25 | 26 | const inputK = document.createElement("input") as any; 27 | inputK.type = "range"; 28 | inputK.value = 0; 29 | inputK.step = 1; 30 | inputK.min = 0; 31 | inputK.max = 4; 32 | inputK.style.width = "90%"; 33 | inputK.style.padding = "20px 0"; 34 | inputK.addEventListener("input", () => { 35 | k = +inputK.value; 36 | onChange(); 37 | }); 38 | document.body.append(inputK); 39 | window.addEventListener("click", (e) => { 40 | if (e.target === document.body || e.target === document.body.parentElement) 41 | inputK.focus(); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/svg-creator/css-utils.ts: -------------------------------------------------------------------------------- 1 | const percent = (x: number) => 2 | parseFloat((x * 100).toFixed(2)).toString() + "%"; 3 | 4 | const mergeKeyFrames = (keyframes: { t: number; style: string }[]) => { 5 | const s = new Map(); 6 | for (const { t, style } of keyframes) { 7 | s.set(style, [...(s.get(style) ?? []), t]); 8 | } 9 | return Array.from(s.entries()) 10 | .map(([style, ts]) => ({ style, ts })) 11 | .sort((a, b) => a.ts[0] - b.ts[0]); 12 | }; 13 | 14 | /** 15 | * generate the keyframe animation from a list of keyframe 16 | */ 17 | export const createAnimation = ( 18 | name: string, 19 | keyframes: { t: number; style: string }[] 20 | ) => 21 | `@keyframes ${name}{` + 22 | mergeKeyFrames(keyframes) 23 | .map(({ style, ts }) => ts.map(percent).join(",") + `{${style}}`) 24 | .join("") + 25 | "}"; 26 | 27 | /** 28 | * remove white spaces 29 | */ 30 | export const minifyCss = (css: string) => 31 | css 32 | .replace(/\s+/g, " ") 33 | .replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, "")) 34 | .replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, "")) 35 | .replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, "")) 36 | .replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, "")) 37 | .replace(/\;\s*\}/g, "}") 38 | .trim(); 39 | -------------------------------------------------------------------------------- /svg-only/action.yml: -------------------------------------------------------------------------------- 1 | name: "generate-snake-game-from-github-contribution-grid" 2 | description: "Generates a snake game from a github user contributions grid. Output the animation as svg" 3 | author: "platane" 4 | 5 | runs: 6 | using: node20 7 | main: dist/index.js 8 | 9 | inputs: 10 | github_user_name: 11 | description: "github user name" 12 | required: true 13 | github_token: 14 | description: "github token used to fetch the contribution calendar. Default to the action token if empty." 15 | required: false 16 | default: ${{ github.token }} 17 | outputs: 18 | required: false 19 | description: | 20 | list of files to generate. 21 | one file per line. Each output can be customized with options as query string. 22 | 23 | supported query string options: 24 | 25 | - palette: A preset of color, one of [github, github-dark, github-light] 26 | - color_snake: Color of the snake 27 | - color_dots: Coma separated list of dots color. 28 | The first one is 0 contribution, then it goes from the low contribution to the highest. 29 | Exactly 5 colors are expected. 30 | 31 | example: 32 | outputs: | 33 | dark.svg?palette=github-dark&color_snake=blue 34 | light.svg?color_snake=#7845ab 35 | ocean.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 36 | -------------------------------------------------------------------------------- /packages/draw/drawGrid.ts: -------------------------------------------------------------------------------- 1 | import { getColor } from "@snk/types/grid"; 2 | import { pathRoundedRect } from "./pathRoundedRect"; 3 | import type { Grid, Color } from "@snk/types/grid"; 4 | import type { Point } from "@snk/types/point"; 5 | 6 | type Options = { 7 | colorDots: Record; 8 | colorEmpty: string; 9 | colorDotBorder: string; 10 | sizeCell: number; 11 | sizeDot: number; 12 | sizeDotBorderRadius: number; 13 | }; 14 | 15 | export const drawGrid = ( 16 | ctx: CanvasRenderingContext2D, 17 | grid: Grid, 18 | cells: Point[] | null, 19 | o: Options 20 | ) => { 21 | for (let x = grid.width; x--; ) 22 | for (let y = grid.height; y--; ) { 23 | if (!cells || cells.some((c) => c.x === x && c.y === y)) { 24 | const c = getColor(grid, x, y); 25 | // @ts-ignore 26 | const color = !c ? o.colorEmpty : o.colorDots[c]; 27 | ctx.save(); 28 | ctx.translate( 29 | x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, 30 | y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2 31 | ); 32 | 33 | ctx.fillStyle = color; 34 | ctx.strokeStyle = o.colorDotBorder; 35 | ctx.lineWidth = 1; 36 | ctx.beginPath(); 37 | 38 | pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeDotBorderRadius); 39 | 40 | ctx.fill(); 41 | ctx.stroke(); 42 | ctx.closePath(); 43 | 44 | ctx.restore(); 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /packages/solver/outside.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmptyGrid, 3 | getColor, 4 | isEmpty, 5 | isInside, 6 | setColor, 7 | setColorEmpty, 8 | } from "@snk/types/grid"; 9 | import { around4 } from "@snk/types/point"; 10 | import type { Color, Grid } from "@snk/types/grid"; 11 | 12 | export type Outside = Grid & { __outside: true }; 13 | 14 | export const createOutside = (grid: Grid, color: Color = 0 as Color) => { 15 | const outside = createEmptyGrid(grid.width, grid.height) as Outside; 16 | for (let x = outside.width; x--; ) 17 | for (let y = outside.height; y--; ) setColor(outside, x, y, 1 as Color); 18 | 19 | fillOutside(outside, grid, color); 20 | 21 | return outside; 22 | }; 23 | 24 | export const fillOutside = ( 25 | outside: Outside, 26 | grid: Grid, 27 | color: Color = 0 as Color 28 | ) => { 29 | let changed = true; 30 | while (changed) { 31 | changed = false; 32 | for (let x = outside.width; x--; ) 33 | for (let y = outside.height; y--; ) 34 | if ( 35 | getColor(grid, x, y) <= color && 36 | !isOutside(outside, x, y) && 37 | around4.some((a) => isOutside(outside, x + a.x, y + a.y)) 38 | ) { 39 | changed = true; 40 | setColorEmpty(outside, x, y); 41 | } 42 | } 43 | 44 | return outside; 45 | }; 46 | 47 | export const isOutside = (outside: Outside, x: number, y: number) => 48 | !isInside(outside, x, y) || isEmpty(getColor(outside, x, y)); 49 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "generate-snake-game-from-github-contribution-grid" 2 | description: "Generates a snake game from a github user contributions grid. Output the animation as gif or svg" 3 | author: "platane" 4 | 5 | runs: 6 | using: docker 7 | image: docker://platane/snk@sha256:1c8a0b51a75ad8cf36b7defddd2187bdbb92bbbb5521a9e6cc5df795b00fc590 8 | 9 | inputs: 10 | github_user_name: 11 | description: "github user name" 12 | required: true 13 | github_token: 14 | description: "github token used to fetch the contribution calendar. Default to the action token if empty." 15 | required: false 16 | default: ${{ github.token }} 17 | outputs: 18 | required: false 19 | description: | 20 | list of files to generate. 21 | one file per line. Each output can be customized with options as query string. 22 | 23 | supported query string options: 24 | 25 | - palette: A preset of color, one of [github, github-dark, github-light] 26 | - color_snake: Color of the snake 27 | - color_dots: Coma separated list of dots color. 28 | The first one is 0 contribution, then it goes from the low contribution to the highest. 29 | Exactly 5 colors are expected. 30 | example: 31 | outputs: | 32 | dark.svg?palette=github-dark&color_snake=blue 33 | light.svg?color_snake=#7845ab 34 | ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 35 | -------------------------------------------------------------------------------- /packages/solver/README.md: -------------------------------------------------------------------------------- 1 | # @snk/solver 2 | 3 | Contains the algorithm to compute the best route given a grid and a starting position for the snake. 4 | 5 | ## Implementation 6 | 7 | - for each color in the grid 8 | 9 | - 1\ **clear residual color** phase 10 | 11 | - find all the cells of a previous color that are "tunnel-able" ( ie: the snake can find a path from the outside of the grid to the cell, and can go back to the outside without colliding ). The snake is allowed to pass thought current and previous color. Higher colors are walls 12 | 13 | - sort the "tunnel-able" cell, there is penalty for passing through current color, as previous color should be eliminated as soon as possible. 14 | 15 | - for cells with the same score, take the closest one ( determined with a quick mathematic distance, which is not accurate but fast at least ) 16 | 17 | - navigate to the cell, and through the tunnel. 18 | 19 | - re-compute the list of tunnel-able cells ( as eating cells might have freed better tunnel ) as well as the score 20 | 21 | - iterate 22 | 23 | - 2\ **clear clean color** phase 24 | 25 | - find all the cells of the current color that are "tunnel-able" 26 | 27 | - no need to consider scoring here. In order to improve efficiency, get the closest cell by doing a tree search ( instead of a simple mathematic distance like in the previous phase ) 28 | 29 | - navigate to the cell, and through the tunnel. 30 | 31 | - iterate 32 | 33 | - go back to the starting point 34 | -------------------------------------------------------------------------------- /packages/svg-creator/__tests__/createSvg.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { createSvg, DrawOptions as DrawOptions } from ".."; 4 | import * as grids from "@snk/types/__fixtures__/grid"; 5 | import { snake3 as snake } from "@snk/types/__fixtures__/snake"; 6 | import { getBestRoute } from "@snk/solver/getBestRoute"; 7 | import { AnimationOptions } from "@snk/gif-creator"; 8 | 9 | const drawOptions: DrawOptions = { 10 | sizeDotBorderRadius: 2, 11 | sizeCell: 16, 12 | sizeDot: 12, 13 | colorDotBorder: "#1b1f230a", 14 | colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, 15 | colorEmpty: "#ebedf0", 16 | colorSnake: "purple", 17 | dark: { 18 | colorEmpty: "#161b22", 19 | colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" }, 20 | }, 21 | }; 22 | 23 | const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 }; 24 | 25 | const dir = path.resolve(__dirname, "__snapshots__"); 26 | 27 | try { 28 | fs.mkdirSync(dir); 29 | } catch (err) {} 30 | 31 | for (const [key, grid] of Object.entries(grids)) 32 | it(`should generate ${key} svg`, async () => { 33 | const chain = [snake, ...getBestRoute(grid, snake)!]; 34 | 35 | const svg = await createSvg( 36 | grid, 37 | null, 38 | chain, 39 | drawOptions, 40 | animationOptions 41 | ); 42 | 43 | expect(svg).toBeDefined(); 44 | 45 | fs.writeFileSync(path.resolve(dir, key + ".svg"), svg); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/solver/__tests__/getBestRoute-fuzz.spec.ts: -------------------------------------------------------------------------------- 1 | import { getBestRoute } from "../getBestRoute"; 2 | import { snake3, snake4 } from "@snk/types/__fixtures__/snake"; 3 | import { 4 | getHeadX, 5 | getHeadY, 6 | getSnakeLength, 7 | Snake, 8 | snakeWillSelfCollide, 9 | } from "@snk/types/snake"; 10 | import { createFromSeed } from "@snk/types/__fixtures__/createFromSeed"; 11 | 12 | const n = 1000; 13 | 14 | for (const { width, height, snake } of [ 15 | { width: 5, height: 5, snake: snake3 }, 16 | { width: 5, height: 5, snake: snake4 }, 17 | ]) 18 | it(`should find solution for ${n} ${width}x${height} generated grids for ${getSnakeLength( 19 | snake 20 | )} length snake`, () => { 21 | const results = Array.from({ length: n }, (_, seed) => { 22 | const grid = createFromSeed(seed, width, height); 23 | 24 | try { 25 | const chain = getBestRoute(grid, snake); 26 | 27 | assertValidPath(chain); 28 | 29 | return { seed }; 30 | } catch (error) { 31 | return { seed, error }; 32 | } 33 | }); 34 | 35 | expect(results.filter((x) => x.error)).toEqual([]); 36 | }); 37 | 38 | const assertValidPath = (chain: Snake[]) => { 39 | for (let i = 0; i < chain.length - 1; i++) { 40 | const dx = getHeadX(chain[i + 1]) - getHeadX(chain[i]); 41 | const dy = getHeadY(chain[i + 1]) - getHeadY(chain[i]); 42 | 43 | if (!((Math.abs(dx) === 1 && dy == 0) || (Math.abs(dy) === 1 && dx == 0))) 44 | throw new Error(`unexpected direction ${dx},${dy}`); 45 | 46 | if (snakeWillSelfCollide(chain[i], dx, dy)) throw new Error(`self collide`); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /packages/demo/demo.getPathTo.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { createCanvas } from "./canvas"; 3 | import { copySnake, snakeToCells } from "@snk/types/snake"; 4 | import { grid, snake as snake0 } from "./sample"; 5 | import { getPathTo } from "@snk/solver/getPathTo"; 6 | 7 | const { canvas, ctx, draw, getPointedCell, highlightCell } = createCanvas(grid); 8 | canvas.style.pointerEvents = "auto"; 9 | 10 | let snake = copySnake(snake0); 11 | let chain = [snake]; 12 | 13 | canvas.addEventListener("mousemove", (e) => { 14 | const { x, y } = getPointedCell(e); 15 | 16 | chain = [...(getPathTo(grid, snake, x, y) || []), snake].reverse(); 17 | 18 | inputI.max = chain.length - 1; 19 | i = inputI.value = chain.length - 1; 20 | 21 | onChange(); 22 | }); 23 | 24 | canvas.addEventListener("click", () => { 25 | snake = chain.slice(-1)[0]; 26 | 27 | chain = [snake]; 28 | inputI.max = chain.length - 1; 29 | i = inputI.value = chain.length - 1; 30 | 31 | onChange(); 32 | }); 33 | 34 | let i = 0; 35 | const onChange = () => { 36 | ctx.clearRect(0, 0, 9999, 9999); 37 | 38 | draw(grid, chain[i], []); 39 | chain 40 | .map(snakeToCells) 41 | .flat() 42 | .forEach(({ x, y }) => highlightCell(x, y)); 43 | }; 44 | 45 | onChange(); 46 | 47 | const inputI = document.createElement("input") as any; 48 | inputI.type = "range"; 49 | inputI.value = 0; 50 | inputI.max = chain ? chain.length - 1 : 0; 51 | inputI.step = 1; 52 | inputI.min = 0; 53 | inputI.style.width = "90%"; 54 | inputI.style.padding = "20px 0"; 55 | inputI.addEventListener("input", () => { 56 | i = +inputI.value; 57 | onChange(); 58 | }); 59 | document.body.append(inputI); 60 | -------------------------------------------------------------------------------- /packages/action/__tests__/generateContributionSnake.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { generateContributionSnake } from "../generateContributionSnake"; 4 | import { parseOutputsOption } from "../outputsOptions"; 5 | import { config } from "dotenv"; 6 | config({ path: __dirname + "/../../../.env" }); 7 | 8 | jest.setTimeout(2 * 60 * 1000); 9 | 10 | const silent = (handler: () => void | Promise) => async () => { 11 | const originalConsoleLog = console.log; 12 | console.log = () => undefined; 13 | try { 14 | return await handler(); 15 | } finally { 16 | console.log = originalConsoleLog; 17 | } 18 | }; 19 | 20 | it( 21 | "should generate contribution snake", 22 | silent(async () => { 23 | const entries = [ 24 | path.join(__dirname, "__snapshots__/out.svg"), 25 | 26 | path.join(__dirname, "__snapshots__/out-dark.svg") + 27 | "?palette=github-dark&color_snake=orange", 28 | 29 | path.join(__dirname, "__snapshots__/out.gif") + 30 | "?color_snake=orange&color_dots=#d4e0f0,#8dbdff,#64a1f4,#4b91f1,#3c7dd9", 31 | ]; 32 | 33 | const outputs = parseOutputsOption(entries); 34 | 35 | const results = await generateContributionSnake("platane", outputs, { 36 | githubToken: process.env.GITHUB_TOKEN!, 37 | }); 38 | 39 | expect(results[0]).toBeDefined(); 40 | expect(results[1]).toBeDefined(); 41 | expect(results[2]).toBeDefined(); 42 | 43 | fs.writeFileSync(outputs[0]!.filename, results[0]!); 44 | fs.writeFileSync(outputs[1]!.filename, results[1]!); 45 | fs.writeFileSync(outputs[2]!.filename, results[2]!); 46 | }) 47 | ); 48 | -------------------------------------------------------------------------------- /packages/types/grid.ts: -------------------------------------------------------------------------------- 1 | export type Color = (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9) & { _tag: "__Color__" }; 2 | export type Empty = 0 & { _tag: "__Empty__" }; 3 | 4 | export type Grid = { 5 | width: number; 6 | height: number; 7 | data: Uint8Array; 8 | }; 9 | 10 | export const isInside = (grid: Grid, x: number, y: number) => 11 | x >= 0 && y >= 0 && x < grid.width && y < grid.height; 12 | 13 | export const isInsideLarge = (grid: Grid, m: number, x: number, y: number) => 14 | x >= -m && y >= -m && x < grid.width + m && y < grid.height + m; 15 | 16 | export const copyGrid = ({ width, height, data }: Grid) => ({ 17 | width, 18 | height, 19 | data: Uint8Array.from(data), 20 | }); 21 | 22 | const getIndex = (grid: Grid, x: number, y: number) => x * grid.height + y; 23 | 24 | export const getColor = (grid: Grid, x: number, y: number) => 25 | grid.data[getIndex(grid, x, y)] as Color | Empty; 26 | 27 | export const isEmpty = (color: Color | Empty): color is Empty => color === 0; 28 | 29 | export const setColor = ( 30 | grid: Grid, 31 | x: number, 32 | y: number, 33 | color: Color | Empty 34 | ) => { 35 | grid.data[getIndex(grid, x, y)] = color || 0; 36 | }; 37 | 38 | export const setColorEmpty = (grid: Grid, x: number, y: number) => { 39 | setColor(grid, x, y, 0 as Empty); 40 | }; 41 | 42 | /** 43 | * return true if the grid is empty 44 | */ 45 | export const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === 0); 46 | 47 | export const gridEquals = (a: Grid, b: Grid) => 48 | a.data.every((_, i) => a.data[i] === b.data[i]); 49 | 50 | export const createEmptyGrid = (width: number, height: number) => ({ 51 | width, 52 | height, 53 | data: new Uint8Array(width * height), 54 | }); 55 | -------------------------------------------------------------------------------- /packages/demo/springUtils.ts: -------------------------------------------------------------------------------- 1 | const epsilon = 0.01; 2 | 3 | export const clamp = (a: number, b: number) => (x: number) => 4 | Math.max(a, Math.min(b, x)); 5 | 6 | /** 7 | * step the spring, mutate the state to reflect the state at t+dt 8 | * 9 | */ 10 | const stepSpringOne = ( 11 | s: { x: number; v: number }, 12 | { 13 | tension, 14 | friction, 15 | maxVelocity = Infinity, 16 | }: { tension: number; friction: number; maxVelocity?: number }, 17 | target: number, 18 | dt = 1 / 60 19 | ) => { 20 | const a = -tension * (s.x - target) - friction * s.v; 21 | 22 | s.v += a * dt; 23 | s.v = clamp(-maxVelocity / dt, maxVelocity / dt)(s.v); 24 | s.x += s.v * dt; 25 | }; 26 | 27 | /** 28 | * return true if the spring is to be considered in a stable state 29 | * ( close enough to the target and with a small enough velocity ) 30 | */ 31 | export const isStable = ( 32 | s: { x: number; v: number }, 33 | target: number, 34 | dt = 1 / 60 35 | ) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon; 36 | 37 | export const isStableAndBound = ( 38 | s: { x: number; v: number }, 39 | target: number, 40 | dt?: number 41 | ) => { 42 | const stable = isStable(s, target, dt); 43 | if (stable) { 44 | s.x = target; 45 | s.v = 0; 46 | } 47 | return stable; 48 | }; 49 | 50 | export const stepSpring = ( 51 | s: { x: number; v: number }, 52 | params: { tension: number; friction: number; maxVelocity?: number }, 53 | target: number, 54 | dt = 1 / 60 55 | ) => { 56 | const interval = 1 / 60; 57 | 58 | while (dt > 0) { 59 | stepSpringOne(s, params, target, Math.min(interval, dt)); 60 | // eslint-disable-next-line no-param-reassign 61 | dt -= interval; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /.github/workflows/manual-run.yml: -------------------------------------------------------------------------------- 1 | name: manual run 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | generate: 8 | permissions: 9 | contents: write 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | 13 | steps: 14 | - uses: Platane/snk/svg-only@v3 15 | with: 16 | github_user_name: ${{ github.repository_owner }} 17 | outputs: | 18 | dist/only-svg/github-contribution-grid-snake.svg 19 | dist/only-svg/github-contribution-grid-snake-dark.svg?palette=github-dark 20 | 21 | - uses: Platane/snk@v3 22 | with: 23 | github_user_name: ${{ github.repository_owner }} 24 | outputs: | 25 | dist/docker/github-contribution-grid-snake.svg 26 | dist/docker/github-contribution-grid-snake-dark.svg?palette=github-dark 27 | dist/docker/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 28 | 29 | - name: ensure the generated file exists 30 | run: | 31 | ls dist 32 | test -f dist/only-svg/github-contribution-grid-snake.svg 33 | test -f dist/only-svg/github-contribution-grid-snake-dark.svg 34 | 35 | test -f dist/docker/github-contribution-grid-snake.svg 36 | test -f dist/docker/github-contribution-grid-snake-dark.svg 37 | test -f dist/docker/github-contribution-grid-snake.gif 38 | 39 | - name: push github-contribution-grid-snake.svg to the output branch 40 | uses: crazy-max/ghaction-github-pages@v3.1.0 41 | with: 42 | target_branch: manual-run-output 43 | build_dir: dist 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /packages/types/snake.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from "./point"; 2 | 3 | export type Snake = Uint8Array & { _tag: "__Snake__" }; 4 | 5 | export const getHeadX = (snake: Snake) => snake[0] - 2; 6 | export const getHeadY = (snake: Snake) => snake[1] - 2; 7 | 8 | export const getSnakeLength = (snake: Snake) => snake.length / 2; 9 | 10 | export const copySnake = (snake: Snake) => snake.slice() as Snake; 11 | 12 | export const snakeEquals = (a: Snake, b: Snake) => { 13 | for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; 14 | return true; 15 | }; 16 | 17 | /** 18 | * return a copy of the next snake, considering that dx, dy is the direction 19 | */ 20 | export const nextSnake = (snake: Snake, dx: number, dy: number) => { 21 | const copy = new Uint8Array(snake.length); 22 | for (let i = 2; i < snake.length; i++) copy[i] = snake[i - 2]; 23 | copy[0] = snake[0] + dx; 24 | copy[1] = snake[1] + dy; 25 | return copy as Snake; 26 | }; 27 | 28 | /** 29 | * return true if the next snake will collide with itself 30 | */ 31 | export const snakeWillSelfCollide = (snake: Snake, dx: number, dy: number) => { 32 | const nx = snake[0] + dx; 33 | const ny = snake[1] + dy; 34 | 35 | for (let i = 2; i < snake.length - 2; i += 2) 36 | if (snake[i + 0] === nx && snake[i + 1] === ny) return true; 37 | 38 | return false; 39 | }; 40 | 41 | export const snakeToCells = (snake: Snake): Point[] => 42 | Array.from({ length: snake.length / 2 }, (_, i) => ({ 43 | x: snake[i * 2 + 0] - 2, 44 | y: snake[i * 2 + 1] - 2, 45 | })); 46 | 47 | export const createSnakeFromCells = (points: Point[]) => { 48 | const snake = new Uint8Array(points.length * 2); 49 | for (let i = points.length; i--; ) { 50 | snake[i * 2 + 0] = points[i].x + 2; 51 | snake[i * 2 + 1] = points[i].y + 2; 52 | } 53 | return snake as Snake; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/svg-creator/grid.ts: -------------------------------------------------------------------------------- 1 | import type { Color, Empty } from "@snk/types/grid"; 2 | import type { Point } from "@snk/types/point"; 3 | import { createAnimation } from "./css-utils"; 4 | import { h } from "./xml-utils"; 5 | 6 | export type Options = { 7 | colorDots: Record; 8 | colorEmpty: string; 9 | colorDotBorder: string; 10 | sizeCell: number; 11 | sizeDot: number; 12 | sizeDotBorderRadius: number; 13 | }; 14 | 15 | export const createGrid = ( 16 | cells: (Point & { t: number | null; color: Color | Empty })[], 17 | { sizeDotBorderRadius, sizeDot, sizeCell }: Options, 18 | duration: number 19 | ) => { 20 | const svgElements: string[] = []; 21 | const styles = [ 22 | `.c{ 23 | shape-rendering: geometricPrecision; 24 | fill: var(--ce); 25 | stroke-width: 1px; 26 | stroke: var(--cb); 27 | animation: none ${duration}ms linear infinite; 28 | width: ${sizeDot}px; 29 | height: ${sizeDot}px; 30 | }`, 31 | ]; 32 | 33 | let i = 0; 34 | for (const { x, y, color, t } of cells) { 35 | const id = t && "c" + (i++).toString(36); 36 | const m = (sizeCell - sizeDot) / 2; 37 | 38 | if (t !== null && id) { 39 | const animationName = id; 40 | 41 | styles.push( 42 | createAnimation(animationName, [ 43 | { t: t - 0.0001, style: `fill:var(--c${color})` }, 44 | { t: t + 0.0001, style: `fill:var(--ce)` }, 45 | { t: 1, style: `fill:var(--ce)` }, 46 | ]), 47 | 48 | `.c.${id}{ 49 | fill: var(--c${color}); 50 | animation-name: ${animationName} 51 | }` 52 | ); 53 | } 54 | 55 | svgElements.push( 56 | h("rect", { 57 | class: ["c", id].filter(Boolean).join(" "), 58 | x: x * sizeCell + m, 59 | y: y * sizeCell + m, 60 | rx: sizeDotBorderRadius, 61 | ry: sizeDotBorderRadius, 62 | }) 63 | ); 64 | } 65 | 66 | return { svgElements, styles }; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/draw/drawSnake.ts: -------------------------------------------------------------------------------- 1 | import { pathRoundedRect } from "./pathRoundedRect"; 2 | import { snakeToCells } from "@snk/types/snake"; 3 | import type { Snake } from "@snk/types/snake"; 4 | 5 | type Options = { 6 | colorSnake: string; 7 | sizeCell: number; 8 | }; 9 | 10 | export const drawSnake = ( 11 | ctx: CanvasRenderingContext2D, 12 | snake: Snake, 13 | o: Options 14 | ) => { 15 | const cells = snakeToCells(snake); 16 | 17 | for (let i = 0; i < cells.length; i++) { 18 | const u = (i + 1) * 0.6; 19 | 20 | ctx.save(); 21 | ctx.fillStyle = o.colorSnake; 22 | ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u); 23 | ctx.beginPath(); 24 | pathRoundedRect( 25 | ctx, 26 | o.sizeCell - u * 2, 27 | o.sizeCell - u * 2, 28 | (o.sizeCell - u * 2) * 0.25 29 | ); 30 | ctx.fill(); 31 | ctx.restore(); 32 | } 33 | }; 34 | 35 | const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b; 36 | const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); 37 | 38 | export const drawSnakeLerp = ( 39 | ctx: CanvasRenderingContext2D, 40 | snake0: Snake, 41 | snake1: Snake, 42 | k: number, 43 | o: Options 44 | ) => { 45 | const m = 0.8; 46 | const n = snake0.length / 2; 47 | for (let i = 0; i < n; i++) { 48 | const u = (i + 1) * 0.6 * (o.sizeCell / 16); 49 | 50 | const a = (1 - m) * (i / Math.max(n - 1, 1)); 51 | const ki = clamp((k - a) / m, 0, 1); 52 | 53 | const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2; 54 | const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2; 55 | 56 | ctx.save(); 57 | ctx.fillStyle = o.colorSnake; 58 | ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u); 59 | ctx.beginPath(); 60 | pathRoundedRect( 61 | ctx, 62 | o.sizeCell - u * 2, 63 | o.sizeCell - u * 2, 64 | (o.sizeCell - u * 2) * 0.25 65 | ); 66 | ctx.fill(); 67 | ctx.restore(); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /packages/action/generateContributionSnake.ts: -------------------------------------------------------------------------------- 1 | import { getGithubUserContribution } from "@snk/github-user-contribution"; 2 | import { userContributionToGrid } from "./userContributionToGrid"; 3 | import { getBestRoute } from "@snk/solver/getBestRoute"; 4 | import { snake4 } from "@snk/types/__fixtures__/snake"; 5 | import { getPathToPose } from "@snk/solver/getPathToPose"; 6 | import type { DrawOptions as DrawOptions } from "@snk/svg-creator"; 7 | import type { AnimationOptions } from "@snk/gif-creator"; 8 | 9 | export const generateContributionSnake = async ( 10 | userName: string, 11 | outputs: ({ 12 | format: "svg" | "gif"; 13 | drawOptions: DrawOptions; 14 | animationOptions: AnimationOptions; 15 | } | null)[], 16 | options: { githubToken: string } 17 | ) => { 18 | console.log("🎣 fetching github user contribution"); 19 | const cells = await getGithubUserContribution(userName, options); 20 | 21 | const grid = userContributionToGrid(cells); 22 | const snake = snake4; 23 | 24 | console.log("📡 computing best route"); 25 | const chain = getBestRoute(grid, snake)!; 26 | chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); 27 | 28 | return Promise.all( 29 | outputs.map(async (out, i) => { 30 | if (!out) return; 31 | const { format, drawOptions, animationOptions } = out; 32 | switch (format) { 33 | case "svg": { 34 | console.log(`🖌 creating svg (outputs[${i}])`); 35 | const { createSvg } = await import("@snk/svg-creator"); 36 | return createSvg(grid, cells, chain, drawOptions, animationOptions); 37 | } 38 | case "gif": { 39 | console.log(`📹 creating gif (outputs[${i}])`); 40 | const { createGif } = await import("@snk/gif-creator"); 41 | return await createGif( 42 | grid, 43 | cells, 44 | chain, 45 | drawOptions, 46 | animationOptions 47 | ); 48 | } 49 | } 50 | }) 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /packages/action/__tests__/outputsOptions.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseEntry } from "../outputsOptions"; 2 | 3 | it("should parse options as json", () => { 4 | expect( 5 | parseEntry(`/out.svg {"color_snake":"yellow"}`)?.drawOptions 6 | ).toHaveProperty("colorSnake", "yellow"); 7 | 8 | expect( 9 | parseEntry(`/out.svg?{"color_snake":"yellow"}`)?.drawOptions 10 | ).toHaveProperty("colorSnake", "yellow"); 11 | 12 | expect( 13 | parseEntry(`/out.svg?{"color_dots":["#000","#111","#222","#333","#444"]}`) 14 | ?.drawOptions.colorDots 15 | ).toEqual(["#000", "#111", "#222", "#333", "#444"]); 16 | }); 17 | 18 | it("should parse options as searchparams", () => { 19 | expect(parseEntry(`/out.svg?color_snake=yellow`)?.drawOptions).toHaveProperty( 20 | "colorSnake", 21 | "yellow" 22 | ); 23 | 24 | expect( 25 | parseEntry(`/out.svg?color_dots=#000,#111,#222,#333,#444`)?.drawOptions 26 | .colorDots 27 | ).toEqual(["#000", "#111", "#222", "#333", "#444"]); 28 | }); 29 | 30 | it("should parse filename", () => { 31 | expect(parseEntry(`/a/b/c.svg?{"color_snake":"yellow"}`)).toHaveProperty( 32 | "filename", 33 | "/a/b/c.svg" 34 | ); 35 | expect( 36 | parseEntry(`/a/b/out.svg?.gif.svg?{"color_snake":"yellow"}`) 37 | ).toHaveProperty("filename", "/a/b/out.svg?.gif.svg"); 38 | 39 | expect( 40 | parseEntry(`/a/b/{[-1].svg?.gif.svg?{"color_snake":"yellow"}`) 41 | ).toHaveProperty("filename", "/a/b/{[-1].svg?.gif.svg"); 42 | }); 43 | 44 | [ 45 | // default 46 | "path/to/out.gif", 47 | 48 | // overwrite colors (search params) 49 | "/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444", 50 | 51 | // overwrite colors (json) 52 | `/out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]}`, 53 | 54 | // overwrite dark colors 55 | "/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44", 56 | ].forEach((entry) => 57 | it(`should parse ${entry}`, () => { 58 | expect(parseEntry(entry)).toMatchSnapshot(); 59 | }) 60 | ); 61 | -------------------------------------------------------------------------------- /packages/solver/__tests__/sortPush.spec.ts: -------------------------------------------------------------------------------- 1 | import { sortPush } from "../utils/sortPush"; 2 | 3 | const sortFn = (a: number, b: number) => a - b; 4 | 5 | it("should sort push length=0", () => { 6 | const a: any[] = []; 7 | const x = -1; 8 | const res = [...a, x].sort(sortFn); 9 | 10 | sortPush(a, x, sortFn); 11 | 12 | expect(a).toEqual(res); 13 | }); 14 | 15 | it("should sort push under", () => { 16 | const a = [1, 2, 3, 4, 5]; 17 | const x = -1; 18 | const res = [...a, x].sort(sortFn); 19 | 20 | sortPush(a, x, sortFn); 21 | 22 | expect(a).toEqual(res); 23 | }); 24 | 25 | it("should sort push 0", () => { 26 | const a = [1, 2, 3, 4, 5]; 27 | const x = 1; 28 | const res = [...a, x].sort(sortFn); 29 | 30 | sortPush(a, x, sortFn); 31 | 32 | expect(a).toEqual(res); 33 | }); 34 | 35 | it("should sort push end", () => { 36 | const a = [1, 2, 3, 4, 5]; 37 | const x = 5; 38 | const res = [...a, x].sort(sortFn); 39 | 40 | sortPush(a, x, sortFn); 41 | 42 | expect(a).toEqual(res); 43 | }); 44 | 45 | it("should sort push over", () => { 46 | const a = [1, 2, 3, 4, 5]; 47 | const x = 10; 48 | const res = [...a, x].sort(sortFn); 49 | 50 | sortPush(a, x, sortFn); 51 | 52 | expect(a).toEqual(res); 53 | }); 54 | 55 | it("should sort push inside", () => { 56 | const a = [1, 2, 3, 4, 5]; 57 | const x = 1.5; 58 | const res = [...a, x].sort(sortFn); 59 | 60 | sortPush(a, x, sortFn); 61 | 62 | expect(a).toEqual(res); 63 | }); 64 | 65 | describe("benchmark", () => { 66 | const n = 200; 67 | 68 | const samples = Array.from({ length: 5000 }, () => [ 69 | Math.random(), 70 | Array.from({ length: n }, () => Math.random()), 71 | ]); 72 | const s0 = samples.map(([x, arr]: any) => [x, arr.slice()]); 73 | const s1 = samples.map(([x, arr]: any) => [x, arr.slice()]); 74 | 75 | it("push + sort", () => { 76 | for (const [x, arr] of s0) { 77 | arr.push(x); 78 | arr.sort(sortFn); 79 | } 80 | }); 81 | it("sortPush", () => { 82 | for (const [x, arr] of s1) { 83 | sortPush(arr, x, sortFn); 84 | } 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /packages/solver/getPathTo.ts: -------------------------------------------------------------------------------- 1 | import { isInsideLarge, getColor, isInside, isEmpty } from "@snk/types/grid"; 2 | import { around4 } from "@snk/types/point"; 3 | import { 4 | getHeadX, 5 | getHeadY, 6 | nextSnake, 7 | snakeEquals, 8 | snakeWillSelfCollide, 9 | } from "@snk/types/snake"; 10 | import { sortPush } from "./utils/sortPush"; 11 | import type { Snake } from "@snk/types/snake"; 12 | import type { Grid } from "@snk/types/grid"; 13 | 14 | type M = { parent: M | null; snake: Snake; w: number; h: number; f: number }; 15 | 16 | /** 17 | * starting from snake0, get to the cell x,y 18 | * return the snake chain (reversed) 19 | */ 20 | export const getPathTo = (grid: Grid, snake0: Snake, x: number, y: number) => { 21 | const openList: M[] = [{ snake: snake0, w: 0 } as any]; 22 | const closeList: Snake[] = []; 23 | 24 | while (openList.length) { 25 | const c = openList.shift()!; 26 | 27 | const cx = getHeadX(c.snake); 28 | const cy = getHeadY(c.snake); 29 | 30 | for (let i = 0; i < around4.length; i++) { 31 | const { x: dx, y: dy } = around4[i]; 32 | 33 | const nx = cx + dx; 34 | const ny = cy + dy; 35 | 36 | if (nx === x && ny === y) { 37 | // unwrap 38 | const path = [nextSnake(c.snake, dx, dy)]; 39 | let e: M["parent"] = c; 40 | while (e.parent) { 41 | path.push(e.snake); 42 | e = e.parent; 43 | } 44 | return path; 45 | } 46 | 47 | if ( 48 | isInsideLarge(grid, 2, nx, ny) && 49 | !snakeWillSelfCollide(c.snake, dx, dy) && 50 | (!isInside(grid, nx, ny) || isEmpty(getColor(grid, nx, ny))) 51 | ) { 52 | const nsnake = nextSnake(c.snake, dx, dy); 53 | 54 | if (!closeList.some((s) => snakeEquals(nsnake, s))) { 55 | const w = c.w + 1; 56 | const h = Math.abs(nx - x) + Math.abs(ny - y); 57 | const f = w + h; 58 | const o = { snake: nsnake, parent: c, w, h, f }; 59 | 60 | sortPush(openList, o, (a, b) => a.f - b.f); 61 | closeList.push(nsnake); 62 | } 63 | } 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /packages/demo/worker-utils.ts: -------------------------------------------------------------------------------- 1 | type API = Record any>; 2 | 3 | const symbol = "worker-rpc__"; 4 | 5 | export const createRpcServer = (api: API) => 6 | self.addEventListener("message", async (event) => { 7 | if (event.data?.symbol === symbol) { 8 | try { 9 | const res = await api[event.data.methodName](...event.data.args); 10 | self.postMessage({ symbol, key: event.data.key, res }); 11 | } catch (error: any) { 12 | postMessage({ symbol, key: event.data.key, error: error.message }); 13 | } 14 | } 15 | }); 16 | 17 | export const createRpcClient = (worker: Worker) => { 18 | const originalTerminate = worker.terminate; 19 | worker.terminate = () => { 20 | worker.dispatchEvent(new Event("terminate")); 21 | originalTerminate.call(worker); 22 | }; 23 | 24 | return new Proxy( 25 | {} as { 26 | [K in keyof API_]: ( 27 | ...args: Parameters 28 | ) => Promise>>; 29 | }, 30 | { 31 | get: 32 | (_, methodName) => 33 | (...args: any[]) => 34 | new Promise((resolve, reject) => { 35 | const key = Math.random().toString(); 36 | 37 | const onTerminate = () => { 38 | worker.removeEventListener("terminate", onTerminate); 39 | worker.removeEventListener("message", onMessageHandler); 40 | reject(new Error("worker terminated")); 41 | }; 42 | 43 | const onMessageHandler = (event: MessageEvent) => { 44 | if (event.data?.symbol === symbol && event.data.key === key) { 45 | if (event.data.error) reject(event.data.error); 46 | else if (event.data.res) resolve(event.data.res); 47 | 48 | worker.removeEventListener("terminate", onTerminate); 49 | worker.removeEventListener("message", onMessageHandler); 50 | } 51 | }; 52 | 53 | worker.addEventListener("message", onMessageHandler); 54 | worker.addEventListener("terminate", onTerminate); 55 | worker.postMessage({ symbol, key, methodName, args }); 56 | }), 57 | } 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/svg-creator/stack.ts: -------------------------------------------------------------------------------- 1 | import type { Color, Empty } from "@snk/types/grid"; 2 | import { createAnimation } from "./css-utils"; 3 | import { h } from "./xml-utils"; 4 | 5 | export type Options = { 6 | sizeDot: number; 7 | }; 8 | 9 | export const createStack = ( 10 | cells: { t: number | null; color: Color | Empty }[], 11 | { sizeDot }: Options, 12 | width: number, 13 | y: number, 14 | duration: number 15 | ) => { 16 | const svgElements: string[] = []; 17 | const styles = [ 18 | `.u{ 19 | transform-origin: 0 0; 20 | transform: scale(0,1); 21 | animation: none linear ${duration}ms infinite; 22 | }`, 23 | ]; 24 | 25 | const stack = cells 26 | .slice() 27 | .filter((a) => a.t !== null) 28 | .sort((a, b) => a.t! - b.t!) as any[]; 29 | 30 | const blocks: { color: Color; ts: number[] }[] = []; 31 | stack.forEach(({ color, t }) => { 32 | const latest = blocks[blocks.length - 1]; 33 | if (latest?.color === color) latest.ts.push(t); 34 | else blocks.push({ color, ts: [t] }); 35 | }); 36 | 37 | const m = width / stack.length; 38 | let i = 0; 39 | let nx = 0; 40 | for (const { color, ts } of blocks) { 41 | const id = "u" + (i++).toString(36); 42 | const animationName = id; 43 | const x = (nx * m).toFixed(1); 44 | 45 | nx += ts.length; 46 | 47 | svgElements.push( 48 | h("rect", { 49 | class: `u ${id}`, 50 | height: sizeDot, 51 | width: (ts.length * m + 0.6).toFixed(1), 52 | x, 53 | y, 54 | }) 55 | ); 56 | 57 | styles.push( 58 | createAnimation( 59 | animationName, 60 | [ 61 | ...ts 62 | .map((t, i, { length }) => [ 63 | { scale: i / length, t: t - 0.0001 }, 64 | { scale: (i + 1) / length, t: t + 0.0001 }, 65 | ]) 66 | .flat(), 67 | { scale: 1, t: 1 }, 68 | ].map(({ scale, t }) => ({ 69 | t, 70 | style: `transform:scale(${scale.toFixed(3)},1)`, 71 | })) 72 | ), 73 | 74 | `.u.${id} { 75 | fill: var(--c${color}); 76 | animation-name: ${animationName}; 77 | transform-origin: ${x}px 0 78 | } 79 | ` 80 | ); 81 | } 82 | 83 | return { svgElements, styles }; 84 | }; 85 | -------------------------------------------------------------------------------- /packages/solver/tunnel.ts: -------------------------------------------------------------------------------- 1 | import { getColor, isEmpty, isInside } from "@snk/types/grid"; 2 | import { getHeadX, getHeadY, nextSnake } from "@snk/types/snake"; 3 | import type { Snake } from "@snk/types/snake"; 4 | import type { Grid } from "@snk/types/grid"; 5 | import type { Point } from "@snk/types/point"; 6 | 7 | /** 8 | * get the sequence of snake to cross the tunnel 9 | */ 10 | export const getTunnelPath = (snake0: Snake, tunnel: Point[]) => { 11 | const chain: Snake[] = []; 12 | let snake = snake0; 13 | 14 | for (let i = 1; i < tunnel.length; i++) { 15 | const dx = tunnel[i].x - getHeadX(snake); 16 | const dy = tunnel[i].y - getHeadY(snake); 17 | snake = nextSnake(snake, dx, dy); 18 | chain.unshift(snake); 19 | } 20 | 21 | return chain; 22 | }; 23 | 24 | /** 25 | * assuming the grid change and the colors got deleted, update the tunnel 26 | */ 27 | export const updateTunnel = ( 28 | grid: Grid, 29 | tunnel: Point[], 30 | toDelete: Point[] 31 | ) => { 32 | while (tunnel.length) { 33 | const { x, y } = tunnel[0]; 34 | if ( 35 | isEmptySafe(grid, x, y) || 36 | toDelete.some((p) => p.x === x && p.y === y) 37 | ) { 38 | tunnel.shift(); 39 | } else break; 40 | } 41 | 42 | while (tunnel.length) { 43 | const { x, y } = tunnel[tunnel.length - 1]; 44 | if ( 45 | isEmptySafe(grid, x, y) || 46 | toDelete.some((p) => p.x === x && p.y === y) 47 | ) { 48 | tunnel.pop(); 49 | } else break; 50 | } 51 | }; 52 | 53 | const isEmptySafe = (grid: Grid, x: number, y: number) => 54 | !isInside(grid, x, y) || isEmpty(getColor(grid, x, y)); 55 | 56 | /** 57 | * remove empty cell from start 58 | */ 59 | export const trimTunnelStart = (grid: Grid, tunnel: Point[]) => { 60 | while (tunnel.length) { 61 | const { x, y } = tunnel[0]; 62 | if (isEmptySafe(grid, x, y)) tunnel.shift(); 63 | else break; 64 | } 65 | }; 66 | 67 | /** 68 | * remove empty cell from end 69 | */ 70 | export const trimTunnelEnd = (grid: Grid, tunnel: Point[]) => { 71 | while (tunnel.length) { 72 | const i = tunnel.length - 1; 73 | const { x, y } = tunnel[i]; 74 | if ( 75 | isEmptySafe(grid, x, y) || 76 | tunnel.findIndex((p) => p.x === x && p.y === y) < i 77 | ) 78 | tunnel.pop(); 79 | else break; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /packages/gif-creator/__tests__/createGif.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { AnimationOptions, createGif } from ".."; 4 | import * as grids from "@snk/types/__fixtures__/grid"; 5 | import { snake3 as snake } from "@snk/types/__fixtures__/snake"; 6 | import { createSnakeFromCells, nextSnake } from "@snk/types/snake"; 7 | import { getBestRoute } from "@snk/solver/getBestRoute"; 8 | import type { Options as DrawOptions } from "@snk/draw/drawWorld"; 9 | 10 | jest.setTimeout(20 * 1000); 11 | 12 | const upscale = 1; 13 | const drawOptions: DrawOptions = { 14 | sizeDotBorderRadius: 2 * upscale, 15 | sizeCell: 16 * upscale, 16 | sizeDot: 12 * upscale, 17 | colorDotBorder: "#1b1f230a", 18 | colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, 19 | colorEmpty: "#ebedf0", 20 | colorSnake: "purple", 21 | }; 22 | 23 | const animationOptions: AnimationOptions = { frameDuration: 200, step: 1 }; 24 | 25 | const dir = path.resolve(__dirname, "__snapshots__"); 26 | 27 | try { 28 | fs.mkdirSync(dir); 29 | } catch (err) {} 30 | 31 | for (const key of [ 32 | "empty", 33 | "simple", 34 | "corner", 35 | "small", 36 | "smallPacked", 37 | ] as const) 38 | it(`should generate ${key} gif`, async () => { 39 | const grid = grids[key]; 40 | 41 | const chain = [snake, ...getBestRoute(grid, snake)!]; 42 | 43 | const gif = await createGif( 44 | grid, 45 | null, 46 | chain, 47 | drawOptions, 48 | animationOptions 49 | ); 50 | 51 | expect(gif).toBeDefined(); 52 | 53 | fs.writeFileSync(path.resolve(dir, key + ".gif"), gif); 54 | }); 55 | 56 | it(`should generate swipper`, async () => { 57 | const grid = grids.smallFull; 58 | let snk = createSnakeFromCells( 59 | Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 })) 60 | ); 61 | 62 | const chain = [snk]; 63 | for (let y = -1; y < grid.height; y++) { 64 | snk = nextSnake(snk, 0, 1); 65 | chain.push(snk); 66 | 67 | for (let x = grid.width - 1; x--; ) { 68 | snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0); 69 | chain.push(snk); 70 | } 71 | } 72 | 73 | const gif = await createGif(grid, null, chain, drawOptions, animationOptions); 74 | 75 | expect(gif).toBeDefined(); 76 | 77 | fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/demo/demo.getBestTunnel.ts: -------------------------------------------------------------------------------- 1 | import "./menu"; 2 | import { createCanvas } from "./canvas"; 3 | import { getSnakeLength } from "@snk/types/snake"; 4 | import { grid, snake } from "./sample"; 5 | import { getColor } from "@snk/types/grid"; 6 | import { getBestTunnel } from "@snk/solver/getBestTunnel"; 7 | import { createOutside } from "@snk/solver/outside"; 8 | import type { Color } from "@snk/types/grid"; 9 | import type { Point } from "@snk/types/point"; 10 | 11 | const { canvas, ctx, draw, highlightCell } = createCanvas(grid); 12 | document.body.appendChild(canvas); 13 | 14 | const ones: Point[] = []; 15 | 16 | for (let x = 0; x < grid.width; x++) 17 | for (let y = 0; y < grid.height; y++) 18 | if (getColor(grid, x, y) === 1) ones.push({ x, y }); 19 | 20 | const tunnels = ones.map(({ x, y }) => ({ 21 | x, 22 | y, 23 | tunnel: getBestTunnel( 24 | grid, 25 | createOutside(grid), 26 | x, 27 | y, 28 | 3 as Color, 29 | getSnakeLength(snake) 30 | ), 31 | })); 32 | 33 | const onChange = () => { 34 | const k = +inputK.value; 35 | const i = +inputI.value; 36 | 37 | ctx.clearRect(0, 0, 9999, 9999); 38 | 39 | if (!tunnels[k]) return; 40 | 41 | const { x, y, tunnel } = tunnels[k]!; 42 | 43 | draw(grid, snake, []); 44 | 45 | highlightCell(x, y, "red"); 46 | 47 | if (tunnel) { 48 | tunnel.forEach(({ x, y }) => highlightCell(x, y)); 49 | highlightCell(x, y, "red"); 50 | highlightCell(tunnel[i].x, tunnel[i].y, "blue"); 51 | } 52 | }; 53 | 54 | const inputK = document.createElement("input") as any; 55 | inputK.type = "range"; 56 | inputK.value = 0; 57 | inputK.step = 1; 58 | inputK.min = 0; 59 | inputK.max = tunnels ? tunnels.length - 1 : 0; 60 | inputK.style.width = "90%"; 61 | inputK.style.padding = "20px 0"; 62 | inputK.addEventListener("input", () => { 63 | inputI.value = 0; 64 | inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1; 65 | onChange(); 66 | }); 67 | document.body.append(inputK); 68 | 69 | const inputI = document.createElement("input") as any; 70 | inputI.type = "range"; 71 | inputI.value = 0; 72 | inputI.step = 1; 73 | inputI.min = 0; 74 | inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1; 75 | inputI.style.width = "90%"; 76 | inputI.style.padding = "20px 0"; 77 | inputI.addEventListener("input", onChange); 78 | document.body.append(inputI); 79 | 80 | onChange(); 81 | -------------------------------------------------------------------------------- /packages/draw/drawCircleStack.ts: -------------------------------------------------------------------------------- 1 | import { pathRoundedRect } from "./pathRoundedRect"; 2 | import type { Color } from "@snk/types/grid"; 3 | import type { Point } from "@snk/types/point"; 4 | 5 | type Options = { 6 | colorDots: Record; 7 | colorBorder: string; 8 | sizeCell: number; 9 | sizeDot: number; 10 | sizeBorderRadius: number; 11 | }; 12 | 13 | const isInsideCircle = (x: number, y: number, r: number) => { 14 | const l = 6; 15 | let k = 0; 16 | for (let dx = 0; dx < l; dx++) 17 | for (let dy = 0; dy < l; dy++) { 18 | const ux = x + (dx + 0.5) / l; 19 | const uy = y + (dy + 0.5) / l; 20 | 21 | if (ux * ux + uy * uy < r * r) k++; 22 | } 23 | 24 | return k > l * l * 0.6; 25 | }; 26 | 27 | export const getCellPath = (n: number): Point[] => { 28 | const l = Math.ceil(Math.sqrt(n)); 29 | 30 | const cells = []; 31 | 32 | for (let x = -l; x <= l; x++) 33 | for (let y = -l; y <= l; y++) { 34 | const a = (Math.atan2(y, x) + (5 * Math.PI) / 2) % (Math.PI * 2); 35 | 36 | let r = 0; 37 | 38 | while (!isInsideCircle(x, y, r + 0.5)) r++; 39 | 40 | cells.push({ x, y, f: r * 100 + a }); 41 | } 42 | 43 | return cells.sort((a, b) => a.f - b.f).slice(0, n); 44 | }; 45 | 46 | export const cellPath = getCellPath(52 * 7 + 5); 47 | 48 | export const getCircleSize = (n: number) => { 49 | const c = cellPath.slice(0, n); 50 | const xs = c.map((p) => p.x); 51 | const ys = c.map((p) => p.y); 52 | 53 | return { 54 | max: { x: Math.max(0, ...xs), y: Math.max(0, ...ys) }, 55 | min: { x: Math.min(0, ...xs), y: Math.min(0, ...ys) }, 56 | }; 57 | }; 58 | 59 | export const drawCircleStack = ( 60 | ctx: CanvasRenderingContext2D, 61 | stack: Color[], 62 | o: Options 63 | ) => { 64 | for (let i = stack.length; i--; ) { 65 | const { x, y } = cellPath[i]; 66 | 67 | ctx.save(); 68 | ctx.translate( 69 | x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, 70 | y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2 71 | ); 72 | 73 | //@ts-ignore 74 | ctx.fillStyle = o.colorDots[stack[i]]; 75 | ctx.strokeStyle = o.colorBorder; 76 | ctx.lineWidth = 1; 77 | ctx.beginPath(); 78 | 79 | pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius); 80 | 81 | ctx.fill(); 82 | ctx.stroke(); 83 | ctx.closePath(); 84 | ctx.restore(); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /packages/types/__fixtures__/grid.ts: -------------------------------------------------------------------------------- 1 | import ParkMiller from "park-miller"; 2 | import { Color, createEmptyGrid, setColor } from "../grid"; 3 | import { randomlyFillGrid } from "../randomlyFillGrid"; 4 | import { createFromAscii } from "./createFromAscii"; 5 | 6 | const colors = [1, 2, 3] as Color[]; 7 | 8 | // empty small grid 9 | export const empty = createEmptyGrid(5, 5); 10 | 11 | // empty small grid with a unique color at the middle 12 | export const simple = createEmptyGrid(5, 5); 13 | setColor(simple, 2, 2, 1 as Color); 14 | 15 | // empty small grid with color at each corner 16 | export const corner = createEmptyGrid(5, 5); 17 | setColor(corner, 0, 4, 1 as Color); 18 | setColor(corner, 4, 0, 1 as Color); 19 | setColor(corner, 4, 4, 1 as Color); 20 | setColor(corner, 0, 0, 1 as Color); 21 | 22 | export const enclaveN = createFromAscii(` 23 | 24 | #.# 25 | # 26 | 27 | `); 28 | export const enclaveBorder = createFromAscii(` 29 | #.# 30 | # 31 | 32 | `); 33 | export const enclaveM = createFromAscii(` 34 | 35 | ### 36 | # # 37 | # . # 38 | # # 39 | # # 40 | `); 41 | 42 | export const enclaveK = createFromAscii(` 43 | 44 | #### 45 | # .# 46 | # # 47 | # # 48 | # # 49 | `); 50 | export const enclaveU = createFromAscii(` 51 | 52 | #### 53 | #..# 54 | #..# 55 | #.# 56 | # # . 57 | `); 58 | export const closedP = createFromAscii(` 59 | 60 | ### 61 | ##.# 62 | ## # 63 | ## 64 | `); 65 | export const closedU = createFromAscii(` 66 | 67 | #### 68 | #..# 69 | #..# 70 | #.# 71 | ### 72 | `); 73 | export const closedO = createFromAscii(` 74 | 75 | ####### 76 | # # 77 | # . # 78 | # # 79 | ####### 80 | `); 81 | export const tunnels = createFromAscii(` 82 | 83 | ### ### ### 84 | #.# #.# #.# 85 | #.# ### # # 86 | `); 87 | 88 | const createRandom = (width: number, height: number, emptyP: number) => { 89 | const grid = createEmptyGrid(width, height); 90 | const pm = new ParkMiller(10); 91 | const random = pm.integerInRange.bind(pm); 92 | randomlyFillGrid(grid, { colors, emptyP }, random); 93 | return grid; 94 | }; 95 | 96 | // small realistic 97 | export const small = createRandom(10, 7, 3); 98 | export const smallPacked = createRandom(10, 7, 1); 99 | export const smallFull = createRandom(10, 7, 0); 100 | 101 | // small realistic 102 | export const realistic = createRandom(52, 7, 3); 103 | export const realisticFull = createRandom(52, 7, 0); 104 | -------------------------------------------------------------------------------- /packages/demo/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import HtmlWebpackPlugin from "html-webpack-plugin"; 3 | import webpack from "webpack"; 4 | import { getGithubUserContribution } from "@snk/github-user-contribution"; 5 | import { config } from "dotenv"; 6 | import type { Configuration as WebpackConfiguration } from "webpack"; 7 | import { 8 | ExpressRequestHandler, 9 | type Configuration as WebpackDevServerConfiguration, 10 | } from "webpack-dev-server"; 11 | config({ path: __dirname + "/../../.env" }); 12 | 13 | const demos: string[] = require("./demo.json"); 14 | 15 | const webpackDevServerConfiguration: WebpackDevServerConfiguration = { 16 | open: { target: demos[1] + ".html" }, 17 | setupMiddlewares: (ms) => [ 18 | ...ms, 19 | (async (req, res, next) => { 20 | const userName = req.url.match( 21 | /\/api\/github-user-contribution\/(\w+)/ 22 | )?.[1]; 23 | if (userName) 24 | res.send( 25 | await getGithubUserContribution(userName, { 26 | githubToken: process.env.GITHUB_TOKEN!, 27 | }) 28 | ); 29 | else next(); 30 | }) as ExpressRequestHandler, 31 | ], 32 | }; 33 | 34 | const webpackConfiguration: WebpackConfiguration = { 35 | mode: "development", 36 | entry: Object.fromEntries( 37 | demos.map((demo: string) => [demo, `./demo.${demo}`]) 38 | ), 39 | target: ["web", "es2019"], 40 | resolve: { extensions: [".ts", ".js"] }, 41 | output: { 42 | path: path.join(__dirname, "dist"), 43 | filename: "[contenthash].js", 44 | }, 45 | module: { 46 | rules: [ 47 | { 48 | exclude: /node_modules/, 49 | test: /\.ts$/, 50 | loader: "ts-loader", 51 | options: { 52 | transpileOnly: true, 53 | compilerOptions: { 54 | lib: ["dom", "es2020"], 55 | target: "es2019", 56 | }, 57 | }, 58 | }, 59 | ], 60 | }, 61 | plugins: [ 62 | ...demos.map( 63 | (demo) => 64 | new HtmlWebpackPlugin({ 65 | title: "snk - " + demo, 66 | filename: `${demo}.html`, 67 | chunks: [demo], 68 | }) 69 | ), 70 | new HtmlWebpackPlugin({ 71 | title: "snk - " + demos[0], 72 | filename: `index.html`, 73 | chunks: [demos[0]], 74 | }), 75 | new webpack.EnvironmentPlugin({ 76 | GITHUB_USER_CONTRIBUTION_API_ENDPOINT: 77 | process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT ?? 78 | "/api/github-user-contribution/", 79 | }), 80 | ], 81 | 82 | devtool: false, 83 | }; 84 | 85 | export default { 86 | ...webpackConfiguration, 87 | devServer: webpackDevServerConfiguration, 88 | }; 89 | -------------------------------------------------------------------------------- /packages/gif-creator/__tests__/benchmark.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { performance } from "perf_hooks"; 3 | import { createSnakeFromCells } from "@snk/types/snake"; 4 | import { realistic as grid } from "@snk/types/__fixtures__/grid"; 5 | import { AnimationOptions, createGif } from ".."; 6 | import { getBestRoute } from "@snk/solver/getBestRoute"; 7 | import { getPathToPose } from "@snk/solver/getPathToPose"; 8 | import type { Options as DrawOptions } from "@snk/draw/drawWorld"; 9 | 10 | let snake = createSnakeFromCells( 11 | Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 })) 12 | ); 13 | 14 | // const chain = [snake]; 15 | // for (let y = -1; y < grid.height; y++) { 16 | // snake = nextSnake(snake, 0, 1); 17 | // chain.push(snake); 18 | 19 | // for (let x = grid.width - 1; x--; ) { 20 | // snake = nextSnake(snake, (y + 100) % 2 ? 1 : -1, 0); 21 | // chain.push(snake); 22 | // } 23 | // } 24 | 25 | const chain = getBestRoute(grid, snake)!; 26 | chain.push(...getPathToPose(chain.slice(-1)[0], snake)!); 27 | 28 | const drawOptions: DrawOptions = { 29 | sizeDotBorderRadius: 2, 30 | sizeCell: 16, 31 | sizeDot: 12, 32 | colorDotBorder: "#1b1f230a", 33 | colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" }, 34 | colorEmpty: "#ebedf0", 35 | colorSnake: "purple", 36 | }; 37 | 38 | const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 }; 39 | 40 | (async () => { 41 | for ( 42 | let length = 10; 43 | length < chain.length; 44 | length += Math.floor((chain.length - 10) / 3 / 10) * 10 45 | ) { 46 | const stats: number[] = []; 47 | 48 | let buffer: Buffer; 49 | const start = Date.now(); 50 | const chainL = chain.slice(0, length); 51 | for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) { 52 | const s = performance.now(); 53 | buffer = await createGif( 54 | grid, 55 | null, 56 | chainL, 57 | drawOptions, 58 | animationOptions 59 | ); 60 | stats.push(performance.now() - s); 61 | } 62 | 63 | console.log( 64 | [ 65 | "---", 66 | `grid dimension: ${grid.width}x${grid.height}`, 67 | `chain length: ${length}`, 68 | `resulting size: ${(buffer!.length / 1024).toFixed(1)}ko`, 69 | `generation duration (mean): ${( 70 | stats.reduce((s, x) => x + s) / stats.length 71 | ).toLocaleString(undefined, { 72 | maximumFractionDigits: 0, 73 | })}ms`, 74 | "", 75 | ].join("\n"), 76 | stats 77 | ); 78 | 79 | fs.writeFileSync( 80 | `__tests__/__snapshots__/benchmark-output-${length}.gif`, 81 | buffer! 82 | ); 83 | } 84 | })(); 85 | -------------------------------------------------------------------------------- /packages/draw/drawWorld.ts: -------------------------------------------------------------------------------- 1 | import { drawGrid } from "./drawGrid"; 2 | import { drawSnake, drawSnakeLerp } from "./drawSnake"; 3 | import type { Grid, Color } from "@snk/types/grid"; 4 | import type { Snake } from "@snk/types/snake"; 5 | import type { Point } from "@snk/types/point"; 6 | 7 | export type Options = { 8 | colorDots: Record; 9 | colorEmpty: string; 10 | colorDotBorder: string; 11 | colorSnake: string; 12 | sizeCell: number; 13 | sizeDot: number; 14 | sizeDotBorderRadius: number; 15 | }; 16 | 17 | export const drawStack = ( 18 | ctx: CanvasRenderingContext2D, 19 | stack: Color[], 20 | max: number, 21 | width: number, 22 | o: { colorDots: Record } 23 | ) => { 24 | ctx.save(); 25 | 26 | const m = width / max; 27 | 28 | for (let i = 0; i < stack.length; i++) { 29 | // @ts-ignore 30 | ctx.fillStyle = o.colorDots[stack[i]]; 31 | ctx.fillRect(i * m, 0, m + width * 0.005, 10); 32 | } 33 | ctx.restore(); 34 | }; 35 | 36 | export const drawWorld = ( 37 | ctx: CanvasRenderingContext2D, 38 | grid: Grid, 39 | cells: Point[] | null, 40 | snake: Snake, 41 | stack: Color[], 42 | o: Options 43 | ) => { 44 | ctx.save(); 45 | 46 | ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); 47 | drawGrid(ctx, grid, cells, o); 48 | drawSnake(ctx, snake, o); 49 | 50 | ctx.restore(); 51 | 52 | ctx.save(); 53 | 54 | ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell); 55 | 56 | const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length); 57 | drawStack(ctx, stack, max, grid.width * o.sizeCell, o); 58 | 59 | ctx.restore(); 60 | 61 | // ctx.save(); 62 | // ctx.translate(o.sizeCell + 100, (grid.height + 4) * o.sizeCell + 100); 63 | // ctx.scale(0.6, 0.6); 64 | // drawCircleStack(ctx, stack, o); 65 | // ctx.restore(); 66 | }; 67 | 68 | export const drawLerpWorld = ( 69 | ctx: CanvasRenderingContext2D | CanvasRenderingContext2D, 70 | grid: Grid, 71 | cells: Point[] | null, 72 | snake0: Snake, 73 | snake1: Snake, 74 | stack: Color[], 75 | k: number, 76 | o: Options 77 | ) => { 78 | ctx.save(); 79 | 80 | ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); 81 | drawGrid(ctx, grid, cells, o); 82 | drawSnakeLerp(ctx, snake0, snake1, k, o); 83 | 84 | ctx.translate(0, (grid.height + 2) * o.sizeCell); 85 | 86 | const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length); 87 | drawStack(ctx, stack, max, grid.width * o.sizeCell, o); 88 | 89 | ctx.restore(); 90 | }; 91 | 92 | export const getCanvasWorldSize = (grid: Grid, o: { sizeCell: number }) => { 93 | const width = o.sizeCell * (grid.width + 2); 94 | const height = o.sizeCell * (grid.height + 4) + 30; 95 | 96 | return { width, height }; 97 | }; 98 | -------------------------------------------------------------------------------- /packages/action/outputsOptions.ts: -------------------------------------------------------------------------------- 1 | import type { AnimationOptions } from "@snk/gif-creator"; 2 | import type { DrawOptions as DrawOptions } from "@snk/svg-creator"; 3 | import { palettes } from "./palettes"; 4 | 5 | export const parseOutputsOption = (lines: string[]) => lines.map(parseEntry); 6 | 7 | export const parseEntry = (entry: string) => { 8 | const m = entry.trim().match(/^(.+\.(svg|gif))(\?(.*)|\s*({.*}))?$/); 9 | 10 | if (!m) return null; 11 | 12 | const [, filename, format, _, q1, q2] = m; 13 | 14 | const query = q1 ?? q2; 15 | 16 | let sp = new URLSearchParams(query || ""); 17 | 18 | try { 19 | const o = JSON.parse(query); 20 | 21 | if (Array.isArray(o.color_dots)) o.color_dots = o.color_dots.join(","); 22 | if (Array.isArray(o.dark_color_dots)) 23 | o.dark_color_dots = o.dark_color_dots.join(","); 24 | 25 | sp = new URLSearchParams(o); 26 | } catch (err) { 27 | if (!(err instanceof SyntaxError)) throw err; 28 | } 29 | 30 | const drawOptions: DrawOptions = { 31 | sizeDotBorderRadius: 2, 32 | sizeCell: 16, 33 | sizeDot: 12, 34 | ...palettes["default"], 35 | dark: palettes["default"].dark && { ...palettes["default"].dark }, 36 | }; 37 | const animationOptions: AnimationOptions = { step: 1, frameDuration: 100 }; 38 | 39 | { 40 | const palette = palettes[sp.get("palette")!]; 41 | if (palette) { 42 | Object.assign(drawOptions, palette); 43 | drawOptions.dark = palette.dark && { ...palette.dark }; 44 | } 45 | } 46 | 47 | { 48 | const dark_palette = palettes[sp.get("dark_palette")!]; 49 | if (dark_palette) { 50 | const clone = { ...dark_palette, dark: undefined }; 51 | drawOptions.dark = clone; 52 | } 53 | } 54 | 55 | if (sp.has("color_snake")) drawOptions.colorSnake = sp.get("color_snake")!; 56 | if (sp.has("color_dots")) { 57 | const colors = sp.get("color_dots")!.split(/[,;]/); 58 | drawOptions.colorDots = colors; 59 | drawOptions.colorEmpty = colors[0]; 60 | drawOptions.dark = undefined; 61 | } 62 | if (sp.has("color_dot_border")) 63 | drawOptions.colorDotBorder = sp.get("color_dot_border")!; 64 | 65 | if (sp.has("dark_color_dots")) { 66 | const colors = sp.get("dark_color_dots")!.split(/[,;]/); 67 | drawOptions.dark = { 68 | colorDotBorder: drawOptions.colorDotBorder, 69 | colorSnake: drawOptions.colorSnake, 70 | ...drawOptions.dark, 71 | colorDots: colors, 72 | colorEmpty: colors[0], 73 | }; 74 | } 75 | if (sp.has("dark_color_dot_border") && drawOptions.dark) 76 | drawOptions.dark.colorDotBorder = sp.get("color_dot_border")!; 77 | if (sp.has("dark_color_snake") && drawOptions.dark) 78 | drawOptions.dark.colorSnake = sp.get("color_snake")!; 79 | 80 | return { 81 | filename, 82 | format: format as "svg" | "gif", 83 | drawOptions, 84 | animationOptions, 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /packages/svg-creator/snake.ts: -------------------------------------------------------------------------------- 1 | import { getSnakeLength, snakeToCells } from "@snk/types/snake"; 2 | import type { Snake } from "@snk/types/snake"; 3 | import type { Point } from "@snk/types/point"; 4 | import { h } from "./xml-utils"; 5 | import { createAnimation } from "./css-utils"; 6 | 7 | export type Options = { 8 | colorSnake: string; 9 | sizeCell: number; 10 | sizeDot: number; 11 | }; 12 | 13 | const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b; 14 | 15 | export const createSnake = ( 16 | chain: Snake[], 17 | { sizeCell, sizeDot }: Options, 18 | duration: number 19 | ) => { 20 | const snakeN = chain[0] ? getSnakeLength(chain[0]) : 0; 21 | 22 | const snakeParts: Point[][] = Array.from({ length: snakeN }, () => []); 23 | 24 | for (const snake of chain) { 25 | const cells = snakeToCells(snake); 26 | for (let i = cells.length; i--; ) snakeParts[i].push(cells[i]); 27 | } 28 | 29 | const svgElements = snakeParts.map((_, i, { length }) => { 30 | // compute snake part size 31 | const dMin = sizeDot * 0.8; 32 | const dMax = sizeCell * 0.9; 33 | const iMax = Math.min(4, length); 34 | const u = (1 - Math.min(i, iMax) / iMax) ** 2; 35 | const s = lerp(u, dMin, dMax); 36 | 37 | const m = (sizeCell - s) / 2; 38 | 39 | const r = Math.min(4.5, (4 * s) / sizeDot); 40 | 41 | return h("rect", { 42 | class: `s s${i}`, 43 | x: m.toFixed(1), 44 | y: m.toFixed(1), 45 | width: s.toFixed(1), 46 | height: s.toFixed(1), 47 | rx: r.toFixed(1), 48 | ry: r.toFixed(1), 49 | }); 50 | }); 51 | 52 | const transform = ({ x, y }: Point) => 53 | `transform:translate(${x * sizeCell}px,${y * sizeCell}px)`; 54 | 55 | const styles = [ 56 | `.s{ 57 | shape-rendering: geometricPrecision; 58 | fill: var(--cs); 59 | animation: none linear ${duration}ms infinite 60 | }`, 61 | 62 | ...snakeParts.map((positions, i) => { 63 | const id = `s${i}`; 64 | const animationName = id; 65 | 66 | const keyframes = removeInterpolatedPositions( 67 | positions.map((tr, i, { length }) => ({ ...tr, t: i / length })) 68 | ).map(({ t, ...p }) => ({ t, style: transform(p) })); 69 | 70 | return [ 71 | createAnimation(animationName, keyframes), 72 | 73 | `.s.${id}{ 74 | ${transform(positions[0])}; 75 | animation-name: ${animationName} 76 | }`, 77 | ]; 78 | }), 79 | ].flat(); 80 | 81 | return { svgElements, styles }; 82 | }; 83 | 84 | const removeInterpolatedPositions = (arr: T[]) => 85 | arr.filter((u, i, arr) => { 86 | if (i - 1 < 0 || i + 1 >= arr.length) return true; 87 | 88 | const a = arr[i - 1]; 89 | const b = arr[i + 1]; 90 | 91 | const ex = (a.x + b.x) / 2; 92 | const ey = (a.y + b.y) / 2; 93 | 94 | // return true; 95 | return !(Math.abs(ex - u.x) < 0.01 && Math.abs(ey - u.y) < 0.01); 96 | }); 97 | -------------------------------------------------------------------------------- /packages/action/__tests__/__snapshots__/outputsOptions.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should parse /out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]} 1`] = ` 4 | { 5 | "animationOptions": { 6 | "frameDuration": 100, 7 | "step": 1, 8 | }, 9 | "drawOptions": { 10 | "colorDotBorder": "#1b1f230a", 11 | "colorDots": [ 12 | "#000", 13 | "#111", 14 | "#222", 15 | "#333", 16 | "#444", 17 | ], 18 | "colorEmpty": "#000", 19 | "colorSnake": "yellow", 20 | "dark": undefined, 21 | "sizeCell": 16, 22 | "sizeDot": 12, 23 | "sizeDotBorderRadius": 2, 24 | }, 25 | "filename": "/out.svg", 26 | "format": "svg", 27 | } 28 | `; 29 | 30 | exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444 1`] = ` 31 | { 32 | "animationOptions": { 33 | "frameDuration": 100, 34 | "step": 1, 35 | }, 36 | "drawOptions": { 37 | "colorDotBorder": "#1b1f230a", 38 | "colorDots": [ 39 | "#000", 40 | "#111", 41 | "#222", 42 | "#333", 43 | "#444", 44 | ], 45 | "colorEmpty": "#000", 46 | "colorSnake": "orange", 47 | "dark": undefined, 48 | "sizeCell": 16, 49 | "sizeDot": 12, 50 | "sizeDotBorderRadius": 2, 51 | }, 52 | "filename": "/out.svg", 53 | "format": "svg", 54 | } 55 | `; 56 | 57 | exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44 1`] = ` 58 | { 59 | "animationOptions": { 60 | "frameDuration": 100, 61 | "step": 1, 62 | }, 63 | "drawOptions": { 64 | "colorDotBorder": "#1b1f230a", 65 | "colorDots": [ 66 | "#000", 67 | "#111", 68 | "#222", 69 | "#333", 70 | "#444", 71 | ], 72 | "colorEmpty": "#000", 73 | "colorSnake": "orange", 74 | "dark": { 75 | "colorDotBorder": "#1b1f230a", 76 | "colorDots": [ 77 | "#a00", 78 | "#a11", 79 | "#a22", 80 | "#a33", 81 | "#a44", 82 | ], 83 | "colorEmpty": "#a00", 84 | "colorSnake": "orange", 85 | }, 86 | "sizeCell": 16, 87 | "sizeDot": 12, 88 | "sizeDotBorderRadius": 2, 89 | }, 90 | "filename": "/out.svg", 91 | "format": "svg", 92 | } 93 | `; 94 | 95 | exports[`should parse path/to/out.gif 1`] = ` 96 | { 97 | "animationOptions": { 98 | "frameDuration": 100, 99 | "step": 1, 100 | }, 101 | "drawOptions": { 102 | "colorDotBorder": "#1b1f230a", 103 | "colorDots": [ 104 | "#ebedf0", 105 | "#9be9a8", 106 | "#40c463", 107 | "#30a14e", 108 | "#216e39", 109 | ], 110 | "colorEmpty": "#ebedf0", 111 | "colorSnake": "purple", 112 | "dark": undefined, 113 | "sizeCell": 16, 114 | "sizeDot": 12, 115 | "sizeDotBorderRadius": 2, 116 | }, 117 | "filename": "path/to/out.gif", 118 | "format": "gif", 119 | } 120 | `; 121 | -------------------------------------------------------------------------------- /packages/gif-creator/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { execFileSync } from "child_process"; 4 | import { createCanvas } from "canvas"; 5 | import { Grid, copyGrid, Color } from "@snk/types/grid"; 6 | import { Snake } from "@snk/types/snake"; 7 | import { 8 | Options as DrawOptions, 9 | drawLerpWorld, 10 | getCanvasWorldSize, 11 | } from "@snk/draw/drawWorld"; 12 | import type { Point } from "@snk/types/point"; 13 | import { step } from "@snk/solver/step"; 14 | import tmp from "tmp"; 15 | import gifsicle from "gifsicle"; 16 | // @ts-ignore 17 | import GIFEncoder from "gif-encoder-2"; 18 | 19 | const withTmpDir = async ( 20 | handler: (dir: string) => Promise 21 | ): Promise => { 22 | const { name: dir, removeCallback: cleanUp } = tmp.dirSync({ 23 | unsafeCleanup: true, 24 | }); 25 | 26 | try { 27 | return await handler(dir); 28 | } finally { 29 | cleanUp(); 30 | } 31 | }; 32 | 33 | export type AnimationOptions = { frameDuration: number; step: number }; 34 | 35 | export const createGif = async ( 36 | grid0: Grid, 37 | cells: Point[] | null, 38 | chain: Snake[], 39 | drawOptions: DrawOptions, 40 | animationOptions: AnimationOptions 41 | ) => 42 | withTmpDir(async (dir) => { 43 | const { width, height } = getCanvasWorldSize(grid0, drawOptions); 44 | 45 | const canvas = createCanvas(width, height); 46 | const ctx = canvas.getContext("2d") as any as CanvasRenderingContext2D; 47 | 48 | const grid = copyGrid(grid0); 49 | const stack: Color[] = []; 50 | 51 | const encoder = new GIFEncoder(width, height, "neuquant", true); 52 | encoder.setRepeat(0); 53 | encoder.setDelay(animationOptions.frameDuration); 54 | encoder.start(); 55 | 56 | for (let i = 0; i < chain.length; i += 1) { 57 | const snake0 = chain[i]; 58 | const snake1 = chain[Math.min(chain.length - 1, i + 1)]; 59 | step(grid, stack, snake0); 60 | 61 | for (let k = 0; k < animationOptions.step; k++) { 62 | ctx.clearRect(0, 0, width, height); 63 | ctx.fillStyle = "#fff"; 64 | ctx.fillRect(0, 0, width, height); 65 | drawLerpWorld( 66 | ctx, 67 | grid, 68 | cells, 69 | snake0, 70 | snake1, 71 | stack, 72 | k / animationOptions.step, 73 | drawOptions 74 | ); 75 | 76 | encoder.addFrame(ctx); 77 | } 78 | } 79 | 80 | const outFileName = path.join(dir, "out.gif"); 81 | const optimizedFileName = path.join(dir, "out.optimized.gif"); 82 | 83 | encoder.finish(); 84 | fs.writeFileSync(outFileName, encoder.out.getData()); 85 | 86 | execFileSync( 87 | gifsicle, 88 | [ 89 | // 90 | "--optimize=3", 91 | "--color-method=diversity", 92 | "--colors=18", 93 | outFileName, 94 | ["--output", optimizedFileName], 95 | ].flat() 96 | ); 97 | 98 | return fs.readFileSync(optimizedFileName); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/demo/canvas.ts: -------------------------------------------------------------------------------- 1 | import { Color, Grid } from "@snk/types/grid"; 2 | import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld"; 3 | import { Snake } from "@snk/types/snake"; 4 | import type { DrawOptions as DrawOptions } from "@snk/svg-creator"; 5 | 6 | export const drawOptions: DrawOptions = { 7 | sizeDotBorderRadius: 2, 8 | sizeCell: 16, 9 | sizeDot: 12, 10 | colorDotBorder: "#1b1f230a", 11 | colorDots: { 12 | 1: "#9be9a8", 13 | 2: "#40c463", 14 | 3: "#30a14e", 15 | 4: "#216e39", 16 | }, 17 | colorEmpty: "#ebedf0", 18 | colorSnake: "purple", 19 | dark: { 20 | colorEmpty: "#161b22", 21 | colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" }, 22 | }, 23 | }; 24 | 25 | const getPointedCell = 26 | (canvas: HTMLCanvasElement) => 27 | ({ pageX, pageY }: MouseEvent) => { 28 | const { left, top } = canvas.getBoundingClientRect(); 29 | 30 | const x = Math.floor((pageX - left) / drawOptions.sizeCell) - 1; 31 | const y = Math.floor((pageY - top) / drawOptions.sizeCell) - 2; 32 | 33 | return { x, y }; 34 | }; 35 | 36 | export const createCanvas = ({ 37 | width, 38 | height, 39 | }: { 40 | width: number; 41 | height: number; 42 | }) => { 43 | const canvas = document.createElement("canvas"); 44 | const upscale = 2; 45 | const w = drawOptions.sizeCell * (width + 4); 46 | const h = drawOptions.sizeCell * (height + 4) + 200; 47 | canvas.width = w * upscale; 48 | canvas.height = h * upscale; 49 | canvas.style.width = w + "px"; 50 | canvas.style.height = h + "px"; 51 | canvas.style.display = "block"; 52 | // canvas.style.pointerEvents = "none"; 53 | 54 | const cellInfo = document.createElement("div"); 55 | cellInfo.style.height = "20px"; 56 | 57 | document.body.appendChild(cellInfo); 58 | document.body.appendChild(canvas); 59 | canvas.addEventListener("mousemove", (e) => { 60 | const { x, y } = getPointedCell(canvas)(e); 61 | cellInfo.innerText = [x, y] 62 | .map((u) => u.toString().padStart(2, " ")) 63 | .join(" / "); 64 | }); 65 | 66 | const ctx = canvas.getContext("2d")!; 67 | ctx.scale(upscale, upscale); 68 | 69 | const draw = (grid: Grid, snake: Snake, stack: Color[]) => { 70 | ctx.clearRect(0, 0, 9999, 9999); 71 | drawWorld(ctx, grid, null, snake, stack, drawOptions); 72 | }; 73 | 74 | const drawLerp = ( 75 | grid: Grid, 76 | snake0: Snake, 77 | snake1: Snake, 78 | stack: Color[], 79 | k: number 80 | ) => { 81 | ctx.clearRect(0, 0, 9999, 9999); 82 | drawLerpWorld(ctx, grid, null, snake0, snake1, stack, k, drawOptions); 83 | }; 84 | 85 | const highlightCell = (x: number, y: number, color = "orange") => { 86 | ctx.fillStyle = color; 87 | ctx.beginPath(); 88 | ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4); 89 | }; 90 | 91 | return { 92 | draw, 93 | drawLerp, 94 | highlightCell, 95 | canvas, 96 | getPointedCell: getPointedCell(canvas), 97 | ctx, 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /packages/solver/getPathToPose.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getHeadX, 3 | getHeadY, 4 | getSnakeLength, 5 | nextSnake, 6 | snakeEquals, 7 | snakeToCells, 8 | snakeWillSelfCollide, 9 | } from "@snk/types/snake"; 10 | import type { Snake } from "@snk/types/snake"; 11 | import { 12 | getColor, 13 | Grid, 14 | isEmpty, 15 | isInside, 16 | isInsideLarge, 17 | } from "@snk/types/grid"; 18 | import { getTunnelPath } from "./tunnel"; 19 | import { around4 } from "@snk/types/point"; 20 | import { sortPush } from "./utils/sortPush"; 21 | 22 | const isEmptySafe = (grid: Grid, x: number, y: number) => 23 | !isInside(grid, x, y) || isEmpty(getColor(grid, x, y)); 24 | 25 | type M = { snake: Snake; parent: M | null; w: number; f: number }; 26 | export const getPathToPose = (snake0: Snake, target: Snake, grid?: Grid) => { 27 | if (snakeEquals(snake0, target)) return []; 28 | 29 | const targetCells = snakeToCells(target).reverse(); 30 | 31 | const snakeN = getSnakeLength(snake0); 32 | const box = { 33 | min: { 34 | x: Math.min(getHeadX(snake0), getHeadX(target)) - snakeN - 1, 35 | y: Math.min(getHeadY(snake0), getHeadY(target)) - snakeN - 1, 36 | }, 37 | max: { 38 | x: Math.max(getHeadX(snake0), getHeadX(target)) + snakeN + 1, 39 | y: Math.max(getHeadY(snake0), getHeadY(target)) + snakeN + 1, 40 | }, 41 | }; 42 | 43 | const [t0, ...forbidden] = targetCells; 44 | 45 | forbidden.slice(0, 3); 46 | 47 | const openList: M[] = [{ snake: snake0, w: 0 } as any]; 48 | const closeList: Snake[] = []; 49 | 50 | while (openList.length) { 51 | const o = openList.shift()!; 52 | 53 | const x = getHeadX(o.snake); 54 | const y = getHeadY(o.snake); 55 | 56 | if (x === t0.x && y === t0.y) { 57 | const path: Snake[] = []; 58 | let e: M["parent"] = o; 59 | while (e) { 60 | path.push(e.snake); 61 | e = e.parent; 62 | } 63 | path.unshift(...getTunnelPath(path[0], targetCells)); 64 | path.pop(); 65 | path.reverse(); 66 | return path; 67 | } 68 | 69 | for (let i = 0; i < around4.length; i++) { 70 | const { x: dx, y: dy } = around4[i]; 71 | 72 | const nx = x + dx; 73 | const ny = y + dy; 74 | 75 | if ( 76 | !snakeWillSelfCollide(o.snake, dx, dy) && 77 | (!grid || isEmptySafe(grid, nx, ny)) && 78 | (grid 79 | ? isInsideLarge(grid, 2, nx, ny) 80 | : box.min.x <= nx && 81 | nx <= box.max.x && 82 | box.min.y <= ny && 83 | ny <= box.max.y) && 84 | !forbidden.some((p) => p.x === nx && p.y === ny) 85 | ) { 86 | const snake = nextSnake(o.snake, dx, dy); 87 | 88 | if (!closeList.some((s) => snakeEquals(snake, s))) { 89 | const w = o.w + 1; 90 | const h = Math.abs(nx - x) + Math.abs(ny - y); 91 | const f = w + h; 92 | 93 | sortPush(openList, { f, w, snake, parent: o }, (a, b) => a.f - b.f); 94 | closeList.push(snake); 95 | } 96 | } 97 | } 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: | 8 | New version for the release 9 | If the version is in format .. a new release is emitted. 10 | Otherwise for other format ( for example ..-beta.1 ), a prerelease is emitted. 11 | default: "0.0.1" 12 | required: true 13 | type: string 14 | description: 15 | description: "Version description" 16 | type: string 17 | 18 | jobs: 19 | release: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: write 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - uses: docker/setup-qemu-action@v2 27 | 28 | - uses: docker/setup-buildx-action@v2 29 | 30 | - uses: docker/login-action@v2 31 | with: 32 | username: ${{ secrets.DOCKERHUB_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | - name: build and publish the docker image 36 | uses: docker/build-push-action@v4 37 | id: docker-build 38 | with: 39 | push: true 40 | tags: | 41 | platane/snk:${{ github.sha }} 42 | platane/snk:${{ github.event.inputs.version }} 43 | 44 | - name: update action.yml to point to the newly created docker image 45 | run: | 46 | sed -i "s/image: .*/image: docker:\/\/platane\/snk@${{ steps.docker-build.outputs.digest }}/" action.yml 47 | 48 | - uses: actions/setup-node@v3 49 | with: 50 | cache: yarn 51 | node-version: 20 52 | 53 | - name: build svg-only action 54 | run: | 55 | yarn install --frozen-lockfile 56 | npm run build:action 57 | rm -r svg-only/dist 58 | mv packages/action/dist svg-only/dist 59 | 60 | - name: bump package version 61 | run: yarn version --no-git-tag-version --new-version ${{ github.event.inputs.version }} 62 | 63 | - name: push new build, tag version and push 64 | id: push-tags 65 | run: | 66 | VERSION=${{ github.event.inputs.version }} 67 | 68 | git config --global user.email "bot@platane.me" 69 | git config --global user.name "release bot" 70 | git add package.json svg-only/dist action.yml 71 | git commit -m "📦 $VERSION" 72 | git tag v$VERSION 73 | git push origin main --tags 74 | 75 | if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 76 | git tag v$( echo $VERSION | cut -d. -f 1-1 ) 77 | git tag v$( echo $VERSION | cut -d. -f 1-2 ) 78 | git push origin --tags --force 79 | echo "prerelease=false" >> $GITHUB_OUTPUT 80 | else 81 | echo "prerelease=true" >> $GITHUB_OUTPUT 82 | fi 83 | 84 | - uses: ncipollo/release-action@v1.12.0 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | with: 88 | tag: v${{ github.event.inputs.version }} 89 | body: ${{ github.event.inputs.description }} 90 | prerelease: ${{ steps.push-tags.outputs.prerelease }} 91 | -------------------------------------------------------------------------------- /packages/github-user-contribution/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * get the contribution grid from a github user page 3 | * 4 | * use options.from=YYYY-MM-DD options.to=YYYY-MM-DD to get the contribution grid for a specific time range 5 | * or year=2019 as an alias for from=2019-01-01 to=2019-12-31 6 | * 7 | * otherwise return use the time range from today minus one year to today ( as seen in github profile page ) 8 | * 9 | * @param userName github user name 10 | * @param options 11 | * 12 | * @example 13 | * getGithubUserContribution("platane", { from: "2019-01-01", to: "2019-12-31" }) 14 | * getGithubUserContribution("platane", { year: 2019 }) 15 | * 16 | */ 17 | export const getGithubUserContribution = async ( 18 | userName: string, 19 | o: { githubToken: string } 20 | ) => { 21 | const query = /* GraphQL */ ` 22 | query ($login: String!) { 23 | user(login: $login) { 24 | contributionsCollection { 25 | contributionCalendar { 26 | weeks { 27 | contributionDays { 28 | contributionCount 29 | contributionLevel 30 | weekday 31 | date 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | `; 39 | const variables = { login: userName }; 40 | 41 | const res = await fetch("https://api.github.com/graphql", { 42 | headers: { 43 | Authorization: `bearer ${o.githubToken}`, 44 | "Content-Type": "application/json", 45 | }, 46 | method: "POST", 47 | body: JSON.stringify({ variables, query }), 48 | }); 49 | 50 | if (!res.ok) throw new Error(res.statusText); 51 | 52 | const { data, errors } = (await res.json()) as { 53 | data: GraphQLRes; 54 | errors?: { message: string }[]; 55 | }; 56 | 57 | if (errors?.[0]) throw errors[0]; 58 | 59 | return data.user.contributionsCollection.contributionCalendar.weeks.flatMap( 60 | ({ contributionDays }, x) => 61 | contributionDays.map((d) => ({ 62 | x, 63 | y: d.weekday, 64 | date: d.date, 65 | count: d.contributionCount, 66 | level: 67 | (d.contributionLevel === "FOURTH_QUARTILE" && 4) || 68 | (d.contributionLevel === "THIRD_QUARTILE" && 3) || 69 | (d.contributionLevel === "SECOND_QUARTILE" && 2) || 70 | (d.contributionLevel === "FIRST_QUARTILE" && 1) || 71 | 0, 72 | })) 73 | ); 74 | }; 75 | 76 | type GraphQLRes = { 77 | user: { 78 | contributionsCollection: { 79 | contributionCalendar: { 80 | weeks: { 81 | contributionDays: { 82 | contributionCount: number; 83 | contributionLevel: 84 | | "FOURTH_QUARTILE" 85 | | "THIRD_QUARTILE" 86 | | "SECOND_QUARTILE" 87 | | "FIRST_QUARTILE" 88 | | "NONE"; 89 | date: string; 90 | weekday: number; 91 | }[]; 92 | }[]; 93 | }; 94 | }; 95 | }; 96 | }; 97 | 98 | export type Res = Awaited>; 99 | 100 | export type Cell = Res[number]; 101 | -------------------------------------------------------------------------------- /packages/solver/getBestTunnel.ts: -------------------------------------------------------------------------------- 1 | import { copyGrid, getColor, isInside, setColorEmpty } from "@snk/types/grid"; 2 | import { around4 } from "@snk/types/point"; 3 | import { sortPush } from "./utils/sortPush"; 4 | import { 5 | createSnakeFromCells, 6 | getHeadX, 7 | getHeadY, 8 | nextSnake, 9 | snakeEquals, 10 | snakeWillSelfCollide, 11 | } from "@snk/types/snake"; 12 | import { isOutside } from "./outside"; 13 | import { trimTunnelEnd, trimTunnelStart } from "./tunnel"; 14 | import type { Outside } from "./outside"; 15 | import type { Snake } from "@snk/types/snake"; 16 | import type { Empty, Color, Grid } from "@snk/types/grid"; 17 | import type { Point } from "@snk/types/point"; 18 | 19 | const getColorSafe = (grid: Grid, x: number, y: number) => 20 | isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty); 21 | 22 | const setEmptySafe = (grid: Grid, x: number, y: number) => { 23 | if (isInside(grid, x, y)) setColorEmpty(grid, x, y); 24 | }; 25 | 26 | type M = { snake: Snake; parent: M | null; w: number }; 27 | 28 | const unwrap = (m: M | null): Point[] => 29 | !m 30 | ? [] 31 | : [...unwrap(m.parent), { x: getHeadX(m.snake), y: getHeadY(m.snake) }]; 32 | 33 | /** 34 | * returns the path to reach the outside which contains the least color cell 35 | */ 36 | const getSnakeEscapePath = ( 37 | grid: Grid, 38 | outside: Outside, 39 | snake0: Snake, 40 | color: Color 41 | ) => { 42 | const openList: M[] = [{ snake: snake0, w: 0 } as any]; 43 | const closeList: Snake[] = []; 44 | 45 | while (openList[0]) { 46 | const o = openList.shift()!; 47 | 48 | const x = getHeadX(o.snake); 49 | const y = getHeadY(o.snake); 50 | 51 | if (isOutside(outside, x, y)) return unwrap(o); 52 | 53 | for (const a of around4) { 54 | const c = getColorSafe(grid, x + a.x, y + a.y); 55 | 56 | if (c <= color && !snakeWillSelfCollide(o.snake, a.x, a.y)) { 57 | const snake = nextSnake(o.snake, a.x, a.y); 58 | 59 | if (!closeList.some((s0) => snakeEquals(s0, snake))) { 60 | const w = o.w + 1 + +(c === color) * 1000; 61 | sortPush(openList, { snake, w, parent: o }, (a, b) => a.w - b.w); 62 | closeList.push(snake); 63 | } 64 | } 65 | } 66 | } 67 | 68 | return null; 69 | }; 70 | 71 | /** 72 | * compute the best tunnel to get to the cell and back to the outside ( best = less usage of ) 73 | * 74 | * notice that it's one of the best tunnels, more with the same score could exist 75 | */ 76 | export const getBestTunnel = ( 77 | grid: Grid, 78 | outside: Outside, 79 | x: number, 80 | y: number, 81 | color: Color, 82 | snakeN: number 83 | ) => { 84 | const c = { x, y }; 85 | const snake0 = createSnakeFromCells(Array.from({ length: snakeN }, () => c)); 86 | 87 | const one = getSnakeEscapePath(grid, outside, snake0, color); 88 | 89 | if (!one) return null; 90 | 91 | // get the position of the snake if it was going to leave the x,y cell 92 | const snakeICells = one.slice(0, snakeN); 93 | while (snakeICells.length < snakeN) 94 | snakeICells.push(snakeICells[snakeICells.length - 1]); 95 | const snakeI = createSnakeFromCells(snakeICells); 96 | 97 | // remove from the grid the colors that one eat 98 | const gridI = copyGrid(grid); 99 | for (const { x, y } of one) setEmptySafe(gridI, x, y); 100 | 101 | const two = getSnakeEscapePath(gridI, outside, snakeI, color); 102 | 103 | if (!two) return null; 104 | 105 | one.shift(); 106 | one.reverse(); 107 | one.push(...two); 108 | 109 | trimTunnelStart(grid, one); 110 | trimTunnelEnd(grid, one); 111 | 112 | return one; 113 | }; 114 | -------------------------------------------------------------------------------- /packages/solver/clearCleanColoredLayer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getColor, 3 | isEmpty, 4 | isInside, 5 | isInsideLarge, 6 | setColorEmpty, 7 | } from "@snk/types/grid"; 8 | import { 9 | getHeadX, 10 | getHeadY, 11 | getSnakeLength, 12 | nextSnake, 13 | snakeEquals, 14 | snakeWillSelfCollide, 15 | } from "@snk/types/snake"; 16 | import { around4, Point } from "@snk/types/point"; 17 | import { getBestTunnel } from "./getBestTunnel"; 18 | import { fillOutside } from "./outside"; 19 | import type { Outside } from "./outside"; 20 | import type { Snake } from "@snk/types/snake"; 21 | import type { Color, Empty, Grid } from "@snk/types/grid"; 22 | 23 | export const clearCleanColoredLayer = ( 24 | grid: Grid, 25 | outside: Outside, 26 | snake0: Snake, 27 | color: Color 28 | ) => { 29 | const snakeN = getSnakeLength(snake0); 30 | 31 | const points = getTunnellablePoints(grid, outside, snakeN, color); 32 | 33 | const chain: Snake[] = [snake0]; 34 | 35 | while (points.length) { 36 | const path = getPathToNextPoint(grid, chain[0], color, points)!; 37 | path.pop(); 38 | 39 | for (const snake of path) 40 | setEmptySafe(grid, getHeadX(snake), getHeadY(snake)); 41 | 42 | chain.unshift(...path); 43 | } 44 | 45 | fillOutside(outside, grid); 46 | 47 | chain.pop(); 48 | return chain; 49 | }; 50 | 51 | type M = { snake: Snake; parent: M | null }; 52 | const unwrap = (m: M | null): Snake[] => 53 | !m ? [] : [m.snake, ...unwrap(m.parent)]; 54 | const getPathToNextPoint = ( 55 | grid: Grid, 56 | snake0: Snake, 57 | color: Color, 58 | points: Point[] 59 | ) => { 60 | const closeList: Snake[] = []; 61 | const openList: M[] = [{ snake: snake0 } as any]; 62 | 63 | while (openList.length) { 64 | const o = openList.shift()!; 65 | 66 | const x = getHeadX(o.snake); 67 | const y = getHeadY(o.snake); 68 | 69 | const i = points.findIndex((p) => p.x === x && p.y === y); 70 | if (i >= 0) { 71 | points.splice(i, 1); 72 | return unwrap(o); 73 | } 74 | 75 | for (const { x: dx, y: dy } of around4) { 76 | if ( 77 | isInsideLarge(grid, 2, x + dx, y + dy) && 78 | !snakeWillSelfCollide(o.snake, dx, dy) && 79 | getColorSafe(grid, x + dx, y + dy) <= color 80 | ) { 81 | const snake = nextSnake(o.snake, dx, dy); 82 | 83 | if (!closeList.some((s0) => snakeEquals(s0, snake))) { 84 | closeList.push(snake); 85 | openList.push({ snake, parent: o }); 86 | } 87 | } 88 | } 89 | } 90 | }; 91 | 92 | /** 93 | * get all cells that are tunnellable 94 | */ 95 | export const getTunnellablePoints = ( 96 | grid: Grid, 97 | outside: Outside, 98 | snakeN: number, 99 | color: Color 100 | ) => { 101 | const points: Point[] = []; 102 | 103 | for (let x = grid.width; x--; ) 104 | for (let y = grid.height; y--; ) { 105 | const c = getColor(grid, x, y); 106 | if ( 107 | !isEmpty(c) && 108 | c <= color && 109 | !points.some((p) => p.x === x && p.y === y) 110 | ) { 111 | const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN); 112 | 113 | if (tunnel) 114 | for (const p of tunnel) 115 | if (!isEmptySafe(grid, p.x, p.y)) points.push(p); 116 | } 117 | } 118 | 119 | return points; 120 | }; 121 | 122 | const getColorSafe = (grid: Grid, x: number, y: number) => 123 | isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty); 124 | 125 | const setEmptySafe = (grid: Grid, x: number, y: number) => { 126 | if (isInside(grid, x, y)) setColorEmpty(grid, x, y); 127 | }; 128 | 129 | const isEmptySafe = (grid: Grid, x: number, y: number) => 130 | !isInside(grid, x, y) && isEmpty(getColor(grid, x, y)); 131 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v3 12 | with: 13 | cache: yarn 14 | node-version: 20 15 | - run: yarn install --frozen-lockfile 16 | 17 | - run: npm run type 18 | - run: npm run lint 19 | - run: npm run test --ci 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | test-action: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: update action.yml to use image from local Dockerfile 29 | run: | 30 | sed -i "s/image: .*/image: Dockerfile/" action.yml 31 | 32 | - name: generate-snake-game-from-github-contribution-grid 33 | id: generate-snake 34 | uses: ./ 35 | with: 36 | github_user_name: platane 37 | outputs: | 38 | dist/github-contribution-grid-snake.svg 39 | dist/github-contribution-grid-snake-dark.svg?palette=github-dark 40 | dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 41 | 42 | - name: ensure the generated file exists 43 | run: | 44 | ls dist 45 | test -f dist/github-contribution-grid-snake.svg 46 | test -f dist/github-contribution-grid-snake-dark.svg 47 | test -f dist/github-contribution-grid-snake.gif 48 | 49 | - uses: crazy-max/ghaction-github-pages@v3.1.0 50 | with: 51 | target_branch: output 52 | build_dir: dist 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | test-action-svg-only: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: actions/setup-node@v3 61 | with: 62 | cache: yarn 63 | node-version: 20 64 | - run: yarn install --frozen-lockfile 65 | 66 | - name: build svg-only action 67 | run: | 68 | npm run build:action 69 | rm -r svg-only/dist 70 | mv packages/action/dist svg-only/dist 71 | 72 | - name: generate-snake-game-from-github-contribution-grid 73 | id: generate-snake 74 | uses: ./svg-only 75 | with: 76 | github_user_name: platane 77 | outputs: | 78 | dist/github-contribution-grid-snake.svg 79 | dist/github-contribution-grid-snake-dark.svg?palette=github-dark 80 | 81 | - name: ensure the generated file exists 82 | run: | 83 | ls dist 84 | test -f dist/github-contribution-grid-snake.svg 85 | test -f dist/github-contribution-grid-snake-dark.svg 86 | 87 | - uses: crazy-max/ghaction-github-pages@v3.1.0 88 | with: 89 | target_branch: output-svg-only 90 | build_dir: dist 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | 94 | deploy-ghpages: 95 | runs-on: ubuntu-latest 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: actions/setup-node@v3 99 | with: 100 | cache: yarn 101 | node-version: 20 102 | - run: yarn install --frozen-lockfile 103 | 104 | - run: npm run build:demo 105 | env: 106 | GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/ 107 | 108 | - uses: crazy-max/ghaction-github-pages@v3.1.0 109 | if: success() && github.ref == 'refs/heads/main' 110 | with: 111 | target_branch: gh-pages 112 | build_dir: packages/demo/dist 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }} 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # snk 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/platane/platane/main.yml?label=action&style=flat-square)](https://github.com/Platane/Platane/actions/workflows/main.yml) 4 | [![GitHub release](https://img.shields.io/github/release/platane/snk.svg?style=flat-square)](https://github.com/platane/snk/releases/latest) 5 | [![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) 6 | ![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square) 7 | ![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square) 8 | 9 | Generates a snake game from a github user contributions graph 10 | 11 | 12 | 16 | 20 | github contribution grid snake animation 24 | 25 | 26 | Pull a github user's contribution graph. 27 | Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion. 28 | 29 | 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. 30 | 31 | Available as github action. It can automatically generate a new image each day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme) 32 | 33 | ## Usage 34 | 35 | **github action** 36 | 37 | ```yaml 38 | - uses: Platane/snk@v3 39 | with: 40 | # github user name to read the contribution graph from (**required**) 41 | # using action context var `github.repository_owner` or specified user 42 | github_user_name: ${{ github.repository_owner }} 43 | 44 | # list of files to generate. 45 | # one file per line. Each output can be customized with options as query string. 46 | # 47 | # supported options: 48 | # - palette: A preset of color, one of [github, github-dark, github-light] 49 | # - color_snake: Color of the snake 50 | # - color_dots: Coma separated list of dots color. 51 | # The first one is 0 contribution, then it goes from the low contribution to the highest. 52 | # Exactly 5 colors are expected. 53 | outputs: | 54 | dist/github-snake.svg 55 | dist/github-snake-dark.svg?palette=github-dark 56 | dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 57 | ``` 58 | 59 | [example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L26-L33) 60 | 61 | If you are only interested in generating a svg, consider using this faster action: `uses: Platane/snk/svg-only@v3` 62 | 63 | **dark mode** 64 | 65 | For **dark mode** support on github, use this [special syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to) in your readme. 66 | 67 | ```html 68 | 69 | 70 | 71 | github-snake 72 | 73 | ``` 74 | 75 | **interactive demo** 76 | 77 | 78 | 79 | 80 | 81 | [platane.github.io/snk](https://platane.github.io/snk) 82 | 83 | **local** 84 | 85 | ``` 86 | npm install 87 | 88 | npm run dev:demo 89 | ``` 90 | 91 | ## Implementation 92 | 93 | [solver algorithm](./packages/solver/README.md) 94 | -------------------------------------------------------------------------------- /packages/solver/clearResidualColoredLayer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Empty, 3 | getColor, 4 | isEmpty, 5 | isInside, 6 | setColorEmpty, 7 | } from "@snk/types/grid"; 8 | import { getHeadX, getHeadY, getSnakeLength } from "@snk/types/snake"; 9 | import { getBestTunnel } from "./getBestTunnel"; 10 | import { fillOutside, Outside } from "./outside"; 11 | import { getTunnelPath } from "./tunnel"; 12 | import { getPathTo } from "./getPathTo"; 13 | import type { Snake } from "@snk/types/snake"; 14 | import type { Color, Grid } from "@snk/types/grid"; 15 | import type { Point } from "@snk/types/point"; 16 | 17 | type T = Point & { tunnel: Point[]; priority: number }; 18 | 19 | export const clearResidualColoredLayer = ( 20 | grid: Grid, 21 | outside: Outside, 22 | snake0: Snake, 23 | color: Color 24 | ) => { 25 | const snakeN = getSnakeLength(snake0); 26 | 27 | const tunnels = getTunnellablePoints(grid, outside, snakeN, color); 28 | 29 | // sort 30 | tunnels.sort((a, b) => b.priority - a.priority); 31 | 32 | const chain: Snake[] = [snake0]; 33 | 34 | while (tunnels.length) { 35 | // get the best next tunnel 36 | let t = getNextTunnel(tunnels, chain[0]); 37 | 38 | // goes to the start of the tunnel 39 | chain.unshift(...getPathTo(grid, chain[0], t[0].x, t[0].y)!); 40 | 41 | // goes to the end of the tunnel 42 | chain.unshift(...getTunnelPath(chain[0], t)); 43 | 44 | // update grid 45 | for (const { x, y } of t) setEmptySafe(grid, x, y); 46 | 47 | // update outside 48 | fillOutside(outside, grid); 49 | 50 | // update tunnels 51 | for (let i = tunnels.length; i--; ) 52 | if (isEmpty(getColor(grid, tunnels[i].x, tunnels[i].y))) 53 | tunnels.splice(i, 1); 54 | else { 55 | const t = tunnels[i]; 56 | const tunnel = getBestTunnel(grid, outside, t.x, t.y, color, snakeN); 57 | 58 | if (!tunnel) tunnels.splice(i, 1); 59 | else { 60 | t.tunnel = tunnel; 61 | t.priority = getPriority(grid, color, tunnel); 62 | } 63 | } 64 | 65 | // re-sort 66 | tunnels.sort((a, b) => b.priority - a.priority); 67 | } 68 | 69 | chain.pop(); 70 | return chain; 71 | }; 72 | 73 | const getNextTunnel = (ts: T[], snake: Snake) => { 74 | let minDistance = Infinity; 75 | let closestTunnel: Point[] | null = null; 76 | 77 | const x = getHeadX(snake); 78 | const y = getHeadY(snake); 79 | 80 | const priority = ts[0].priority; 81 | 82 | for (let i = 0; ts[i] && ts[i].priority === priority; i++) { 83 | const t = ts[i].tunnel; 84 | 85 | const d = distanceSq(t[0].x, t[0].y, x, y); 86 | if (d < minDistance) { 87 | minDistance = d; 88 | closestTunnel = t; 89 | } 90 | } 91 | 92 | return closestTunnel!; 93 | }; 94 | 95 | /** 96 | * get all the tunnels for all the cells accessible 97 | */ 98 | export const getTunnellablePoints = ( 99 | grid: Grid, 100 | outside: Outside, 101 | snakeN: number, 102 | color: Color 103 | ) => { 104 | const points: T[] = []; 105 | 106 | for (let x = grid.width; x--; ) 107 | for (let y = grid.height; y--; ) { 108 | const c = getColor(grid, x, y); 109 | if (!isEmpty(c) && c < color) { 110 | const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN); 111 | if (tunnel) { 112 | const priority = getPriority(grid, color, tunnel); 113 | points.push({ x, y, priority, tunnel }); 114 | } 115 | } 116 | } 117 | 118 | return points; 119 | }; 120 | 121 | /** 122 | * get the score of the tunnel 123 | * prioritize tunnel with maximum color smaller than and with minimum 124 | * with some tweaks 125 | */ 126 | export const getPriority = (grid: Grid, color: Color, tunnel: Point[]) => { 127 | let nColor = 0; 128 | let nLess = 0; 129 | 130 | for (let i = 0; i < tunnel.length; i++) { 131 | const { x, y } = tunnel[i]; 132 | const c = getColorSafe(grid, x, y); 133 | 134 | if (!isEmpty(c) && i === tunnel.findIndex((p) => p.x === x && p.y === y)) { 135 | if (c === color) nColor += 1; 136 | else nLess += color - c; 137 | } 138 | } 139 | 140 | if (nColor === 0) return 99999; 141 | return nLess / nColor; 142 | }; 143 | 144 | const distanceSq = (ax: number, ay: number, bx: number, by: number) => 145 | (ax - bx) ** 2 + (ay - by) ** 2; 146 | 147 | const getColorSafe = (grid: Grid, x: number, y: number) => 148 | isInside(grid, x, y) ? getColor(grid, x, y) : (0 as Empty); 149 | 150 | const setEmptySafe = (grid: Grid, x: number, y: number) => { 151 | if (isInside(grid, x, y)) setColorEmpty(grid, x, y); 152 | }; 153 | -------------------------------------------------------------------------------- /packages/svg-creator/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | copyGrid, 3 | getColor, 4 | isEmpty, 5 | isInside, 6 | setColorEmpty, 7 | } from "@snk/types/grid"; 8 | import { getHeadX, getHeadY } from "@snk/types/snake"; 9 | import type { Snake } from "@snk/types/snake"; 10 | import type { Grid, Color, Empty } from "@snk/types/grid"; 11 | import type { Point } from "@snk/types/point"; 12 | import type { AnimationOptions } from "@snk/gif-creator"; 13 | import { createSnake } from "./snake"; 14 | import { createGrid } from "./grid"; 15 | import { createStack } from "./stack"; 16 | import { h } from "./xml-utils"; 17 | import { minifyCss } from "./css-utils"; 18 | 19 | export type DrawOptions = { 20 | colorDots: Record; 21 | colorEmpty: string; 22 | colorDotBorder: string; 23 | colorSnake: string; 24 | sizeCell: number; 25 | sizeDot: number; 26 | sizeDotBorderRadius: number; 27 | dark?: { 28 | colorDots: Record; 29 | colorEmpty: string; 30 | colorDotBorder?: string; 31 | colorSnake?: string; 32 | }; 33 | }; 34 | 35 | const getCellsFromGrid = ({ width, height }: Grid) => 36 | Array.from({ length: width }, (_, x) => 37 | Array.from({ length: height }, (_, y) => ({ x, y })) 38 | ).flat(); 39 | 40 | const createLivingCells = ( 41 | grid0: Grid, 42 | chain: Snake[], 43 | cells: Point[] | null 44 | ) => { 45 | const livingCells: (Point & { 46 | t: number | null; 47 | color: Color | Empty; 48 | })[] = (cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({ 49 | x, 50 | y, 51 | t: null, 52 | color: getColor(grid0, x, y), 53 | })); 54 | 55 | const grid = copyGrid(grid0); 56 | for (let i = 0; i < chain.length; i++) { 57 | const snake = chain[i]; 58 | const x = getHeadX(snake); 59 | const y = getHeadY(snake); 60 | 61 | if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) { 62 | setColorEmpty(grid, x, y); 63 | const cell = livingCells.find((c) => c.x === x && c.y === y)!; 64 | cell.t = i / chain.length; 65 | } 66 | } 67 | 68 | return livingCells; 69 | }; 70 | 71 | export const createSvg = ( 72 | grid: Grid, 73 | cells: Point[] | null, 74 | chain: Snake[], 75 | drawOptions: DrawOptions, 76 | animationOptions: Pick 77 | ) => { 78 | const width = (grid.width + 2) * drawOptions.sizeCell; 79 | const height = (grid.height + 5) * drawOptions.sizeCell; 80 | 81 | const duration = animationOptions.frameDuration * chain.length; 82 | 83 | const livingCells = createLivingCells(grid, chain, cells); 84 | 85 | const elements = [ 86 | createGrid(livingCells, drawOptions, duration), 87 | createStack( 88 | livingCells, 89 | drawOptions, 90 | grid.width * drawOptions.sizeCell, 91 | (grid.height + 2) * drawOptions.sizeCell, 92 | duration 93 | ), 94 | createSnake(chain, drawOptions, duration), 95 | ]; 96 | 97 | const viewBox = [ 98 | -drawOptions.sizeCell, 99 | -drawOptions.sizeCell * 2, 100 | width, 101 | height, 102 | ].join(" "); 103 | 104 | const style = 105 | generateColorVar(drawOptions) + 106 | elements 107 | .map((e) => e.styles) 108 | .flat() 109 | .join("\n"); 110 | 111 | const svg = [ 112 | h("svg", { 113 | viewBox, 114 | width, 115 | height, 116 | xmlns: "http://www.w3.org/2000/svg", 117 | }).replace("/>", ">"), 118 | 119 | "", 120 | "Generated with https://github.com/Platane/snk", 121 | "", 122 | 123 | "", 126 | 127 | ...elements.map((e) => e.svgElements).flat(), 128 | 129 | "", 130 | ].join(""); 131 | 132 | return optimizeSvg(svg); 133 | }; 134 | 135 | const optimizeCss = (css: string) => minifyCss(css); 136 | const optimizeSvg = (svg: string) => svg; 137 | 138 | const generateColorVar = (drawOptions: DrawOptions) => 139 | ` 140 | :root { 141 | --cb: ${drawOptions.colorDotBorder}; 142 | --cs: ${drawOptions.colorSnake}; 143 | --ce: ${drawOptions.colorEmpty}; 144 | ${Object.entries(drawOptions.colorDots) 145 | .map(([i, color]) => `--c${i}:${color};`) 146 | .join("")} 147 | } 148 | ` + 149 | (drawOptions.dark 150 | ? ` 151 | @media (prefers-color-scheme: dark) { 152 | :root { 153 | --cb: ${drawOptions.dark.colorDotBorder || drawOptions.colorDotBorder}; 154 | --cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake}; 155 | --ce: ${drawOptions.dark.colorEmpty}; 156 | ${Object.entries(drawOptions.dark.colorDots) 157 | .map(([i, color]) => `--c${i}:${color};`) 158 | .join("")} 159 | } 160 | } 161 | ` 162 | : ""); 163 | -------------------------------------------------------------------------------- /svg-only/dist/142.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 142; 3 | exports.ids = [142]; 4 | exports.modules = { 5 | 6 | /***/ 7142: 7 | /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 8 | 9 | // ESM COMPAT FLAG 10 | __webpack_require__.r(__webpack_exports__); 11 | 12 | // EXPORTS 13 | __webpack_require__.d(__webpack_exports__, { 14 | "createGif": () => (/* binding */ createGif) 15 | }); 16 | 17 | // EXTERNAL MODULE: external "fs" 18 | var external_fs_ = __webpack_require__(7147); 19 | var external_fs_default = /*#__PURE__*/__webpack_require__.n(external_fs_); 20 | // EXTERNAL MODULE: external "path" 21 | var external_path_ = __webpack_require__(1017); 22 | var external_path_default = /*#__PURE__*/__webpack_require__.n(external_path_); 23 | // EXTERNAL MODULE: external "child_process" 24 | var external_child_process_ = __webpack_require__(2081); 25 | // EXTERNAL MODULE: external "canvas" 26 | var external_canvas_ = __webpack_require__(1576); 27 | // EXTERNAL MODULE: ../types/grid.ts 28 | var types_grid = __webpack_require__(2881); 29 | ;// CONCATENATED MODULE: ../draw/pathRoundedRect.ts 30 | const pathRoundedRect_pathRoundedRect = (ctx, width, height, borderRadius) => { 31 | ctx.moveTo(borderRadius, 0); 32 | ctx.arcTo(width, 0, width, height, borderRadius); 33 | ctx.arcTo(width, height, 0, height, borderRadius); 34 | ctx.arcTo(0, height, 0, 0, borderRadius); 35 | ctx.arcTo(0, 0, width, 0, borderRadius); 36 | }; 37 | 38 | ;// CONCATENATED MODULE: ../draw/drawGrid.ts 39 | 40 | 41 | const drawGrid_drawGrid = (ctx, grid, cells, o) => { 42 | for (let x = grid.width; x--;) 43 | for (let y = grid.height; y--;) { 44 | if (!cells || cells.some((c) => c.x === x && c.y === y)) { 45 | const c = (0,types_grid/* getColor */.Lq)(grid, x, y); 46 | // @ts-ignore 47 | const color = !c ? o.colorEmpty : o.colorDots[c]; 48 | ctx.save(); 49 | ctx.translate(x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2, y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2); 50 | ctx.fillStyle = color; 51 | ctx.strokeStyle = o.colorDotBorder; 52 | ctx.lineWidth = 1; 53 | ctx.beginPath(); 54 | pathRoundedRect_pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeDotBorderRadius); 55 | ctx.fill(); 56 | ctx.stroke(); 57 | ctx.closePath(); 58 | ctx.restore(); 59 | } 60 | } 61 | }; 62 | 63 | ;// CONCATENATED MODULE: ../draw/drawSnake.ts 64 | 65 | 66 | const drawSnake_drawSnake = (ctx, snake, o) => { 67 | const cells = snakeToCells(snake); 68 | for (let i = 0; i < cells.length; i++) { 69 | const u = (i + 1) * 0.6; 70 | ctx.save(); 71 | ctx.fillStyle = o.colorSnake; 72 | ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u); 73 | ctx.beginPath(); 74 | pathRoundedRect(ctx, o.sizeCell - u * 2, o.sizeCell - u * 2, (o.sizeCell - u * 2) * 0.25); 75 | ctx.fill(); 76 | ctx.restore(); 77 | } 78 | }; 79 | const lerp = (k, a, b) => (1 - k) * a + k * b; 80 | const clamp = (x, a, b) => Math.max(a, Math.min(b, x)); 81 | const drawSnakeLerp = (ctx, snake0, snake1, k, o) => { 82 | const m = 0.8; 83 | const n = snake0.length / 2; 84 | for (let i = 0; i < n; i++) { 85 | const u = (i + 1) * 0.6 * (o.sizeCell / 16); 86 | const a = (1 - m) * (i / Math.max(n - 1, 1)); 87 | const ki = clamp((k - a) / m, 0, 1); 88 | const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2; 89 | const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2; 90 | ctx.save(); 91 | ctx.fillStyle = o.colorSnake; 92 | ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u); 93 | ctx.beginPath(); 94 | pathRoundedRect_pathRoundedRect(ctx, o.sizeCell - u * 2, o.sizeCell - u * 2, (o.sizeCell - u * 2) * 0.25); 95 | ctx.fill(); 96 | ctx.restore(); 97 | } 98 | }; 99 | 100 | ;// CONCATENATED MODULE: ../draw/drawWorld.ts 101 | 102 | 103 | const drawStack = (ctx, stack, max, width, o) => { 104 | ctx.save(); 105 | const m = width / max; 106 | for (let i = 0; i < stack.length; i++) { 107 | // @ts-ignore 108 | ctx.fillStyle = o.colorDots[stack[i]]; 109 | ctx.fillRect(i * m, 0, m + width * 0.005, 10); 110 | } 111 | ctx.restore(); 112 | }; 113 | const drawWorld = (ctx, grid, cells, snake, stack, o) => { 114 | ctx.save(); 115 | ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); 116 | drawGrid(ctx, grid, cells, o); 117 | drawSnake(ctx, snake, o); 118 | ctx.restore(); 119 | ctx.save(); 120 | ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell); 121 | const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length); 122 | drawStack(ctx, stack, max, grid.width * o.sizeCell, o); 123 | ctx.restore(); 124 | // ctx.save(); 125 | // ctx.translate(o.sizeCell + 100, (grid.height + 4) * o.sizeCell + 100); 126 | // ctx.scale(0.6, 0.6); 127 | // drawCircleStack(ctx, stack, o); 128 | // ctx.restore(); 129 | }; 130 | const drawLerpWorld = (ctx, grid, cells, snake0, snake1, stack, k, o) => { 131 | ctx.save(); 132 | ctx.translate(1 * o.sizeCell, 2 * o.sizeCell); 133 | drawGrid_drawGrid(ctx, grid, cells, o); 134 | drawSnakeLerp(ctx, snake0, snake1, k, o); 135 | ctx.translate(0, (grid.height + 2) * o.sizeCell); 136 | const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length); 137 | drawStack(ctx, stack, max, grid.width * o.sizeCell, o); 138 | ctx.restore(); 139 | }; 140 | const getCanvasWorldSize = (grid, o) => { 141 | const width = o.sizeCell * (grid.width + 2); 142 | const height = o.sizeCell * (grid.height + 4) + 30; 143 | return { width, height }; 144 | }; 145 | 146 | // EXTERNAL MODULE: ../types/snake.ts 147 | var types_snake = __webpack_require__(9347); 148 | ;// CONCATENATED MODULE: ../solver/step.ts 149 | 150 | 151 | const step = (grid, stack, snake) => { 152 | const x = (0,types_snake/* getHeadX */.If)(snake); 153 | const y = (0,types_snake/* getHeadY */.IP)(snake); 154 | const color = (0,types_grid/* getColor */.Lq)(grid, x, y); 155 | if ((0,types_grid/* isInside */.V0)(grid, x, y) && !(0,types_grid/* isEmpty */.xb)(color)) { 156 | stack.push(color); 157 | (0,types_grid/* setColorEmpty */.Dy)(grid, x, y); 158 | } 159 | }; 160 | 161 | // EXTERNAL MODULE: ../../node_modules/tmp/lib/tmp.js 162 | var tmp = __webpack_require__(6382); 163 | // EXTERNAL MODULE: external "gifsicle" 164 | var external_gifsicle_ = __webpack_require__(542); 165 | var external_gifsicle_default = /*#__PURE__*/__webpack_require__.n(external_gifsicle_); 166 | // EXTERNAL MODULE: ../../node_modules/gif-encoder-2/index.js 167 | var gif_encoder_2 = __webpack_require__(3561); 168 | var gif_encoder_2_default = /*#__PURE__*/__webpack_require__.n(gif_encoder_2); 169 | ;// CONCATENATED MODULE: ../gif-creator/index.ts 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | // @ts-ignore 180 | 181 | const withTmpDir = async (handler) => { 182 | const { name: dir, removeCallback: cleanUp } = tmp.dirSync({ 183 | unsafeCleanup: true, 184 | }); 185 | try { 186 | return await handler(dir); 187 | } 188 | finally { 189 | cleanUp(); 190 | } 191 | }; 192 | const createGif = async (grid0, cells, chain, drawOptions, animationOptions) => withTmpDir(async (dir) => { 193 | const { width, height } = getCanvasWorldSize(grid0, drawOptions); 194 | const canvas = (0,external_canvas_.createCanvas)(width, height); 195 | const ctx = canvas.getContext("2d"); 196 | const grid = (0,types_grid/* copyGrid */.VJ)(grid0); 197 | const stack = []; 198 | const encoder = new (gif_encoder_2_default())(width, height, "neuquant", true); 199 | encoder.setRepeat(0); 200 | encoder.setDelay(animationOptions.frameDuration); 201 | encoder.start(); 202 | for (let i = 0; i < chain.length; i += 1) { 203 | const snake0 = chain[i]; 204 | const snake1 = chain[Math.min(chain.length - 1, i + 1)]; 205 | step(grid, stack, snake0); 206 | for (let k = 0; k < animationOptions.step; k++) { 207 | ctx.clearRect(0, 0, width, height); 208 | ctx.fillStyle = "#fff"; 209 | ctx.fillRect(0, 0, width, height); 210 | drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k / animationOptions.step, drawOptions); 211 | encoder.addFrame(ctx); 212 | } 213 | } 214 | const outFileName = external_path_default().join(dir, "out.gif"); 215 | const optimizedFileName = external_path_default().join(dir, "out.optimized.gif"); 216 | encoder.finish(); 217 | external_fs_default().writeFileSync(outFileName, encoder.out.getData()); 218 | (0,external_child_process_.execFileSync)((external_gifsicle_default()), [ 219 | // 220 | "--optimize=3", 221 | "--color-method=diversity", 222 | "--colors=18", 223 | outFileName, 224 | ["--output", optimizedFileName], 225 | ].flat()); 226 | return external_fs_default().readFileSync(optimizedFileName); 227 | }); 228 | 229 | 230 | /***/ }) 231 | 232 | }; 233 | ; -------------------------------------------------------------------------------- /packages/demo/demo.interactive.ts: -------------------------------------------------------------------------------- 1 | import { Color, copyGrid, Grid } from "@snk/types/grid"; 2 | import { step } from "@snk/solver/step"; 3 | import { isStableAndBound, stepSpring } from "./springUtils"; 4 | import type { Res } from "@snk/github-user-contribution"; 5 | import type { Snake } from "@snk/types/snake"; 6 | import type { Point } from "@snk/types/point"; 7 | import { 8 | drawLerpWorld, 9 | getCanvasWorldSize, 10 | Options as DrawOptions, 11 | } from "@snk/draw/drawWorld"; 12 | import { userContributionToGrid } from "@snk/action/userContributionToGrid"; 13 | import { createSvg } from "@snk/svg-creator"; 14 | import { createRpcClient } from "./worker-utils"; 15 | import type { API as WorkerAPI } from "./demo.interactive.worker"; 16 | import { AnimationOptions } from "@snk/gif-creator"; 17 | import { basePalettes } from "@snk/action/palettes"; 18 | 19 | const createForm = ({ 20 | onSubmit, 21 | onChangeUserName, 22 | }: { 23 | onSubmit: (s: string) => Promise; 24 | onChangeUserName: (s: string) => void; 25 | }) => { 26 | const form = document.createElement("form"); 27 | form.style.position = "relative"; 28 | form.style.display = "flex"; 29 | form.style.flexDirection = "row"; 30 | const input = document.createElement("input"); 31 | input.addEventListener("input", () => onChangeUserName(input.value)); 32 | input.style.padding = "16px"; 33 | input.placeholder = "github user"; 34 | const submit = document.createElement("button"); 35 | submit.style.padding = "16px"; 36 | submit.type = "submit"; 37 | submit.innerText = "ok"; 38 | 39 | const label = document.createElement("label"); 40 | label.style.position = "absolute"; 41 | label.style.textAlign = "center"; 42 | label.style.top = "60px"; 43 | label.style.left = "0"; 44 | label.style.right = "0"; 45 | 46 | form.appendChild(input); 47 | form.appendChild(submit); 48 | document.body.appendChild(form); 49 | 50 | form.addEventListener("submit", (event) => { 51 | event.preventDefault(); 52 | 53 | onSubmit(input.value) 54 | .finally(() => { 55 | clearTimeout(timeout); 56 | }) 57 | .catch((err) => { 58 | label.innerText = "error :("; 59 | throw err; 60 | }); 61 | 62 | input.disabled = true; 63 | submit.disabled = true; 64 | form.appendChild(label); 65 | label.innerText = "loading ..."; 66 | 67 | const timeout = setTimeout(() => { 68 | label.innerText = "loading ( it might take a while ) ... "; 69 | }, 5000); 70 | }); 71 | 72 | // 73 | // dispose 74 | const dispose = () => { 75 | document.body.removeChild(form); 76 | }; 77 | 78 | return { dispose }; 79 | }; 80 | 81 | const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); 82 | 83 | const createGithubProfile = () => { 84 | const container = document.createElement("div"); 85 | container.style.padding = "20px"; 86 | container.style.opacity = "0"; 87 | container.style.display = "flex"; 88 | container.style.flexDirection = "column"; 89 | container.style.height = "120px"; 90 | container.style.alignItems = "flex-start"; 91 | const image = document.createElement("img"); 92 | image.style.width = "100px"; 93 | image.style.height = "100px"; 94 | image.style.borderRadius = "50px"; 95 | const name = document.createElement("a"); 96 | name.style.padding = "4px 0 0 0"; 97 | 98 | document.body.appendChild(container); 99 | container.appendChild(image); 100 | container.appendChild(name); 101 | 102 | image.addEventListener("load", () => { 103 | container.style.opacity = "1"; 104 | }); 105 | const onChangeUser = (userName: string) => { 106 | container.style.opacity = "0"; 107 | name.innerText = userName; 108 | name.href = `https://github.com/${userName}`; 109 | image.src = `https://github.com/${userName}.png`; 110 | }; 111 | 112 | const dispose = () => { 113 | document.body.removeChild(container); 114 | }; 115 | 116 | return { dispose, onChangeUser }; 117 | }; 118 | 119 | const createViewer = ({ 120 | grid0, 121 | chain, 122 | cells, 123 | }: { 124 | grid0: Grid; 125 | chain: Snake[]; 126 | cells: Point[]; 127 | }) => { 128 | const drawOptions: DrawOptions = { 129 | sizeDotBorderRadius: 2, 130 | sizeCell: 16, 131 | sizeDot: 12, 132 | ...basePalettes["github-light"], 133 | }; 134 | 135 | // 136 | // canvas 137 | const canvas = document.createElement("canvas"); 138 | const { width, height } = getCanvasWorldSize(grid0, drawOptions); 139 | canvas.width = width; 140 | canvas.height = height; 141 | 142 | const w = Math.min(width, window.innerWidth); 143 | const h = (height / width) * w; 144 | canvas.style.width = w + "px"; 145 | canvas.style.height = h + "px"; 146 | canvas.style.pointerEvents = "none"; 147 | 148 | document.body.appendChild(canvas); 149 | 150 | // 151 | // draw 152 | let animationFrame: number; 153 | const spring = { x: 0, v: 0, target: 0 }; 154 | const springParams = { tension: 120, friction: 20, maxVelocity: 50 }; 155 | const ctx = canvas.getContext("2d")!; 156 | const loop = () => { 157 | cancelAnimationFrame(animationFrame); 158 | 159 | stepSpring(spring, springParams, spring.target); 160 | const stable = isStableAndBound(spring, spring.target); 161 | 162 | const grid = copyGrid(grid0); 163 | const stack: Color[] = []; 164 | for (let i = 0; i < Math.min(chain.length, spring.x); i++) 165 | step(grid, stack, chain[i]); 166 | 167 | const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)]; 168 | const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)]; 169 | const k = spring.x % 1; 170 | 171 | ctx.clearRect(0, 0, 9999, 9999); 172 | drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k, drawOptions); 173 | 174 | if (!stable) animationFrame = requestAnimationFrame(loop); 175 | }; 176 | loop(); 177 | 178 | // 179 | // controls 180 | const input = document.createElement("input"); 181 | input.type = "range"; 182 | input.value = "0"; 183 | input.step = "1"; 184 | input.min = "0"; 185 | input.max = "" + chain.length; 186 | input.style.width = "calc( 100% - 20px )"; 187 | input.addEventListener("input", () => { 188 | spring.target = +input.value; 189 | cancelAnimationFrame(animationFrame); 190 | animationFrame = requestAnimationFrame(loop); 191 | }); 192 | const onClickBackground = (e: MouseEvent) => { 193 | if (e.target === document.body || e.target === document.body.parentElement) 194 | input.focus(); 195 | }; 196 | window.addEventListener("click", onClickBackground); 197 | document.body.append(input); 198 | 199 | // 200 | const schemaSelect = document.createElement("select"); 201 | schemaSelect.style.margin = "10px"; 202 | schemaSelect.style.alignSelf = "flex-start"; 203 | schemaSelect.value = "github-light"; 204 | schemaSelect.addEventListener("change", () => { 205 | Object.assign(drawOptions, basePalettes[schemaSelect.value]); 206 | 207 | svgString = createSvg(grid0, cells, chain, drawOptions, { 208 | frameDuration: 100, 209 | } as AnimationOptions); 210 | const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`; 211 | svgLink.href = svgImageUri; 212 | 213 | if (schemaSelect.value.includes("dark")) 214 | document.body.parentElement?.classList.add("dark-mode"); 215 | else document.body.parentElement?.classList.remove("dark-mode"); 216 | 217 | loop(); 218 | }); 219 | for (const name of Object.keys(basePalettes)) { 220 | const option = document.createElement("option"); 221 | option.value = name; 222 | option.innerText = name; 223 | schemaSelect.appendChild(option); 224 | } 225 | document.body.append(schemaSelect); 226 | 227 | // 228 | // dark mode 229 | const style = document.createElement("style"); 230 | style.innerText = ` 231 | html { transition:background-color 180ms } 232 | a { transition:color 180ms } 233 | html.dark-mode{ background-color:#0d1117 } 234 | html.dark-mode a{ color:rgb(201, 209, 217) } 235 | `; 236 | document.head.append(style); 237 | 238 | // 239 | // svg 240 | const svgLink = document.createElement("a"); 241 | let svgString = createSvg(grid0, cells, chain, drawOptions, { 242 | frameDuration: 100, 243 | } as AnimationOptions); 244 | const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`; 245 | svgLink.href = svgImageUri; 246 | svgLink.innerText = "github-user-contribution.svg"; 247 | svgLink.download = "github-user-contribution.svg"; 248 | svgLink.addEventListener("click", (e) => { 249 | const w = window.open("")!; 250 | w.document.write( 251 | (document.body.parentElement?.classList.contains("dark-mode") 252 | ? "" 253 | : "") + 254 | `` + 255 | svgString + 256 | "" 257 | ); 258 | e.preventDefault(); 259 | }); 260 | svgLink.style.padding = "20px"; 261 | svgLink.style.paddingTop = "60px"; 262 | svgLink.style.alignSelf = "flex-start"; 263 | document.body.append(svgLink); 264 | 265 | // 266 | // dispose 267 | const dispose = () => { 268 | window.removeEventListener("click", onClickBackground); 269 | cancelAnimationFrame(animationFrame); 270 | document.body.removeChild(canvas); 271 | document.body.removeChild(input); 272 | document.body.removeChild(svgLink); 273 | }; 274 | 275 | return { dispose }; 276 | }; 277 | 278 | const onSubmit = async (userName: string) => { 279 | const res = await fetch( 280 | process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName 281 | ); 282 | const cells = (await res.json()) as Res; 283 | 284 | const grid = userContributionToGrid(cells); 285 | 286 | const chain = await getChain(grid); 287 | 288 | dispose(); 289 | 290 | createViewer({ grid0: grid, chain, cells }); 291 | }; 292 | 293 | const worker = new Worker( 294 | new URL( 295 | "./demo.interactive.worker.ts", 296 | // @ts-ignore 297 | import.meta.url 298 | ) 299 | ); 300 | 301 | const { getChain } = createRpcClient(worker); 302 | 303 | const profile = createGithubProfile(); 304 | const { dispose } = createForm({ 305 | onSubmit, 306 | onChangeUserName: profile.onChangeUser, 307 | }); 308 | 309 | document.body.style.margin = "0"; 310 | document.body.style.display = "flex"; 311 | document.body.style.flexDirection = "column"; 312 | document.body.style.alignItems = "center"; 313 | document.body.style.justifyContent = "center"; 314 | document.body.style.height = "100%"; 315 | document.body.style.width = "100%"; 316 | document.body.style.position = "absolute"; 317 | -------------------------------------------------------------------------------- /svg-only/dist/340.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 340; 3 | exports.ids = [340]; 4 | exports.modules = { 5 | 6 | /***/ 8340: 7 | /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 8 | 9 | // ESM COMPAT FLAG 10 | __webpack_require__.r(__webpack_exports__); 11 | 12 | // EXPORTS 13 | __webpack_require__.d(__webpack_exports__, { 14 | "createSvg": () => (/* binding */ createSvg) 15 | }); 16 | 17 | // EXTERNAL MODULE: ../types/grid.ts 18 | var types_grid = __webpack_require__(2881); 19 | // EXTERNAL MODULE: ../types/snake.ts 20 | var types_snake = __webpack_require__(9347); 21 | ;// CONCATENATED MODULE: ../svg-creator/xml-utils.ts 22 | const h = (element, attributes) => `<${element} ${toAttribute(attributes)}/>`; 23 | const toAttribute = (o) => Object.entries(o) 24 | .filter(([, value]) => value !== null) 25 | .map(([name, value]) => `${name}="${value}"`) 26 | .join(" "); 27 | 28 | ;// CONCATENATED MODULE: ../svg-creator/css-utils.ts 29 | const percent = (x) => parseFloat((x * 100).toFixed(2)).toString() + "%"; 30 | const mergeKeyFrames = (keyframes) => { 31 | const s = new Map(); 32 | for (const { t, style } of keyframes) { 33 | s.set(style, [...(s.get(style) ?? []), t]); 34 | } 35 | return Array.from(s.entries()) 36 | .map(([style, ts]) => ({ style, ts })) 37 | .sort((a, b) => a.ts[0] - b.ts[0]); 38 | }; 39 | /** 40 | * generate the keyframe animation from a list of keyframe 41 | */ 42 | const createAnimation = (name, keyframes) => `@keyframes ${name}{` + 43 | mergeKeyFrames(keyframes) 44 | .map(({ style, ts }) => ts.map(percent).join(",") + `{${style}}`) 45 | .join("") + 46 | "}"; 47 | /** 48 | * remove white spaces 49 | */ 50 | const minifyCss = (css) => css 51 | .replace(/\s+/g, " ") 52 | .replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, "")) 53 | .replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, "")) 54 | .replace(/.\s+[,;:{}()]/g, (a) => a.replace(/\s+/g, "")) 55 | .replace(/[,;:{}()]\s+./g, (a) => a.replace(/\s+/g, "")) 56 | .replace(/\;\s*\}/g, "}") 57 | .trim(); 58 | 59 | ;// CONCATENATED MODULE: ../svg-creator/snake.ts 60 | 61 | 62 | 63 | const lerp = (k, a, b) => (1 - k) * a + k * b; 64 | const createSnake = (chain, { sizeCell, sizeDot }, duration) => { 65 | const snakeN = chain[0] ? (0,types_snake/* getSnakeLength */.JJ)(chain[0]) : 0; 66 | const snakeParts = Array.from({ length: snakeN }, () => []); 67 | for (const snake of chain) { 68 | const cells = (0,types_snake/* snakeToCells */.Ks)(snake); 69 | for (let i = cells.length; i--;) 70 | snakeParts[i].push(cells[i]); 71 | } 72 | const svgElements = snakeParts.map((_, i, { length }) => { 73 | // compute snake part size 74 | const dMin = sizeDot * 0.8; 75 | const dMax = sizeCell * 0.9; 76 | const iMax = Math.min(4, length); 77 | const u = (1 - Math.min(i, iMax) / iMax) ** 2; 78 | const s = lerp(u, dMin, dMax); 79 | const m = (sizeCell - s) / 2; 80 | const r = Math.min(4.5, (4 * s) / sizeDot); 81 | return h("rect", { 82 | class: `s s${i}`, 83 | x: m.toFixed(1), 84 | y: m.toFixed(1), 85 | width: s.toFixed(1), 86 | height: s.toFixed(1), 87 | rx: r.toFixed(1), 88 | ry: r.toFixed(1), 89 | }); 90 | }); 91 | const transform = ({ x, y }) => `transform:translate(${x * sizeCell}px,${y * sizeCell}px)`; 92 | const styles = [ 93 | `.s{ 94 | shape-rendering: geometricPrecision; 95 | fill: var(--cs); 96 | animation: none linear ${duration}ms infinite 97 | }`, 98 | ...snakeParts.map((positions, i) => { 99 | const id = `s${i}`; 100 | const animationName = id; 101 | const keyframes = removeInterpolatedPositions(positions.map((tr, i, { length }) => ({ ...tr, t: i / length }))).map(({ t, ...p }) => ({ t, style: transform(p) })); 102 | return [ 103 | createAnimation(animationName, keyframes), 104 | `.s.${id}{ 105 | ${transform(positions[0])}; 106 | animation-name: ${animationName} 107 | }`, 108 | ]; 109 | }), 110 | ].flat(); 111 | return { svgElements, styles }; 112 | }; 113 | const removeInterpolatedPositions = (arr) => arr.filter((u, i, arr) => { 114 | if (i - 1 < 0 || i + 1 >= arr.length) 115 | return true; 116 | const a = arr[i - 1]; 117 | const b = arr[i + 1]; 118 | const ex = (a.x + b.x) / 2; 119 | const ey = (a.y + b.y) / 2; 120 | // return true; 121 | return !(Math.abs(ex - u.x) < 0.01 && Math.abs(ey - u.y) < 0.01); 122 | }); 123 | 124 | ;// CONCATENATED MODULE: ../svg-creator/grid.ts 125 | 126 | 127 | const createGrid = (cells, { sizeDotBorderRadius, sizeDot, sizeCell }, duration) => { 128 | const svgElements = []; 129 | const styles = [ 130 | `.c{ 131 | shape-rendering: geometricPrecision; 132 | fill: var(--ce); 133 | stroke-width: 1px; 134 | stroke: var(--cb); 135 | animation: none ${duration}ms linear infinite; 136 | width: ${sizeDot}px; 137 | height: ${sizeDot}px; 138 | }`, 139 | ]; 140 | let i = 0; 141 | for (const { x, y, color, t } of cells) { 142 | const id = t && "c" + (i++).toString(36); 143 | const m = (sizeCell - sizeDot) / 2; 144 | if (t !== null && id) { 145 | const animationName = id; 146 | styles.push(createAnimation(animationName, [ 147 | { t: t - 0.0001, style: `fill:var(--c${color})` }, 148 | { t: t + 0.0001, style: `fill:var(--ce)` }, 149 | { t: 1, style: `fill:var(--ce)` }, 150 | ]), `.c.${id}{ 151 | fill: var(--c${color}); 152 | animation-name: ${animationName} 153 | }`); 154 | } 155 | svgElements.push(h("rect", { 156 | class: ["c", id].filter(Boolean).join(" "), 157 | x: x * sizeCell + m, 158 | y: y * sizeCell + m, 159 | rx: sizeDotBorderRadius, 160 | ry: sizeDotBorderRadius, 161 | })); 162 | } 163 | return { svgElements, styles }; 164 | }; 165 | 166 | ;// CONCATENATED MODULE: ../svg-creator/stack.ts 167 | 168 | 169 | const createStack = (cells, { sizeDot }, width, y, duration) => { 170 | const svgElements = []; 171 | const styles = [ 172 | `.u{ 173 | transform-origin: 0 0; 174 | transform: scale(0,1); 175 | animation: none linear ${duration}ms infinite; 176 | }`, 177 | ]; 178 | const stack = cells 179 | .slice() 180 | .filter((a) => a.t !== null) 181 | .sort((a, b) => a.t - b.t); 182 | const blocks = []; 183 | stack.forEach(({ color, t }) => { 184 | const latest = blocks[blocks.length - 1]; 185 | if (latest?.color === color) 186 | latest.ts.push(t); 187 | else 188 | blocks.push({ color, ts: [t] }); 189 | }); 190 | const m = width / stack.length; 191 | let i = 0; 192 | let nx = 0; 193 | for (const { color, ts } of blocks) { 194 | const id = "u" + (i++).toString(36); 195 | const animationName = id; 196 | const x = (nx * m).toFixed(1); 197 | nx += ts.length; 198 | svgElements.push(h("rect", { 199 | class: `u ${id}`, 200 | height: sizeDot, 201 | width: (ts.length * m + 0.6).toFixed(1), 202 | x, 203 | y, 204 | })); 205 | styles.push(createAnimation(animationName, [ 206 | ...ts 207 | .map((t, i, { length }) => [ 208 | { scale: i / length, t: t - 0.0001 }, 209 | { scale: (i + 1) / length, t: t + 0.0001 }, 210 | ]) 211 | .flat(), 212 | { scale: 1, t: 1 }, 213 | ].map(({ scale, t }) => ({ 214 | t, 215 | style: `transform:scale(${scale.toFixed(3)},1)`, 216 | }))), `.u.${id} { 217 | fill: var(--c${color}); 218 | animation-name: ${animationName}; 219 | transform-origin: ${x}px 0 220 | } 221 | `); 222 | } 223 | return { svgElements, styles }; 224 | }; 225 | 226 | ;// CONCATENATED MODULE: ../svg-creator/index.ts 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | const getCellsFromGrid = ({ width, height }) => Array.from({ length: width }, (_, x) => Array.from({ length: height }, (_, y) => ({ x, y }))).flat(); 235 | const createLivingCells = (grid0, chain, cells) => { 236 | const livingCells = (cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({ 237 | x, 238 | y, 239 | t: null, 240 | color: (0,types_grid/* getColor */.Lq)(grid0, x, y), 241 | })); 242 | const grid = (0,types_grid/* copyGrid */.VJ)(grid0); 243 | for (let i = 0; i < chain.length; i++) { 244 | const snake = chain[i]; 245 | const x = (0,types_snake/* getHeadX */.If)(snake); 246 | const y = (0,types_snake/* getHeadY */.IP)(snake); 247 | if ((0,types_grid/* isInside */.V0)(grid, x, y) && !(0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, x, y))) { 248 | (0,types_grid/* setColorEmpty */.Dy)(grid, x, y); 249 | const cell = livingCells.find((c) => c.x === x && c.y === y); 250 | cell.t = i / chain.length; 251 | } 252 | } 253 | return livingCells; 254 | }; 255 | const createSvg = (grid, cells, chain, drawOptions, animationOptions) => { 256 | const width = (grid.width + 2) * drawOptions.sizeCell; 257 | const height = (grid.height + 5) * drawOptions.sizeCell; 258 | const duration = animationOptions.frameDuration * chain.length; 259 | const livingCells = createLivingCells(grid, chain, cells); 260 | const elements = [ 261 | createGrid(livingCells, drawOptions, duration), 262 | createStack(livingCells, drawOptions, grid.width * drawOptions.sizeCell, (grid.height + 2) * drawOptions.sizeCell, duration), 263 | createSnake(chain, drawOptions, duration), 264 | ]; 265 | const viewBox = [ 266 | -drawOptions.sizeCell, 267 | -drawOptions.sizeCell * 2, 268 | width, 269 | height, 270 | ].join(" "); 271 | const style = generateColorVar(drawOptions) + 272 | elements 273 | .map((e) => e.styles) 274 | .flat() 275 | .join("\n"); 276 | const svg = [ 277 | h("svg", { 278 | viewBox, 279 | width, 280 | height, 281 | xmlns: "http://www.w3.org/2000/svg", 282 | }).replace("/>", ">"), 283 | "", 284 | "Generated with https://github.com/Platane/snk", 285 | "", 286 | "", 289 | ...elements.map((e) => e.svgElements).flat(), 290 | "", 291 | ].join(""); 292 | return optimizeSvg(svg); 293 | }; 294 | const optimizeCss = (css) => minifyCss(css); 295 | const optimizeSvg = (svg) => svg; 296 | const generateColorVar = (drawOptions) => ` 297 | :root { 298 | --cb: ${drawOptions.colorDotBorder}; 299 | --cs: ${drawOptions.colorSnake}; 300 | --ce: ${drawOptions.colorEmpty}; 301 | ${Object.entries(drawOptions.colorDots) 302 | .map(([i, color]) => `--c${i}:${color};`) 303 | .join("")} 304 | } 305 | ` + 306 | (drawOptions.dark 307 | ? ` 308 | @media (prefers-color-scheme: dark) { 309 | :root { 310 | --cb: ${drawOptions.dark.colorDotBorder || drawOptions.colorDotBorder}; 311 | --cs: ${drawOptions.dark.colorSnake || drawOptions.colorSnake}; 312 | --ce: ${drawOptions.dark.colorEmpty}; 313 | ${Object.entries(drawOptions.dark.colorDots) 314 | .map(([i, color]) => `--c${i}:${color};`) 315 | .join("")} 316 | } 317 | } 318 | ` 319 | : ""); 320 | 321 | 322 | /***/ }) 323 | 324 | }; 325 | ; -------------------------------------------------------------------------------- /svg-only/dist/407.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 407; 3 | exports.ids = [407]; 4 | exports.modules = { 5 | 6 | /***/ 407: 7 | /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 8 | 9 | // ESM COMPAT FLAG 10 | __webpack_require__.r(__webpack_exports__); 11 | 12 | // EXPORTS 13 | __webpack_require__.d(__webpack_exports__, { 14 | "generateContributionSnake": () => (/* binding */ generateContributionSnake) 15 | }); 16 | 17 | ;// CONCATENATED MODULE: ../github-user-contribution/index.ts 18 | /** 19 | * get the contribution grid from a github user page 20 | * 21 | * use options.from=YYYY-MM-DD options.to=YYYY-MM-DD to get the contribution grid for a specific time range 22 | * or year=2019 as an alias for from=2019-01-01 to=2019-12-31 23 | * 24 | * otherwise return use the time range from today minus one year to today ( as seen in github profile page ) 25 | * 26 | * @param userName github user name 27 | * @param options 28 | * 29 | * @example 30 | * getGithubUserContribution("platane", { from: "2019-01-01", to: "2019-12-31" }) 31 | * getGithubUserContribution("platane", { year: 2019 }) 32 | * 33 | */ 34 | const getGithubUserContribution = async (userName, o) => { 35 | const query = /* GraphQL */ ` 36 | query ($login: String!) { 37 | user(login: $login) { 38 | contributionsCollection { 39 | contributionCalendar { 40 | weeks { 41 | contributionDays { 42 | contributionCount 43 | contributionLevel 44 | weekday 45 | date 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | `; 53 | const variables = { login: userName }; 54 | const res = await fetch("https://api.github.com/graphql", { 55 | headers: { 56 | Authorization: `bearer ${o.githubToken}`, 57 | "Content-Type": "application/json", 58 | }, 59 | method: "POST", 60 | body: JSON.stringify({ variables, query }), 61 | }); 62 | if (!res.ok) 63 | throw new Error(res.statusText); 64 | const { data, errors } = (await res.json()); 65 | if (errors?.[0]) 66 | throw errors[0]; 67 | return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(({ contributionDays }, x) => contributionDays.map((d) => ({ 68 | x, 69 | y: d.weekday, 70 | date: d.date, 71 | count: d.contributionCount, 72 | level: (d.contributionLevel === "FOURTH_QUARTILE" && 4) || 73 | (d.contributionLevel === "THIRD_QUARTILE" && 3) || 74 | (d.contributionLevel === "SECOND_QUARTILE" && 2) || 75 | (d.contributionLevel === "FIRST_QUARTILE" && 1) || 76 | 0, 77 | }))); 78 | }; 79 | 80 | // EXTERNAL MODULE: ../types/grid.ts 81 | var types_grid = __webpack_require__(2881); 82 | ;// CONCATENATED MODULE: ./userContributionToGrid.ts 83 | 84 | const userContributionToGrid = (cells) => { 85 | const width = Math.max(0, ...cells.map((c) => c.x)) + 1; 86 | const height = Math.max(0, ...cells.map((c) => c.y)) + 1; 87 | const grid = (0,types_grid/* createEmptyGrid */.u1)(width, height); 88 | for (const c of cells) { 89 | if (c.level > 0) 90 | (0,types_grid/* setColor */.vk)(grid, c.x, c.y, c.level); 91 | else 92 | (0,types_grid/* setColorEmpty */.Dy)(grid, c.x, c.y); 93 | } 94 | return grid; 95 | }; 96 | 97 | ;// CONCATENATED MODULE: ../types/point.ts 98 | const around4 = [ 99 | { x: 1, y: 0 }, 100 | { x: 0, y: -1 }, 101 | { x: -1, y: 0 }, 102 | { x: 0, y: 1 }, 103 | ]; 104 | const pointEquals = (a, b) => a.x === b.x && a.y === b.y; 105 | 106 | ;// CONCATENATED MODULE: ../solver/outside.ts 107 | 108 | 109 | const createOutside = (grid, color = 0) => { 110 | const outside = (0,types_grid/* createEmptyGrid */.u1)(grid.width, grid.height); 111 | for (let x = outside.width; x--;) 112 | for (let y = outside.height; y--;) 113 | (0,types_grid/* setColor */.vk)(outside, x, y, 1); 114 | fillOutside(outside, grid, color); 115 | return outside; 116 | }; 117 | const fillOutside = (outside, grid, color = 0) => { 118 | let changed = true; 119 | while (changed) { 120 | changed = false; 121 | for (let x = outside.width; x--;) 122 | for (let y = outside.height; y--;) 123 | if ((0,types_grid/* getColor */.Lq)(grid, x, y) <= color && 124 | !isOutside(outside, x, y) && 125 | around4.some((a) => isOutside(outside, x + a.x, y + a.y))) { 126 | changed = true; 127 | (0,types_grid/* setColorEmpty */.Dy)(outside, x, y); 128 | } 129 | } 130 | return outside; 131 | }; 132 | const isOutside = (outside, x, y) => !(0,types_grid/* isInside */.V0)(outside, x, y) || (0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(outside, x, y)); 133 | 134 | // EXTERNAL MODULE: ../types/snake.ts 135 | var types_snake = __webpack_require__(9347); 136 | ;// CONCATENATED MODULE: ../solver/utils/sortPush.ts 137 | const sortPush = (arr, x, sortFn) => { 138 | let a = 0; 139 | let b = arr.length; 140 | if (arr.length === 0 || sortFn(x, arr[a]) <= 0) { 141 | arr.unshift(x); 142 | return; 143 | } 144 | while (b - a > 1) { 145 | const e = Math.ceil((a + b) / 2); 146 | const s = sortFn(x, arr[e]); 147 | if (s === 0) 148 | a = b = e; 149 | else if (s > 0) 150 | a = e; 151 | else 152 | b = e; 153 | } 154 | const e = Math.ceil((a + b) / 2); 155 | arr.splice(e, 0, x); 156 | }; 157 | 158 | ;// CONCATENATED MODULE: ../solver/tunnel.ts 159 | 160 | 161 | /** 162 | * get the sequence of snake to cross the tunnel 163 | */ 164 | const getTunnelPath = (snake0, tunnel) => { 165 | const chain = []; 166 | let snake = snake0; 167 | for (let i = 1; i < tunnel.length; i++) { 168 | const dx = tunnel[i].x - (0,types_snake/* getHeadX */.If)(snake); 169 | const dy = tunnel[i].y - (0,types_snake/* getHeadY */.IP)(snake); 170 | snake = (0,types_snake/* nextSnake */.kv)(snake, dx, dy); 171 | chain.unshift(snake); 172 | } 173 | return chain; 174 | }; 175 | /** 176 | * assuming the grid change and the colors got deleted, update the tunnel 177 | */ 178 | const updateTunnel = (grid, tunnel, toDelete) => { 179 | while (tunnel.length) { 180 | const { x, y } = tunnel[0]; 181 | if (isEmptySafe(grid, x, y) || 182 | toDelete.some((p) => p.x === x && p.y === y)) { 183 | tunnel.shift(); 184 | } 185 | else 186 | break; 187 | } 188 | while (tunnel.length) { 189 | const { x, y } = tunnel[tunnel.length - 1]; 190 | if (isEmptySafe(grid, x, y) || 191 | toDelete.some((p) => p.x === x && p.y === y)) { 192 | tunnel.pop(); 193 | } 194 | else 195 | break; 196 | } 197 | }; 198 | const isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.V0)(grid, x, y) || (0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, x, y)); 199 | /** 200 | * remove empty cell from start 201 | */ 202 | const trimTunnelStart = (grid, tunnel) => { 203 | while (tunnel.length) { 204 | const { x, y } = tunnel[0]; 205 | if (isEmptySafe(grid, x, y)) 206 | tunnel.shift(); 207 | else 208 | break; 209 | } 210 | }; 211 | /** 212 | * remove empty cell from end 213 | */ 214 | const trimTunnelEnd = (grid, tunnel) => { 215 | while (tunnel.length) { 216 | const i = tunnel.length - 1; 217 | const { x, y } = tunnel[i]; 218 | if (isEmptySafe(grid, x, y) || 219 | tunnel.findIndex((p) => p.x === x && p.y === y) < i) 220 | tunnel.pop(); 221 | else 222 | break; 223 | } 224 | }; 225 | 226 | ;// CONCATENATED MODULE: ../solver/getBestTunnel.ts 227 | 228 | 229 | 230 | 231 | 232 | 233 | const getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.V0)(grid, x, y) ? (0,types_grid/* getColor */.Lq)(grid, x, y) : 0; 234 | const setEmptySafe = (grid, x, y) => { 235 | if ((0,types_grid/* isInside */.V0)(grid, x, y)) 236 | (0,types_grid/* setColorEmpty */.Dy)(grid, x, y); 237 | }; 238 | const unwrap = (m) => !m 239 | ? [] 240 | : [...unwrap(m.parent), { x: (0,types_snake/* getHeadX */.If)(m.snake), y: (0,types_snake/* getHeadY */.IP)(m.snake) }]; 241 | /** 242 | * returns the path to reach the outside which contains the least color cell 243 | */ 244 | const getSnakeEscapePath = (grid, outside, snake0, color) => { 245 | const openList = [{ snake: snake0, w: 0 }]; 246 | const closeList = []; 247 | while (openList[0]) { 248 | const o = openList.shift(); 249 | const x = (0,types_snake/* getHeadX */.If)(o.snake); 250 | const y = (0,types_snake/* getHeadY */.IP)(o.snake); 251 | if (isOutside(outside, x, y)) 252 | return unwrap(o); 253 | for (const a of around4) { 254 | const c = getColorSafe(grid, x + a.x, y + a.y); 255 | if (c <= color && !(0,types_snake/* snakeWillSelfCollide */.nJ)(o.snake, a.x, a.y)) { 256 | const snake = (0,types_snake/* nextSnake */.kv)(o.snake, a.x, a.y); 257 | if (!closeList.some((s0) => (0,types_snake/* snakeEquals */.kE)(s0, snake))) { 258 | const w = o.w + 1 + +(c === color) * 1000; 259 | sortPush(openList, { snake, w, parent: o }, (a, b) => a.w - b.w); 260 | closeList.push(snake); 261 | } 262 | } 263 | } 264 | } 265 | return null; 266 | }; 267 | /** 268 | * compute the best tunnel to get to the cell and back to the outside ( best = less usage of ) 269 | * 270 | * notice that it's one of the best tunnels, more with the same score could exist 271 | */ 272 | const getBestTunnel = (grid, outside, x, y, color, snakeN) => { 273 | const c = { x, y }; 274 | const snake0 = (0,types_snake/* createSnakeFromCells */.xG)(Array.from({ length: snakeN }, () => c)); 275 | const one = getSnakeEscapePath(grid, outside, snake0, color); 276 | if (!one) 277 | return null; 278 | // get the position of the snake if it was going to leave the x,y cell 279 | const snakeICells = one.slice(0, snakeN); 280 | while (snakeICells.length < snakeN) 281 | snakeICells.push(snakeICells[snakeICells.length - 1]); 282 | const snakeI = (0,types_snake/* createSnakeFromCells */.xG)(snakeICells); 283 | // remove from the grid the colors that one eat 284 | const gridI = (0,types_grid/* copyGrid */.VJ)(grid); 285 | for (const { x, y } of one) 286 | setEmptySafe(gridI, x, y); 287 | const two = getSnakeEscapePath(gridI, outside, snakeI, color); 288 | if (!two) 289 | return null; 290 | one.shift(); 291 | one.reverse(); 292 | one.push(...two); 293 | trimTunnelStart(grid, one); 294 | trimTunnelEnd(grid, one); 295 | return one; 296 | }; 297 | 298 | ;// CONCATENATED MODULE: ../solver/getPathTo.ts 299 | 300 | 301 | 302 | 303 | /** 304 | * starting from snake0, get to the cell x,y 305 | * return the snake chain (reversed) 306 | */ 307 | const getPathTo = (grid, snake0, x, y) => { 308 | const openList = [{ snake: snake0, w: 0 }]; 309 | const closeList = []; 310 | while (openList.length) { 311 | const c = openList.shift(); 312 | const cx = (0,types_snake/* getHeadX */.If)(c.snake); 313 | const cy = (0,types_snake/* getHeadY */.IP)(c.snake); 314 | for (let i = 0; i < around4.length; i++) { 315 | const { x: dx, y: dy } = around4[i]; 316 | const nx = cx + dx; 317 | const ny = cy + dy; 318 | if (nx === x && ny === y) { 319 | // unwrap 320 | const path = [(0,types_snake/* nextSnake */.kv)(c.snake, dx, dy)]; 321 | let e = c; 322 | while (e.parent) { 323 | path.push(e.snake); 324 | e = e.parent; 325 | } 326 | return path; 327 | } 328 | if ((0,types_grid/* isInsideLarge */.HJ)(grid, 2, nx, ny) && 329 | !(0,types_snake/* snakeWillSelfCollide */.nJ)(c.snake, dx, dy) && 330 | (!(0,types_grid/* isInside */.V0)(grid, nx, ny) || (0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, nx, ny)))) { 331 | const nsnake = (0,types_snake/* nextSnake */.kv)(c.snake, dx, dy); 332 | if (!closeList.some((s) => (0,types_snake/* snakeEquals */.kE)(nsnake, s))) { 333 | const w = c.w + 1; 334 | const h = Math.abs(nx - x) + Math.abs(ny - y); 335 | const f = w + h; 336 | const o = { snake: nsnake, parent: c, w, h, f }; 337 | sortPush(openList, o, (a, b) => a.f - b.f); 338 | closeList.push(nsnake); 339 | } 340 | } 341 | } 342 | } 343 | }; 344 | 345 | ;// CONCATENATED MODULE: ../solver/clearResidualColoredLayer.ts 346 | 347 | 348 | 349 | 350 | 351 | 352 | const clearResidualColoredLayer = (grid, outside, snake0, color) => { 353 | const snakeN = (0,types_snake/* getSnakeLength */.JJ)(snake0); 354 | const tunnels = getTunnellablePoints(grid, outside, snakeN, color); 355 | // sort 356 | tunnels.sort((a, b) => b.priority - a.priority); 357 | const chain = [snake0]; 358 | while (tunnels.length) { 359 | // get the best next tunnel 360 | let t = getNextTunnel(tunnels, chain[0]); 361 | // goes to the start of the tunnel 362 | chain.unshift(...getPathTo(grid, chain[0], t[0].x, t[0].y)); 363 | // goes to the end of the tunnel 364 | chain.unshift(...getTunnelPath(chain[0], t)); 365 | // update grid 366 | for (const { x, y } of t) 367 | clearResidualColoredLayer_setEmptySafe(grid, x, y); 368 | // update outside 369 | fillOutside(outside, grid); 370 | // update tunnels 371 | for (let i = tunnels.length; i--;) 372 | if ((0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, tunnels[i].x, tunnels[i].y))) 373 | tunnels.splice(i, 1); 374 | else { 375 | const t = tunnels[i]; 376 | const tunnel = getBestTunnel(grid, outside, t.x, t.y, color, snakeN); 377 | if (!tunnel) 378 | tunnels.splice(i, 1); 379 | else { 380 | t.tunnel = tunnel; 381 | t.priority = getPriority(grid, color, tunnel); 382 | } 383 | } 384 | // re-sort 385 | tunnels.sort((a, b) => b.priority - a.priority); 386 | } 387 | chain.pop(); 388 | return chain; 389 | }; 390 | const getNextTunnel = (ts, snake) => { 391 | let minDistance = Infinity; 392 | let closestTunnel = null; 393 | const x = (0,types_snake/* getHeadX */.If)(snake); 394 | const y = (0,types_snake/* getHeadY */.IP)(snake); 395 | const priority = ts[0].priority; 396 | for (let i = 0; ts[i] && ts[i].priority === priority; i++) { 397 | const t = ts[i].tunnel; 398 | const d = distanceSq(t[0].x, t[0].y, x, y); 399 | if (d < minDistance) { 400 | minDistance = d; 401 | closestTunnel = t; 402 | } 403 | } 404 | return closestTunnel; 405 | }; 406 | /** 407 | * get all the tunnels for all the cells accessible 408 | */ 409 | const getTunnellablePoints = (grid, outside, snakeN, color) => { 410 | const points = []; 411 | for (let x = grid.width; x--;) 412 | for (let y = grid.height; y--;) { 413 | const c = (0,types_grid/* getColor */.Lq)(grid, x, y); 414 | if (!(0,types_grid/* isEmpty */.xb)(c) && c < color) { 415 | const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN); 416 | if (tunnel) { 417 | const priority = getPriority(grid, color, tunnel); 418 | points.push({ x, y, priority, tunnel }); 419 | } 420 | } 421 | } 422 | return points; 423 | }; 424 | /** 425 | * get the score of the tunnel 426 | * prioritize tunnel with maximum color smaller than and with minimum 427 | * with some tweaks 428 | */ 429 | const getPriority = (grid, color, tunnel) => { 430 | let nColor = 0; 431 | let nLess = 0; 432 | for (let i = 0; i < tunnel.length; i++) { 433 | const { x, y } = tunnel[i]; 434 | const c = clearResidualColoredLayer_getColorSafe(grid, x, y); 435 | if (!(0,types_grid/* isEmpty */.xb)(c) && i === tunnel.findIndex((p) => p.x === x && p.y === y)) { 436 | if (c === color) 437 | nColor += 1; 438 | else 439 | nLess += color - c; 440 | } 441 | } 442 | if (nColor === 0) 443 | return 99999; 444 | return nLess / nColor; 445 | }; 446 | const distanceSq = (ax, ay, bx, by) => (ax - bx) ** 2 + (ay - by) ** 2; 447 | const clearResidualColoredLayer_getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.V0)(grid, x, y) ? (0,types_grid/* getColor */.Lq)(grid, x, y) : 0; 448 | const clearResidualColoredLayer_setEmptySafe = (grid, x, y) => { 449 | if ((0,types_grid/* isInside */.V0)(grid, x, y)) 450 | (0,types_grid/* setColorEmpty */.Dy)(grid, x, y); 451 | }; 452 | 453 | ;// CONCATENATED MODULE: ../solver/clearCleanColoredLayer.ts 454 | 455 | 456 | 457 | 458 | 459 | const clearCleanColoredLayer = (grid, outside, snake0, color) => { 460 | const snakeN = (0,types_snake/* getSnakeLength */.JJ)(snake0); 461 | const points = clearCleanColoredLayer_getTunnellablePoints(grid, outside, snakeN, color); 462 | const chain = [snake0]; 463 | while (points.length) { 464 | const path = getPathToNextPoint(grid, chain[0], color, points); 465 | path.pop(); 466 | for (const snake of path) 467 | clearCleanColoredLayer_setEmptySafe(grid, (0,types_snake/* getHeadX */.If)(snake), (0,types_snake/* getHeadY */.IP)(snake)); 468 | chain.unshift(...path); 469 | } 470 | fillOutside(outside, grid); 471 | chain.pop(); 472 | return chain; 473 | }; 474 | const clearCleanColoredLayer_unwrap = (m) => !m ? [] : [m.snake, ...clearCleanColoredLayer_unwrap(m.parent)]; 475 | const getPathToNextPoint = (grid, snake0, color, points) => { 476 | const closeList = []; 477 | const openList = [{ snake: snake0 }]; 478 | while (openList.length) { 479 | const o = openList.shift(); 480 | const x = (0,types_snake/* getHeadX */.If)(o.snake); 481 | const y = (0,types_snake/* getHeadY */.IP)(o.snake); 482 | const i = points.findIndex((p) => p.x === x && p.y === y); 483 | if (i >= 0) { 484 | points.splice(i, 1); 485 | return clearCleanColoredLayer_unwrap(o); 486 | } 487 | for (const { x: dx, y: dy } of around4) { 488 | if ((0,types_grid/* isInsideLarge */.HJ)(grid, 2, x + dx, y + dy) && 489 | !(0,types_snake/* snakeWillSelfCollide */.nJ)(o.snake, dx, dy) && 490 | clearCleanColoredLayer_getColorSafe(grid, x + dx, y + dy) <= color) { 491 | const snake = (0,types_snake/* nextSnake */.kv)(o.snake, dx, dy); 492 | if (!closeList.some((s0) => (0,types_snake/* snakeEquals */.kE)(s0, snake))) { 493 | closeList.push(snake); 494 | openList.push({ snake, parent: o }); 495 | } 496 | } 497 | } 498 | } 499 | }; 500 | /** 501 | * get all cells that are tunnellable 502 | */ 503 | const clearCleanColoredLayer_getTunnellablePoints = (grid, outside, snakeN, color) => { 504 | const points = []; 505 | for (let x = grid.width; x--;) 506 | for (let y = grid.height; y--;) { 507 | const c = (0,types_grid/* getColor */.Lq)(grid, x, y); 508 | if (!(0,types_grid/* isEmpty */.xb)(c) && 509 | c <= color && 510 | !points.some((p) => p.x === x && p.y === y)) { 511 | const tunnel = getBestTunnel(grid, outside, x, y, color, snakeN); 512 | if (tunnel) 513 | for (const p of tunnel) 514 | if (!clearCleanColoredLayer_isEmptySafe(grid, p.x, p.y)) 515 | points.push(p); 516 | } 517 | } 518 | return points; 519 | }; 520 | const clearCleanColoredLayer_getColorSafe = (grid, x, y) => (0,types_grid/* isInside */.V0)(grid, x, y) ? (0,types_grid/* getColor */.Lq)(grid, x, y) : 0; 521 | const clearCleanColoredLayer_setEmptySafe = (grid, x, y) => { 522 | if ((0,types_grid/* isInside */.V0)(grid, x, y)) 523 | (0,types_grid/* setColorEmpty */.Dy)(grid, x, y); 524 | }; 525 | const clearCleanColoredLayer_isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.V0)(grid, x, y) && (0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, x, y)); 526 | 527 | ;// CONCATENATED MODULE: ../solver/getBestRoute.ts 528 | 529 | 530 | 531 | 532 | const getBestRoute = (grid0, snake0) => { 533 | const grid = (0,types_grid/* copyGrid */.VJ)(grid0); 534 | const outside = createOutside(grid); 535 | const chain = [snake0]; 536 | for (const color of extractColors(grid)) { 537 | if (color > 1) 538 | chain.unshift(...clearResidualColoredLayer(grid, outside, chain[0], color)); 539 | chain.unshift(...clearCleanColoredLayer(grid, outside, chain[0], color)); 540 | } 541 | return chain.reverse(); 542 | }; 543 | const extractColors = (grid) => { 544 | // @ts-ignore 545 | let maxColor = Math.max(...grid.data); 546 | return Array.from({ length: maxColor }, (_, i) => (i + 1)); 547 | }; 548 | 549 | ;// CONCATENATED MODULE: ../types/__fixtures__/snake.ts 550 | 551 | const create = (length) => (0,types_snake/* createSnakeFromCells */.xG)(Array.from({ length }, (_, i) => ({ x: i, y: -1 }))); 552 | const snake1 = create(1); 553 | const snake3 = create(3); 554 | const snake4 = create(4); 555 | const snake5 = create(5); 556 | const snake9 = create(9); 557 | 558 | ;// CONCATENATED MODULE: ../solver/getPathToPose.ts 559 | 560 | 561 | 562 | 563 | 564 | const getPathToPose_isEmptySafe = (grid, x, y) => !(0,types_grid/* isInside */.V0)(grid, x, y) || (0,types_grid/* isEmpty */.xb)((0,types_grid/* getColor */.Lq)(grid, x, y)); 565 | const getPathToPose = (snake0, target, grid) => { 566 | if ((0,types_snake/* snakeEquals */.kE)(snake0, target)) 567 | return []; 568 | const targetCells = (0,types_snake/* snakeToCells */.Ks)(target).reverse(); 569 | const snakeN = (0,types_snake/* getSnakeLength */.JJ)(snake0); 570 | const box = { 571 | min: { 572 | x: Math.min((0,types_snake/* getHeadX */.If)(snake0), (0,types_snake/* getHeadX */.If)(target)) - snakeN - 1, 573 | y: Math.min((0,types_snake/* getHeadY */.IP)(snake0), (0,types_snake/* getHeadY */.IP)(target)) - snakeN - 1, 574 | }, 575 | max: { 576 | x: Math.max((0,types_snake/* getHeadX */.If)(snake0), (0,types_snake/* getHeadX */.If)(target)) + snakeN + 1, 577 | y: Math.max((0,types_snake/* getHeadY */.IP)(snake0), (0,types_snake/* getHeadY */.IP)(target)) + snakeN + 1, 578 | }, 579 | }; 580 | const [t0, ...forbidden] = targetCells; 581 | forbidden.slice(0, 3); 582 | const openList = [{ snake: snake0, w: 0 }]; 583 | const closeList = []; 584 | while (openList.length) { 585 | const o = openList.shift(); 586 | const x = (0,types_snake/* getHeadX */.If)(o.snake); 587 | const y = (0,types_snake/* getHeadY */.IP)(o.snake); 588 | if (x === t0.x && y === t0.y) { 589 | const path = []; 590 | let e = o; 591 | while (e) { 592 | path.push(e.snake); 593 | e = e.parent; 594 | } 595 | path.unshift(...getTunnelPath(path[0], targetCells)); 596 | path.pop(); 597 | path.reverse(); 598 | return path; 599 | } 600 | for (let i = 0; i < around4.length; i++) { 601 | const { x: dx, y: dy } = around4[i]; 602 | const nx = x + dx; 603 | const ny = y + dy; 604 | if (!(0,types_snake/* snakeWillSelfCollide */.nJ)(o.snake, dx, dy) && 605 | (!grid || getPathToPose_isEmptySafe(grid, nx, ny)) && 606 | (grid 607 | ? (0,types_grid/* isInsideLarge */.HJ)(grid, 2, nx, ny) 608 | : box.min.x <= nx && 609 | nx <= box.max.x && 610 | box.min.y <= ny && 611 | ny <= box.max.y) && 612 | !forbidden.some((p) => p.x === nx && p.y === ny)) { 613 | const snake = (0,types_snake/* nextSnake */.kv)(o.snake, dx, dy); 614 | if (!closeList.some((s) => (0,types_snake/* snakeEquals */.kE)(snake, s))) { 615 | const w = o.w + 1; 616 | const h = Math.abs(nx - x) + Math.abs(ny - y); 617 | const f = w + h; 618 | sortPush(openList, { f, w, snake, parent: o }, (a, b) => a.f - b.f); 619 | closeList.push(snake); 620 | } 621 | } 622 | } 623 | } 624 | }; 625 | 626 | ;// CONCATENATED MODULE: ./generateContributionSnake.ts 627 | 628 | 629 | 630 | 631 | 632 | const generateContributionSnake = async (userName, outputs, options) => { 633 | console.log("🎣 fetching github user contribution"); 634 | const cells = await getGithubUserContribution(userName, options); 635 | const grid = userContributionToGrid(cells); 636 | const snake = snake4; 637 | console.log("📡 computing best route"); 638 | const chain = getBestRoute(grid, snake); 639 | chain.push(...getPathToPose(chain.slice(-1)[0], snake)); 640 | return Promise.all(outputs.map(async (out, i) => { 641 | if (!out) 642 | return; 643 | const { format, drawOptions, animationOptions } = out; 644 | switch (format) { 645 | case "svg": { 646 | console.log(`🖌 creating svg (outputs[${i}])`); 647 | const { createSvg } = await __webpack_require__.e(/* import() */ 340).then(__webpack_require__.bind(__webpack_require__, 8340)); 648 | return createSvg(grid, cells, chain, drawOptions, animationOptions); 649 | } 650 | case "gif": { 651 | console.log(`📹 creating gif (outputs[${i}])`); 652 | const { createGif } = await Promise.all(/* import() */[__webpack_require__.e(371), __webpack_require__.e(142)]).then(__webpack_require__.bind(__webpack_require__, 7142)); 653 | return await createGif(grid, cells, chain, drawOptions, animationOptions); 654 | } 655 | } 656 | })); 657 | }; 658 | 659 | 660 | /***/ }), 661 | 662 | /***/ 2881: 663 | /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 664 | 665 | /* harmony export */ __webpack_require__.d(__webpack_exports__, { 666 | /* harmony export */ "Dy": () => (/* binding */ setColorEmpty), 667 | /* harmony export */ "HJ": () => (/* binding */ isInsideLarge), 668 | /* harmony export */ "Lq": () => (/* binding */ getColor), 669 | /* harmony export */ "V0": () => (/* binding */ isInside), 670 | /* harmony export */ "VJ": () => (/* binding */ copyGrid), 671 | /* harmony export */ "u1": () => (/* binding */ createEmptyGrid), 672 | /* harmony export */ "vk": () => (/* binding */ setColor), 673 | /* harmony export */ "xb": () => (/* binding */ isEmpty) 674 | /* harmony export */ }); 675 | /* unused harmony exports isGridEmpty, gridEquals */ 676 | const isInside = (grid, x, y) => x >= 0 && y >= 0 && x < grid.width && y < grid.height; 677 | const isInsideLarge = (grid, m, x, y) => x >= -m && y >= -m && x < grid.width + m && y < grid.height + m; 678 | const copyGrid = ({ width, height, data }) => ({ 679 | width, 680 | height, 681 | data: Uint8Array.from(data), 682 | }); 683 | const getIndex = (grid, x, y) => x * grid.height + y; 684 | const getColor = (grid, x, y) => grid.data[getIndex(grid, x, y)]; 685 | const isEmpty = (color) => color === 0; 686 | const setColor = (grid, x, y, color) => { 687 | grid.data[getIndex(grid, x, y)] = color || 0; 688 | }; 689 | const setColorEmpty = (grid, x, y) => { 690 | setColor(grid, x, y, 0); 691 | }; 692 | /** 693 | * return true if the grid is empty 694 | */ 695 | const isGridEmpty = (grid) => grid.data.every((x) => x === 0); 696 | const gridEquals = (a, b) => a.data.every((_, i) => a.data[i] === b.data[i]); 697 | const createEmptyGrid = (width, height) => ({ 698 | width, 699 | height, 700 | data: new Uint8Array(width * height), 701 | }); 702 | 703 | 704 | /***/ }), 705 | 706 | /***/ 9347: 707 | /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 708 | 709 | /* harmony export */ __webpack_require__.d(__webpack_exports__, { 710 | /* harmony export */ "IP": () => (/* binding */ getHeadY), 711 | /* harmony export */ "If": () => (/* binding */ getHeadX), 712 | /* harmony export */ "JJ": () => (/* binding */ getSnakeLength), 713 | /* harmony export */ "Ks": () => (/* binding */ snakeToCells), 714 | /* harmony export */ "kE": () => (/* binding */ snakeEquals), 715 | /* harmony export */ "kv": () => (/* binding */ nextSnake), 716 | /* harmony export */ "nJ": () => (/* binding */ snakeWillSelfCollide), 717 | /* harmony export */ "xG": () => (/* binding */ createSnakeFromCells) 718 | /* harmony export */ }); 719 | /* unused harmony export copySnake */ 720 | const getHeadX = (snake) => snake[0] - 2; 721 | const getHeadY = (snake) => snake[1] - 2; 722 | const getSnakeLength = (snake) => snake.length / 2; 723 | const copySnake = (snake) => snake.slice(); 724 | const snakeEquals = (a, b) => { 725 | for (let i = 0; i < a.length; i++) 726 | if (a[i] !== b[i]) 727 | return false; 728 | return true; 729 | }; 730 | /** 731 | * return a copy of the next snake, considering that dx, dy is the direction 732 | */ 733 | const nextSnake = (snake, dx, dy) => { 734 | const copy = new Uint8Array(snake.length); 735 | for (let i = 2; i < snake.length; i++) 736 | copy[i] = snake[i - 2]; 737 | copy[0] = snake[0] + dx; 738 | copy[1] = snake[1] + dy; 739 | return copy; 740 | }; 741 | /** 742 | * return true if the next snake will collide with itself 743 | */ 744 | const snakeWillSelfCollide = (snake, dx, dy) => { 745 | const nx = snake[0] + dx; 746 | const ny = snake[1] + dy; 747 | for (let i = 2; i < snake.length - 2; i += 2) 748 | if (snake[i + 0] === nx && snake[i + 1] === ny) 749 | return true; 750 | return false; 751 | }; 752 | const snakeToCells = (snake) => Array.from({ length: snake.length / 2 }, (_, i) => ({ 753 | x: snake[i * 2 + 0] - 2, 754 | y: snake[i * 2 + 1] - 2, 755 | })); 756 | const createSnakeFromCells = (points) => { 757 | const snake = new Uint8Array(points.length * 2); 758 | for (let i = points.length; i--;) { 759 | snake[i * 2 + 0] = points[i].x + 2; 760 | snake[i * 2 + 1] = points[i].y + 2; 761 | } 762 | return snake; 763 | }; 764 | 765 | 766 | /***/ }) 767 | 768 | }; 769 | ; --------------------------------------------------------------------------------