🚀 demo + spring
This commit is contained in:
@@ -37,7 +37,7 @@ export const generateContributionSnake = async (userName: string) => {
|
|||||||
colorSnake: "purple",
|
colorSnake: "purple",
|
||||||
};
|
};
|
||||||
|
|
||||||
const gifOptions = { delay: 3 };
|
const gifOptions = { frameDuration: 10, step: 1 };
|
||||||
|
|
||||||
const chain = getBestRoute(grid0, snake0)!;
|
const chain = getBestRoute(grid0, snake0)!;
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ const create = (length: number) =>
|
|||||||
|
|
||||||
export const snake1 = create(1);
|
export const snake1 = create(1);
|
||||||
export const snake3 = create(3);
|
export const snake3 = create(3);
|
||||||
export const snake7 = create(7);
|
export const snake5 = create(5);
|
||||||
|
export const snake9 = create(9);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Color, Grid } from "@snk/compute/grid";
|
import { Color, Grid } from "@snk/compute/grid";
|
||||||
import { drawWorld } from "@snk/draw/drawWorld";
|
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
|
||||||
import { Snake } from "@snk/compute/snake";
|
import { Snake } from "@snk/compute/snake";
|
||||||
|
|
||||||
export const drawOptions = {
|
export const drawOptions = {
|
||||||
@@ -44,5 +44,16 @@ export const createCanvas = ({
|
|||||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { draw, canvas, ctx };
|
const drawLerp = (
|
||||||
|
grid: Grid,
|
||||||
|
snake0: Snake,
|
||||||
|
snake1: Snake,
|
||||||
|
stack: Color[],
|
||||||
|
k: number
|
||||||
|
) => {
|
||||||
|
ctx.clearRect(0, 0, 9999, 9999);
|
||||||
|
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { draw, drawLerp, canvas, ctx };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,30 +3,43 @@ import { getBestRoute } from "../compute/getBestRoute";
|
|||||||
import { Color, copyGrid } from "../compute/grid";
|
import { Color, copyGrid } from "../compute/grid";
|
||||||
import { grid, snake } from "./sample";
|
import { grid, snake } from "./sample";
|
||||||
import { step } from "@snk/compute/step";
|
import { step } from "@snk/compute/step";
|
||||||
|
import { isStableAndBound, stepSpring } from "./springUtils";
|
||||||
|
|
||||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||||
|
|
||||||
//
|
//
|
||||||
// draw
|
// draw
|
||||||
|
|
||||||
let k = 0;
|
const spring = { x: 0, v: 0, target: 0 };
|
||||||
|
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
|
||||||
|
let animationFrame: number;
|
||||||
|
|
||||||
const { canvas, draw } = createCanvas(grid);
|
const { canvas, drawLerp } = createCanvas(grid);
|
||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
|
|
||||||
const onChange = () => {
|
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
|
||||||
|
stepSpring(spring, springParams, spring.target);
|
||||||
|
const stable = isStableAndBound(spring, spring.target);
|
||||||
|
|
||||||
const grid0 = copyGrid(grid);
|
const grid0 = copyGrid(grid);
|
||||||
const stack0: Color[] = [];
|
const stack0: Color[] = [];
|
||||||
let snake0 = snake;
|
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
|
||||||
chain.slice(0, k).forEach((s) => {
|
step(grid0, stack0, chain[i]);
|
||||||
snake0 = s;
|
|
||||||
step(grid0, stack0, snake0);
|
|
||||||
});
|
|
||||||
|
|
||||||
draw(grid0, snake0, stack0);
|
const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)];
|
||||||
|
const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)];
|
||||||
|
const k = spring.x % 1;
|
||||||
|
|
||||||
|
drawLerp(grid0, snake0, snake1, stack0, k);
|
||||||
|
|
||||||
|
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange();
|
loop();
|
||||||
|
|
||||||
const input = document.createElement("input") as any;
|
const input = document.createElement("input") as any;
|
||||||
input.type = "range";
|
input.type = "range";
|
||||||
@@ -36,8 +49,9 @@ input.min = 0;
|
|||||||
input.max = chain.length;
|
input.max = chain.length;
|
||||||
input.style.width = "90%";
|
input.style.width = "90%";
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
k = +input.value;
|
spring.target = +input.value;
|
||||||
onChange();
|
cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = requestAnimationFrame(loop);
|
||||||
});
|
});
|
||||||
document.body.append(input);
|
document.body.append(input);
|
||||||
document.body.addEventListener("click", () => input.focus());
|
document.body.addEventListener("click", () => input.focus());
|
||||||
|
|||||||
63
packages/demo/springUtils.ts
Normal file
63
packages/demo/springUtils.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const epsilon = 0.01;
|
||||||
|
|
||||||
|
export const clamp = (a: number, b: number) => (x: number) =>
|
||||||
|
Math.max(a, Math.min(b, x));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step the spring, mutate the state to reflect the state at t+dt
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const stepSpringOne = (
|
||||||
|
s: { x: number; v: number },
|
||||||
|
{
|
||||||
|
tension,
|
||||||
|
friction,
|
||||||
|
maxVelocity = Infinity,
|
||||||
|
}: { tension: number; friction: number; maxVelocity?: number },
|
||||||
|
target: number,
|
||||||
|
dt = 1 / 60
|
||||||
|
) => {
|
||||||
|
const a = -tension * (s.x - target) - friction * s.v;
|
||||||
|
|
||||||
|
s.v += a * dt;
|
||||||
|
s.v = clamp(-maxVelocity / dt, maxVelocity / dt)(s.v);
|
||||||
|
s.x += s.v * dt;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* return true if the spring is to be considered in a stable state
|
||||||
|
* ( close enough to the target and with a small enough velocity )
|
||||||
|
*/
|
||||||
|
export const isStable = (
|
||||||
|
s: { x: number; v: number },
|
||||||
|
target: number,
|
||||||
|
dt = 1 / 60
|
||||||
|
) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon;
|
||||||
|
|
||||||
|
export const isStableAndBound = (
|
||||||
|
s: { x: number; v: number },
|
||||||
|
target: number,
|
||||||
|
dt?: number
|
||||||
|
) => {
|
||||||
|
const stable = isStable(s, target, dt);
|
||||||
|
if (stable) {
|
||||||
|
s.x = target;
|
||||||
|
s.v = 0;
|
||||||
|
}
|
||||||
|
return stable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stepSpring = (
|
||||||
|
s: { x: number; v: number },
|
||||||
|
params: { tension: number; friction: number; maxVelocity?: number },
|
||||||
|
target: number,
|
||||||
|
dt = 1 / 60
|
||||||
|
) => {
|
||||||
|
const interval = 1 / 60;
|
||||||
|
|
||||||
|
while (dt > 0) {
|
||||||
|
stepSpringOne(s, params, target, Math.min(interval, dt));
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
dt -= interval;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -30,3 +30,39 @@ export const drawSnake = (
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
|
||||||
|
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||||
|
|
||||||
|
export const drawSnakeLerp = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
snake0: Snake,
|
||||||
|
snake1: Snake,
|
||||||
|
k: number,
|
||||||
|
o: Options
|
||||||
|
) => {
|
||||||
|
const m = 0.8;
|
||||||
|
const n = snake0.length / 2;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const u = (i + 1) * 0.6;
|
||||||
|
|
||||||
|
const a = (1 - m) * (i / Math.max(n - 1, 1));
|
||||||
|
const ki = clamp((k - a) / m, 0, 1);
|
||||||
|
|
||||||
|
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
|
||||||
|
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = o.colorSnake;
|
||||||
|
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
|
||||||
|
ctx.beginPath();
|
||||||
|
pathRoundedRect(
|
||||||
|
ctx,
|
||||||
|
o.sizeCell - u * 2,
|
||||||
|
o.sizeCell - u * 2,
|
||||||
|
(o.sizeCell - u * 2) * 0.25
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Grid, Color } from "@snk/compute/grid";
|
import { Grid, Color } from "@snk/compute/grid";
|
||||||
import { drawGrid } from "./drawGrid";
|
import { drawGrid } from "./drawGrid";
|
||||||
import { Snake } from "@snk/compute/snake";
|
import { Snake } from "@snk/compute/snake";
|
||||||
import { drawSnake } from "./drawSnake";
|
import { drawSnake, drawSnakeLerp } from "./drawSnake";
|
||||||
|
|
||||||
type Options = {
|
export type Options = {
|
||||||
colorDots: Record<Color, string>;
|
colorDots: Record<Color, string>;
|
||||||
colorEmpty: string;
|
colorEmpty: string;
|
||||||
colorBorder: string;
|
colorBorder: string;
|
||||||
@@ -62,3 +62,26 @@ export const drawWorld = (
|
|||||||
// drawCircleStack(ctx, stack, o);
|
// drawCircleStack(ctx, stack, o);
|
||||||
// ctx.restore();
|
// ctx.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const drawLerpWorld = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
grid: Grid,
|
||||||
|
snake0: Snake,
|
||||||
|
snake1: Snake,
|
||||||
|
stack: Color[],
|
||||||
|
k: number,
|
||||||
|
o: Options
|
||||||
|
) => {
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||||
|
drawGrid(ctx, grid, o);
|
||||||
|
drawSnakeLerp(ctx, snake0, snake1, k, o);
|
||||||
|
|
||||||
|
ctx.translate(0, (grid.height + 2) * o.sizeCell);
|
||||||
|
|
||||||
|
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
|
||||||
|
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getBestRoute } from "@snk/compute/getBestRoute";
|
|||||||
import * as grids from "@snk/compute/__fixtures__/grid";
|
import * as grids from "@snk/compute/__fixtures__/grid";
|
||||||
import { snake3 as snake } from "@snk/compute/__fixtures__/snake";
|
import { snake3 as snake } from "@snk/compute/__fixtures__/snake";
|
||||||
|
|
||||||
|
jest.setTimeout(20 * 1000);
|
||||||
|
|
||||||
const drawOptions = {
|
const drawOptions = {
|
||||||
sizeBorderRadius: 2,
|
sizeBorderRadius: 2,
|
||||||
sizeCell: 16,
|
sizeCell: 16,
|
||||||
@@ -15,7 +17,7 @@ const drawOptions = {
|
|||||||
colorSnake: "purple",
|
colorSnake: "purple",
|
||||||
};
|
};
|
||||||
|
|
||||||
const gifOptions = { delay: 18 };
|
const gifOptions = { frameDuration: 20, step: 1 };
|
||||||
|
|
||||||
const dir = path.resolve(__dirname, "__snapshots__");
|
const dir = path.resolve(__dirname, "__snapshots__");
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as path from "path";
|
|||||||
import { createCanvas } from "canvas";
|
import { createCanvas } from "canvas";
|
||||||
import { Grid, copyGrid, Color } from "@snk/compute/grid";
|
import { Grid, copyGrid, Color } from "@snk/compute/grid";
|
||||||
import { Snake } from "@snk/compute/snake";
|
import { Snake } from "@snk/compute/snake";
|
||||||
import { drawWorld } from "@snk/draw/drawWorld";
|
import { Options, drawLerpWorld } from "@snk/draw/drawWorld";
|
||||||
import { step } from "@snk/compute/step";
|
import { step } from "@snk/compute/step";
|
||||||
import * as tmp from "tmp";
|
import * as tmp from "tmp";
|
||||||
import * as execa from "execa";
|
import * as execa from "execa";
|
||||||
@@ -11,15 +11,11 @@ import * as execa from "execa";
|
|||||||
export const createGif = async (
|
export const createGif = async (
|
||||||
grid0: Grid,
|
grid0: Grid,
|
||||||
chain: Snake[],
|
chain: Snake[],
|
||||||
drawOptions: Parameters<typeof drawWorld>[4],
|
drawOptions: Options,
|
||||||
gifOptions: { delay: number }
|
gifOptions: { frameDuration: number; step: number }
|
||||||
) => {
|
) => {
|
||||||
let snake = chain[0];
|
const width = drawOptions.sizeCell * (grid0.width + 2);
|
||||||
const grid = copyGrid(grid0);
|
const height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||||
const stack: Color[] = [];
|
|
||||||
|
|
||||||
const width = drawOptions.sizeCell * (grid.width + 2);
|
|
||||||
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
|
|
||||||
|
|
||||||
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
|
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
|
||||||
unsafeCleanup: true,
|
unsafeCleanup: true,
|
||||||
@@ -28,28 +24,41 @@ export const createGif = async (
|
|||||||
const canvas = createCanvas(width, height);
|
const canvas = createCanvas(width, height);
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
const writeImage = (i: number) => {
|
|
||||||
ctx.clearRect(0, 0, 99999, 99999);
|
|
||||||
ctx.fillStyle = "#fff";
|
|
||||||
ctx.fillRect(0, 0, 99999, 99999);
|
|
||||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
|
||||||
|
|
||||||
const buffer = canvas.toBuffer("image/png", {
|
|
||||||
compressionLevel: 0,
|
|
||||||
filters: canvas.PNG_FILTER_NONE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
|
|
||||||
|
|
||||||
fs.writeFileSync(fileName, buffer);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < chain.length; i++) {
|
const grid = copyGrid(grid0);
|
||||||
snake = chain[i];
|
const stack: Color[] = [];
|
||||||
|
|
||||||
step(grid, stack, snake);
|
for (let i = 0; i < chain.length; i += 1) {
|
||||||
writeImage(i);
|
const snake0 = chain[i];
|
||||||
|
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
|
||||||
|
step(grid, stack, snake0);
|
||||||
|
|
||||||
|
for (let k = 0; k < gifOptions.step; k++) {
|
||||||
|
ctx.clearRect(0, 0, 99999, 99999);
|
||||||
|
ctx.fillStyle = "#fff";
|
||||||
|
ctx.fillRect(0, 0, 99999, 99999);
|
||||||
|
drawLerpWorld(
|
||||||
|
ctx,
|
||||||
|
grid,
|
||||||
|
snake0,
|
||||||
|
snake1,
|
||||||
|
stack,
|
||||||
|
k / gifOptions.step,
|
||||||
|
drawOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = canvas.toBuffer("image/png", {
|
||||||
|
compressionLevel: 0,
|
||||||
|
filters: canvas.PNG_FILTER_NONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = path.join(
|
||||||
|
dir,
|
||||||
|
`${(i * gifOptions.step + k).toString().padStart(4, "0")}.png`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(fileName, buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const outFileName = path.join(dir, "out.gif");
|
const outFileName = path.join(dir, "out.gif");
|
||||||
@@ -60,7 +69,7 @@ export const createGif = async (
|
|||||||
[
|
[
|
||||||
"convert",
|
"convert",
|
||||||
["-loop", "0"],
|
["-loop", "0"],
|
||||||
["-delay", gifOptions.delay.toString()],
|
["-delay", gifOptions.frameDuration.toString()],
|
||||||
["-dispose", "2"],
|
["-dispose", "2"],
|
||||||
// ["-layers", "OptimizeFrame"],
|
// ["-layers", "OptimizeFrame"],
|
||||||
["-compress", "LZW"],
|
["-compress", "LZW"],
|
||||||
|
|||||||
Reference in New Issue
Block a user