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