Compare commits
14 Commits
v2.0.0-rc.
...
v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d078b2d231 | ||
|
|
a81c1bcc97 | ||
|
|
40b26d0110 | ||
|
|
d6e930af5b | ||
|
|
98feaa6035 | ||
|
|
8f1481341a | ||
|
|
2e275adbb6 | ||
|
|
66fef03781 | ||
|
|
5841a21a09 | ||
|
|
cce5c4514d | ||
|
|
fb82d42d53 | ||
|
|
e3ad8b2caf | ||
|
|
c21e390ca9 | ||
|
|
7077112ba4 |
15
.github/workflows/main.yml
vendored
15
.github/workflows/main.yml
vendored
@@ -18,19 +18,6 @@ jobs:
|
||||
- run: yarn lint
|
||||
- run: yarn test --ci
|
||||
|
||||
test-benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: ( cd packages/gif-creator ; yarn benchmark )
|
||||
|
||||
test-action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -79,7 +66,7 @@ jobs:
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.6.0
|
||||
if: success() && github.ref.name == 'main'
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
|
||||
11
README.md
11
README.md
@@ -21,7 +21,7 @@ Available as github action. Automatically generate a new image at the end of the
|
||||
**github action**
|
||||
|
||||
```yaml
|
||||
- uses: Platane/snk@v2
|
||||
- uses: Platane/snk@v2.0.0-rc.3
|
||||
with:
|
||||
# github user name to read the contribution graph from (**required**)
|
||||
# using action context var `github.repository_owner` or specified user
|
||||
@@ -29,6 +29,13 @@ Available as github action. Automatically generate a new image at the end of the
|
||||
|
||||
# list of files to generate.
|
||||
# one file per line. Each output can be customized with options as query string.
|
||||
#
|
||||
# supported options:
|
||||
# - palette: A preset of color, one of [github, github-dark, github-light]
|
||||
# - color_snake: Color of the snake
|
||||
# - color_dots: Coma separated list of dots color.
|
||||
# The first one is 0 contribution, then it goes from the low contribution to the highest.
|
||||
# Exactly 5 colors are expected.
|
||||
outputs: |
|
||||
dist/github-snake.svg
|
||||
dist/github-snake.svg?palette=github-dark
|
||||
@@ -37,7 +44,7 @@ Available as github action. Automatically generate a new image at the end of the
|
||||
|
||||
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
|
||||
|
||||
If you are only interested in generating a svg, you can use this other faster action: `uses: Platane/snk/svg-only@v2`
|
||||
If you are only interested in generating a svg, you can use this other faster action: `uses: Platane/snk/svg-only@v2.0.0-rc.3`
|
||||
|
||||
**interactive demo**
|
||||
|
||||
|
||||
16
action.yml
16
action.yml
@@ -4,7 +4,7 @@ author: "platane"
|
||||
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://platane/snk@sha256:300fb94d3b1214e6c229990b458286a8f1c4c68a178b1b59b670c9fcac7c80d1
|
||||
image: docker://platane/snk@sha256:3169a2e4a5b5181c203ae18e7075d0d92be88a0cdeabce6c3221288ef6a17675
|
||||
|
||||
inputs:
|
||||
github_user_name:
|
||||
@@ -15,15 +15,15 @@ inputs:
|
||||
default: null
|
||||
description: |
|
||||
list of files to generate.
|
||||
Generates one file per line. Each output can be customized with query string.
|
||||
following this pattern: path/to/file.<gif or svg>?palette=<github or github-dark>&color_snake=<color>&color_dots=<color>,<color>,<color>,<color>,<color>
|
||||
one file per line. Each output can be customized with options as query string.
|
||||
|
||||
supported query string options:
|
||||
|
||||
- palette: a preset of color, one of [github, github-dark, github-light]
|
||||
- color_snake: color of the snake
|
||||
- color_dots: coma separated list of dots color. The first one is the empty cell color, the second one is the lightest shade, the third one is the second lightest shade ect ...
|
||||
supported query string options:
|
||||
|
||||
- palette: A preset of color, one of [github, github-dark, github-light]
|
||||
- color_snake: Color of the snake
|
||||
- color_dots: Coma separated list of dots color.
|
||||
The first one is 0 contribution, then it goes from the low contribution to the highest.
|
||||
Exactly 5 colors are expected.
|
||||
example:
|
||||
outputs: |
|
||||
dark.svg?palette=github-dark&color_snake=blue
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "snk",
|
||||
"description": "Generates a snake game from a github user contributions grid",
|
||||
"version": "2.0.0-rc.1",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"repository": "github:platane/snk",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,8 +8,6 @@ Contains the github action code.
|
||||
|
||||
Because the gif generation requires some native libs, we cannot use a node.js action.
|
||||
|
||||
Use a docker action instead, the image is created from the [Dockerfile](./Dockerfile).
|
||||
Use a docker action instead, the image is created from the [Dockerfile](../../Dockerfile).
|
||||
|
||||
It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs )
|
||||
|
||||
Notice that the [action.yml](../../action.yml) point to the latest version of the image. Which makes releasing sematic versioning of the action pointless. Which is probably fine for a wacky project like this one.
|
||||
|
||||
@@ -1,17 +1,124 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should parse /out.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9 1`] = `
|
||||
exports[`should parse /out.svg {"color_snake":"yellow"} 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#bfd6f6",
|
||||
"#8dbdff",
|
||||
"#64a1f4",
|
||||
"#4b91f1",
|
||||
"#3c7dd9",
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
],
|
||||
"colorEmpty": "#bfd6f6",
|
||||
"colorEmpty": "#ebedf0",
|
||||
"colorSnake": "yellow",
|
||||
"dark": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#161b22",
|
||||
"#01311f",
|
||||
"#034525",
|
||||
"#0f6d31",
|
||||
"#00c647",
|
||||
],
|
||||
"colorEmpty": "#161b22",
|
||||
"colorSnake": "purple",
|
||||
},
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?.gif.svg?color_snake=orange 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
],
|
||||
"colorEmpty": "#ebedf0",
|
||||
"colorSnake": "orange",
|
||||
"dark": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#161b22",
|
||||
"#01311f",
|
||||
"#034525",
|
||||
"#0f6d31",
|
||||
"#00c647",
|
||||
],
|
||||
"colorEmpty": "#161b22",
|
||||
"colorSnake": "purple",
|
||||
},
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg?.gif.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]} 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "yellow",
|
||||
"dark": undefined,
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "orange",
|
||||
"dark": undefined,
|
||||
"sizeCell": 16,
|
||||
@@ -20,15 +127,51 @@ Object {
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
"gifOptions": Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse /out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
"#000",
|
||||
"#111",
|
||||
"#222",
|
||||
"#333",
|
||||
"#444",
|
||||
],
|
||||
"colorEmpty": "#000",
|
||||
"colorSnake": "orange",
|
||||
"dark": Object {
|
||||
"colorDots": Array [
|
||||
"#a00",
|
||||
"#a11",
|
||||
"#a22",
|
||||
"#a33",
|
||||
"#a44",
|
||||
],
|
||||
"colorEmpty": "#a00",
|
||||
},
|
||||
"sizeCell": 16,
|
||||
"sizeDot": 12,
|
||||
"sizeDotBorderRadius": 2,
|
||||
},
|
||||
"filename": "/out.svg",
|
||||
"format": "svg",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`should parse path/to/out.gif 1`] = `
|
||||
Object {
|
||||
"animationOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
"drawOptions": Object {
|
||||
"colorDotBorder": "#1b1f230a",
|
||||
"colorDots": Array [
|
||||
@@ -58,9 +201,5 @@ Object {
|
||||
},
|
||||
"filename": "path/to/out.gif",
|
||||
"format": "gif",
|
||||
"gifOptions": Object {
|
||||
"frameDuration": 100,
|
||||
"step": 1,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,15 @@ import { parseEntry } from "../outputsOptions";
|
||||
[
|
||||
"path/to/out.gif",
|
||||
|
||||
"/out.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9",
|
||||
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444",
|
||||
|
||||
`/out.svg?{"color_snake":"yellow","color_dots":["#000","#111","#222","#333","#444"]}`,
|
||||
|
||||
`/out.svg {"color_snake":"yellow"}`,
|
||||
|
||||
"/out.svg?color_snake=orange&color_dots=#000,#111,#222,#333,#444&dark_color_dots=#a00,#a11,#a22,#a33,#a44",
|
||||
|
||||
"/out.svg?.gif.svg?color_snake=orange",
|
||||
].forEach((entry) =>
|
||||
it(`should parse ${entry}`, () => {
|
||||
expect(parseEntry(entry)).toMatchSnapshot();
|
||||
|
||||
@@ -3,23 +3,21 @@ import { userContributionToGrid } from "./userContributionToGrid";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { snake4 } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import { Options as DrawOptions } from "@snk/svg-creator";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
export const generateContributionSnake = async (
|
||||
userName: string,
|
||||
outputs: ({
|
||||
format: "svg" | "gif";
|
||||
drawOptions: DrawOptions;
|
||||
gifOptions: {
|
||||
frameDuration: number;
|
||||
step: number;
|
||||
};
|
||||
animationOptions: AnimationOptions;
|
||||
} | null)[]
|
||||
) => {
|
||||
console.log("🎣 fetching github user contribution");
|
||||
const { cells, colorScheme } = await getGithubUserContribution(userName);
|
||||
const cells = await getGithubUserContribution(userName);
|
||||
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const grid = userContributionToGrid(cells);
|
||||
const snake = snake4;
|
||||
|
||||
console.log("📡 computing best route");
|
||||
@@ -29,17 +27,23 @@ export const generateContributionSnake = async (
|
||||
return Promise.all(
|
||||
outputs.map(async (out, i) => {
|
||||
if (!out) return;
|
||||
const { format, drawOptions, gifOptions } = out;
|
||||
const { format, drawOptions, animationOptions } = out;
|
||||
switch (format) {
|
||||
case "svg": {
|
||||
console.log(`🖌 creating svg (outputs[${i}])`);
|
||||
const { createSvg } = await import("@snk/svg-creator");
|
||||
return createSvg(grid, chain, drawOptions, gifOptions);
|
||||
return createSvg(grid, cells, chain, drawOptions, animationOptions);
|
||||
}
|
||||
case "gif": {
|
||||
console.log(`📹 creating gif (outputs[${i}])`);
|
||||
const { createGif } = await import("@snk/gif-creator");
|
||||
return await createGif(grid, chain, drawOptions, gifOptions);
|
||||
return await createGif(
|
||||
grid,
|
||||
cells,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Options as DrawOptions } from "@snk/svg-creator";
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
import { palettes } from "./palettes";
|
||||
|
||||
export const parseOutputsOption = (lines: string[]) => lines.map(parseEntry);
|
||||
|
||||
export const parseEntry = (entry: string) => {
|
||||
const m = entry.trim().match(/^(.+\.(svg|gif))(\?.*)?$/);
|
||||
const m = entry.trim().match(/^(.+\.(svg|gif))(\?(.*)|\s*({.*}))?$/);
|
||||
|
||||
if (!m) return null;
|
||||
|
||||
const [_, filename, format, query] = m;
|
||||
const [, filename, format, _, q1, q2] = m;
|
||||
|
||||
const sp = new URLSearchParams(query || "");
|
||||
const query = q1 ?? q2;
|
||||
|
||||
let sp = new URLSearchParams(query || "");
|
||||
|
||||
try {
|
||||
const o = JSON.parse(query);
|
||||
|
||||
if (Array.isArray(o.color_dots)) o.color_dots = o.color_dots.join(",");
|
||||
if (Array.isArray(o.dark_color_dots))
|
||||
o.dark_color_dots = o.dark_color_dots.join(",");
|
||||
|
||||
sp = new URLSearchParams(o);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SyntaxError)) throw err;
|
||||
}
|
||||
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
@@ -17,7 +33,7 @@ export const parseEntry = (entry: string) => {
|
||||
sizeDot: 12,
|
||||
...palettes["default"],
|
||||
};
|
||||
const gifOptions = { step: 1, frameDuration: 100 };
|
||||
const animationOptions: AnimationOptions = { step: 1, frameDuration: 100 };
|
||||
|
||||
{
|
||||
const palette = palettes[sp.get("palette")!];
|
||||
@@ -50,5 +66,10 @@ export const parseEntry = (entry: string) => {
|
||||
if (sp.has("dark_color_snake") && drawOptions.dark)
|
||||
drawOptions.dark.colorSnake = sp.get("color_snake")!;
|
||||
|
||||
return { filename, format: format as "svg" | "gif", drawOptions, gifOptions };
|
||||
return {
|
||||
filename,
|
||||
format: format as "svg" | "gif",
|
||||
drawOptions,
|
||||
animationOptions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Options as DrawOptions } from "@snk/svg-creator";
|
||||
import { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
|
||||
export const palettes: Record<
|
||||
string,
|
||||
|
||||
@@ -2,17 +2,13 @@ import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid";
|
||||
import type { Cell } from "@snk/github-user-contribution";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
|
||||
export const userContributionToGrid = (
|
||||
cells: Cell[],
|
||||
colorScheme: string[]
|
||||
) => {
|
||||
export const userContributionToGrid = (cells: Cell[]) => {
|
||||
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
|
||||
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
|
||||
|
||||
const grid = createEmptyGrid(width, height);
|
||||
for (const c of cells) {
|
||||
const k = colorScheme.indexOf(c.color);
|
||||
if (k > 0) setColor(grid, c.x, c.y, k as Color);
|
||||
if (c.level > 0) setColor(grid, c.x, c.y, c.level as Color);
|
||||
else setColorEmpty(grid, c.x, c.y);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Color, Grid } from "@snk/types/grid";
|
||||
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import type { Options as DrawOptions } from "@snk/svg-creator";
|
||||
import type { DrawOptions as DrawOptions } from "@snk/svg-creator";
|
||||
|
||||
export const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
@@ -68,7 +68,7 @@ export const createCanvas = ({
|
||||
|
||||
const draw = (grid: Grid, snake: Snake, stack: Color[]) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
drawWorld(ctx, grid, null, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const drawLerp = (
|
||||
@@ -79,7 +79,7 @@ export const createCanvas = ({
|
||||
k: number
|
||||
) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
drawLerpWorld(ctx, grid, null, snake0, snake1, stack, k, drawOptions);
|
||||
};
|
||||
|
||||
const highlightCell = (x: number, y: number, color = "orange") => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { step } from "@snk/solver/step";
|
||||
import { isStableAndBound, stepSpring } from "./springUtils";
|
||||
import type { Res } from "@snk/github-user-contribution";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import {
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
@@ -12,6 +13,7 @@ import { userContributionToGrid } from "@snk/action/userContributionToGrid";
|
||||
import { createSvg } from "@snk/svg-creator";
|
||||
import { createRpcClient } from "./worker-utils";
|
||||
import type { API as WorkerAPI } from "./demo.interactive.worker";
|
||||
import { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
const createForm = ({
|
||||
onSubmit,
|
||||
@@ -116,10 +118,12 @@ const createGithubProfile = () => {
|
||||
const createViewer = ({
|
||||
grid0,
|
||||
chain,
|
||||
cells,
|
||||
drawOptions,
|
||||
}: {
|
||||
grid0: Grid;
|
||||
chain: Snake[];
|
||||
cells: Point[];
|
||||
drawOptions: DrawOptions;
|
||||
}) => {
|
||||
//
|
||||
@@ -159,7 +163,7 @@ const createViewer = ({
|
||||
const k = spring.x % 1;
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
drawLerpWorld(ctx, grid, cells, snake0, snake1, stack, k, drawOptions);
|
||||
|
||||
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
@@ -189,9 +193,9 @@ const createViewer = ({
|
||||
//
|
||||
// svg
|
||||
const svgLink = document.createElement("a");
|
||||
const svgString = createSvg(grid0, chain, drawOptions, {
|
||||
const svgString = createSvg(grid0, cells, chain, drawOptions, {
|
||||
frameDuration: 100,
|
||||
});
|
||||
} as AnimationOptions);
|
||||
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
|
||||
svgLink.href = svgImageUri;
|
||||
svgLink.innerText = "github-user-contribution.svg";
|
||||
@@ -227,26 +231,25 @@ const onSubmit = async (userName: string) => {
|
||||
const res = await fetch(
|
||||
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName
|
||||
);
|
||||
const { cells, colorScheme } = (await res.json()) as Res;
|
||||
const cells = (await res.json()) as Res;
|
||||
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorDotBorder: "#1b1f230a",
|
||||
colorDots: colorScheme as any,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorDots: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
cells,
|
||||
};
|
||||
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const grid = userContributionToGrid(cells);
|
||||
|
||||
const chain = await getChain(grid);
|
||||
|
||||
dispose();
|
||||
|
||||
createViewer({ grid0: grid, chain, drawOptions });
|
||||
createViewer({ grid0: grid, chain, cells, drawOptions });
|
||||
};
|
||||
|
||||
const worker = new Worker(
|
||||
|
||||
@@ -4,12 +4,15 @@ import { createSvg } from "@snk/svg-creator";
|
||||
import { grid, snake } from "./sample";
|
||||
import { drawOptions } from "./canvas";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import type { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
const chain = getBestRoute(grid, snake);
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
(async () => {
|
||||
const svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 });
|
||||
const svg = await createSvg(grid, null, chain, drawOptions, {
|
||||
frameDuration: 200,
|
||||
} as AnimationOptions);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svg;
|
||||
|
||||
@@ -10,17 +10,17 @@ type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeDotBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawGrid = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
o: Options
|
||||
) => {
|
||||
for (let x = grid.width; x--; )
|
||||
for (let y = grid.height; y--; ) {
|
||||
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) {
|
||||
if (!cells || cells.some((c) => c.x === x && c.y === y)) {
|
||||
const c = getColor(grid, x, y);
|
||||
// @ts-ignore
|
||||
const color = !c ? o.colorEmpty : o.colorDots[c];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { drawGrid } from "./drawGrid";
|
||||
import { drawSnake, drawSnakeLerp } from "./drawSnake";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
export type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
@@ -12,7 +12,6 @@ export type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeDotBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawStack = (
|
||||
@@ -37,6 +36,7 @@ export const drawStack = (
|
||||
export const drawWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
snake: Snake,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
@@ -44,7 +44,7 @@ export const drawWorld = (
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawGrid(ctx, grid, cells, o);
|
||||
drawSnake(ctx, snake, o);
|
||||
|
||||
ctx.restore();
|
||||
@@ -68,6 +68,7 @@ export const drawWorld = (
|
||||
export const drawLerpWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
@@ -77,7 +78,7 @@ export const drawLerpWorld = (
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawGrid(ctx, grid, cells, o);
|
||||
drawSnakeLerp(ctx, snake0, snake1, k, o);
|
||||
|
||||
ctx.translate(0, (grid.height + 2) * o.sizeCell);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from "fs";
|
||||
import { performance } from "perf_hooks";
|
||||
import { createSnakeFromCells } from "@snk/types/snake";
|
||||
import { realistic as grid } from "@snk/types/__fixtures__/grid";
|
||||
import { createGif } from "..";
|
||||
import { AnimationOptions, createGif } from "..";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import type { Options as DrawOptions } from "@snk/draw/drawWorld";
|
||||
@@ -35,7 +35,7 @@ const drawOptions: DrawOptions = {
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
(async () => {
|
||||
for (
|
||||
@@ -50,7 +50,13 @@ const gifOptions = { frameDuration: 100, step: 1 };
|
||||
const chainL = chain.slice(0, length);
|
||||
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
|
||||
const s = performance.now();
|
||||
buffer = await createGif(grid, chainL, drawOptions, gifOptions);
|
||||
buffer = await createGif(
|
||||
grid,
|
||||
null,
|
||||
chainL,
|
||||
drawOptions,
|
||||
animationOptions
|
||||
);
|
||||
stats.push(performance.now() - s);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createGif } from "..";
|
||||
import { AnimationOptions, createGif } from "..";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { createSnakeFromCells, nextSnake } from "@snk/types/snake";
|
||||
@@ -20,7 +20,7 @@ const drawOptions: DrawOptions = {
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 200, step: 1 };
|
||||
const animationOptions: AnimationOptions = { frameDuration: 200, step: 1 };
|
||||
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
|
||||
@@ -40,7 +40,13 @@ for (const key of [
|
||||
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
const gif = await createGif(
|
||||
grid,
|
||||
null,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions
|
||||
);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
@@ -64,7 +70,7 @@ it(`should generate swipper`, async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
const gif = await createGif(grid, null, chain, drawOptions, animationOptions);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { createCanvas } from "canvas";
|
||||
import { Grid, copyGrid, Color } from "@snk/types/grid";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
Options,
|
||||
Options as DrawOptions,
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import { step } from "@snk/solver/step";
|
||||
import tmp from "tmp";
|
||||
import gifsicle from "gifsicle";
|
||||
@@ -29,11 +30,14 @@ const withTmpDir = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
export type AnimationOptions = { frameDuration: number; step: number };
|
||||
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
cells: Point[] | null,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number; step: number }
|
||||
drawOptions: DrawOptions,
|
||||
animationOptions: AnimationOptions
|
||||
) =>
|
||||
withTmpDir(async (dir) => {
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
@@ -46,7 +50,7 @@ export const createGif = async (
|
||||
|
||||
const encoder = new GIFEncoder(width, height, "neuquant", true);
|
||||
encoder.setRepeat(0);
|
||||
encoder.setDelay(gifOptions.frameDuration);
|
||||
encoder.setDelay(animationOptions.frameDuration);
|
||||
encoder.start();
|
||||
|
||||
for (let i = 0; i < chain.length; i += 1) {
|
||||
@@ -54,17 +58,18 @@ export const createGif = async (
|
||||
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
|
||||
step(grid, stack, snake0);
|
||||
|
||||
for (let k = 0; k < gifOptions.step; k++) {
|
||||
for (let k = 0; k < animationOptions.step; k++) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawLerpWorld(
|
||||
ctx,
|
||||
grid,
|
||||
cells,
|
||||
snake0,
|
||||
snake1,
|
||||
stack,
|
||||
k / gifOptions.step,
|
||||
k / animationOptions.step,
|
||||
drawOptions
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,41 +7,22 @@ describe("getGithubUserContribution", () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("should get colorScheme", async () => {
|
||||
const { colorScheme } = await promise;
|
||||
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should get around 365 cells", async () => {
|
||||
const { cells } = await promise;
|
||||
const cells = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThanOrEqual(365);
|
||||
expect(cells.length).toBeLessThanOrEqual(365 + 7);
|
||||
});
|
||||
|
||||
it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => {
|
||||
const { cells, colorScheme } = await promise;
|
||||
const cells = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
|
||||
const undefinedDays = Array.from({ length: Math.floor(365 / 7) })
|
||||
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
|
||||
.flat()
|
||||
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y));
|
||||
.filter(({ x, y }) => cells.some((c: any) => c.x === x && c.y === y));
|
||||
|
||||
expect(undefinedDays).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fetch from "node-fetch";
|
||||
import * as cheerio from "cheerio";
|
||||
import { formatParams, Options } from "./formatParams";
|
||||
|
||||
/**
|
||||
@@ -39,91 +38,40 @@ export const getGithubUserContribution = async (
|
||||
return parseUserPage(resText);
|
||||
};
|
||||
|
||||
const defaultColorScheme = [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
|
||||
const parseUserPage = (content: string) => {
|
||||
const $ = cheerio.load(content);
|
||||
// take roughly the svg block
|
||||
const block = content
|
||||
.split(`class="js-calendar-graph-svg"`)[1]
|
||||
.split("</svg>")[0];
|
||||
|
||||
//
|
||||
// "parse" colorScheme
|
||||
const colorScheme = [...defaultColorScheme];
|
||||
let x = 0;
|
||||
let lastYAttribute = 0;
|
||||
|
||||
//
|
||||
// parse cells
|
||||
const rawCells = $(".js-calendar-graph rect[data-count]")
|
||||
.toArray()
|
||||
.map((x) => {
|
||||
const level = +x.attribs["data-level"];
|
||||
const count = +x.attribs["data-count"];
|
||||
const date = x.attribs["data-date"];
|
||||
const rects = Array.from(block.matchAll(/<rect[^>]*>/g)).map(([m]) => {
|
||||
const date = m.match(/data-date="([^"]+)"/)![1];
|
||||
const count = +m.match(/data-count="([^"]+)"/)![1];
|
||||
const level = +m.match(/data-level="([^"]+)"/)![1];
|
||||
const yAttribute = +m.match(/y="([^"]+)"/)![1];
|
||||
|
||||
const color = colorScheme[level];
|
||||
if (lastYAttribute > yAttribute) x++;
|
||||
|
||||
if (!color) throw new Error("could not determine the color of the cell");
|
||||
lastYAttribute = yAttribute;
|
||||
|
||||
return {
|
||||
svgPosition: getSvgPosition(x),
|
||||
color,
|
||||
count,
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
const xMap: Record<number, true> = {};
|
||||
const yMap: Record<number, true> = {};
|
||||
rawCells.forEach(({ svgPosition: { x, y } }) => {
|
||||
xMap[x] = true;
|
||||
yMap[y] = true;
|
||||
return { date, count, level, x, yAttribute };
|
||||
});
|
||||
|
||||
const xRange = Object.keys(xMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
const yRange = Object.keys(yMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
const yAttributes = Array.from(
|
||||
new Set(rects.map((c) => c.yAttribute)).keys()
|
||||
).sort();
|
||||
|
||||
const cells = rawCells.map(({ svgPosition, ...c }) => ({
|
||||
const cells = rects.map(({ yAttribute, ...c }) => ({
|
||||
y: yAttributes.indexOf(yAttribute),
|
||||
...c,
|
||||
x: xRange.indexOf(svgPosition.x),
|
||||
y: yRange.indexOf(svgPosition.y),
|
||||
}));
|
||||
|
||||
return { cells, colorScheme };
|
||||
};
|
||||
|
||||
// returns the position of the svg elements, accounting for it's transform and it's parent transform
|
||||
// ( only accounts for translate transform )
|
||||
const getSvgPosition = (
|
||||
e: cheerio.Element | null
|
||||
): { x: number; y: number } => {
|
||||
if (!e || e.tagName === "svg") return { x: 0, y: 0 };
|
||||
|
||||
const p = getSvgPosition(e.parent as cheerio.Element);
|
||||
|
||||
if (e.attribs.x) p.x += +e.attribs.x;
|
||||
if (e.attribs.y) p.y += +e.attribs.y;
|
||||
|
||||
if (e.attribs.transform) {
|
||||
const m = e.attribs.transform.match(
|
||||
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/
|
||||
);
|
||||
|
||||
if (m) {
|
||||
p.x += +m[1];
|
||||
p.y += +m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
return cells;
|
||||
};
|
||||
|
||||
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;
|
||||
|
||||
export type Cell = Res["cells"][number];
|
||||
export type Cell = Res[number];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createSvg, Options } from "..";
|
||||
import { createSvg, DrawOptions as DrawOptions } from "..";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
const drawOptions: Options = {
|
||||
const drawOptions: DrawOptions = {
|
||||
sizeDotBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
@@ -19,7 +20,7 @@ const drawOptions: Options = {
|
||||
},
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
const animationOptions: AnimationOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
|
||||
@@ -31,7 +32,13 @@ for (const [key, grid] of Object.entries(grids))
|
||||
it(`should generate ${key} svg`, async () => {
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
|
||||
const svg = await createSvg(grid, chain, drawOptions, gifOptions);
|
||||
const svg = await createSvg(
|
||||
grid,
|
||||
null,
|
||||
chain,
|
||||
drawOptions,
|
||||
animationOptions
|
||||
);
|
||||
|
||||
expect(svg).toBeDefined();
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ import { createGrid } from "./grid";
|
||||
import { createStack } from "./stack";
|
||||
import { h } from "./utils";
|
||||
import * as csso from "csso";
|
||||
import { AnimationOptions } from "@snk/gif-creator";
|
||||
|
||||
export type Options = {
|
||||
export type DrawOptions = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorDotBorder: string;
|
||||
@@ -23,7 +24,6 @@ export type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeDotBorderRadius: number;
|
||||
cells?: Point[];
|
||||
dark?: {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
@@ -40,12 +40,12 @@ const getCellsFromGrid = ({ width, height }: Grid) =>
|
||||
const createLivingCells = (
|
||||
grid0: Grid,
|
||||
chain: Snake[],
|
||||
drawOptions: Options
|
||||
cells: Point[] | null
|
||||
) => {
|
||||
const cells: (Point & {
|
||||
const livingCells: (Point & {
|
||||
t: number | null;
|
||||
color: Color | Empty;
|
||||
})[] = (drawOptions.cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
|
||||
})[] = (cells ?? getCellsFromGrid(grid0)).map(({ x, y }) => ({
|
||||
x,
|
||||
y,
|
||||
t: null,
|
||||
@@ -60,31 +60,32 @@ const createLivingCells = (
|
||||
|
||||
if (isInside(grid, x, y) && !isEmpty(getColor(grid, x, y))) {
|
||||
setColorEmpty(grid, x, y);
|
||||
const cell = cells.find((c) => c.x === x && c.y === y)!;
|
||||
const cell = livingCells.find((c) => c.x === x && c.y === y)!;
|
||||
cell.t = i / chain.length;
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
return livingCells;
|
||||
};
|
||||
|
||||
export const createSvg = (
|
||||
grid: Grid,
|
||||
cells: Point[] | null,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number }
|
||||
drawOptions: DrawOptions,
|
||||
animationOptions: Pick<AnimationOptions, "frameDuration">
|
||||
) => {
|
||||
const width = (grid.width + 2) * drawOptions.sizeCell;
|
||||
const height = (grid.height + 5) * drawOptions.sizeCell;
|
||||
|
||||
const duration = gifOptions.frameDuration * chain.length;
|
||||
const duration = animationOptions.frameDuration * chain.length;
|
||||
|
||||
const cells = createLivingCells(grid, chain, drawOptions);
|
||||
const livingCells = createLivingCells(grid, chain, cells);
|
||||
|
||||
const elements = [
|
||||
createGrid(cells, drawOptions, duration),
|
||||
createGrid(livingCells, drawOptions, duration),
|
||||
createStack(
|
||||
cells,
|
||||
livingCells,
|
||||
drawOptions,
|
||||
grid.width * drawOptions.sizeCell,
|
||||
(grid.height + 2) * drawOptions.sizeCell,
|
||||
@@ -134,7 +135,7 @@ export const createSvg = (
|
||||
const optimizeCss = (css: string) => csso.minify(css).css;
|
||||
const optimizeSvg = (svg: string) => svg;
|
||||
|
||||
const generateColorVar = (drawOptions: Options) =>
|
||||
const generateColorVar = (drawOptions: DrawOptions) =>
|
||||
`
|
||||
:root {
|
||||
--cb: ${drawOptions.colorDotBorder};
|
||||
|
||||
@@ -10,11 +10,23 @@ inputs:
|
||||
github_user_name:
|
||||
description: "github user name"
|
||||
required: true
|
||||
svg_out_path:
|
||||
description: "path of the generated svg file. If left empty, the svg file will not be generated."
|
||||
outputs:
|
||||
required: false
|
||||
default: null
|
||||
description: |
|
||||
list of files to generate.
|
||||
one file per line. Each output can be customized with options as query string.
|
||||
|
||||
outputs:
|
||||
svg_out_path:
|
||||
description: "path of the generated svg"
|
||||
supported query string options:
|
||||
|
||||
- palette: A preset of color, one of [github, github-dark, github-light]
|
||||
- color_snake: Color of the snake
|
||||
- color_dots: Coma separated list of dots color.
|
||||
The first one is 0 contribution, then it goes from the low contribution to the highest.
|
||||
Exactly 5 colors are expected.
|
||||
|
||||
example:
|
||||
outputs: |
|
||||
dark.svg?palette=github-dark&color_snake=blue
|
||||
light.svg?color_snake=#7845ab
|
||||
ocean.svg?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
|
||||
|
||||
20236
svg-only/dist/index.js
vendored
20236
svg-only/dist/index.js
vendored
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user