🚀 demo + spring

This commit is contained in:
platane
2020-10-08 19:29:04 +02:00
committed by Platane
parent 16a47349be
commit bb0750e8ba
9 changed files with 208 additions and 49 deletions

View File

@@ -37,7 +37,7 @@ export const generateContributionSnake = async (userName: string) => {
colorSnake: "purple",
};
const gifOptions = { delay: 3 };
const gifOptions = { frameDuration: 10, step: 1 };
const chain = getBestRoute(grid0, snake0)!;

View File

@@ -6,4 +6,5 @@ const create = (length: number) =>
export const snake1 = create(1);
export const snake3 = create(3);
export const snake7 = create(7);
export const snake5 = create(5);
export const snake9 = create(9);

View File

@@ -1,5 +1,5 @@
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";
export const drawOptions = {
@@ -44,5 +44,16 @@ export const createCanvas = ({
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 };
};

View File

@@ -3,30 +3,43 @@ import { getBestRoute } from "../compute/getBestRoute";
import { Color, copyGrid } from "../compute/grid";
import { grid, snake } from "./sample";
import { step } from "@snk/compute/step";
import { isStableAndBound, stepSpring } from "./springUtils";
const chain = [snake, ...getBestRoute(grid, snake)!];
//
// draw
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);
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 stack0: Color[] = [];
let snake0 = snake;
chain.slice(0, k).forEach((s) => {
snake0 = s;
step(grid0, stack0, snake0);
});
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
step(grid0, stack0, chain[i]);
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;
input.type = "range";
@@ -36,8 +49,9 @@ input.min = 0;
input.max = chain.length;
input.style.width = "90%";
input.addEventListener("input", () => {
k = +input.value;
onChange();
spring.target = +input.value;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(loop);
});
document.body.append(input);
document.body.addEventListener("click", () => input.focus());

View File

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

View File

@@ -30,3 +30,39 @@ export const drawSnake = (
ctx.restore();
}
};
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
export const drawSnakeLerp = (
ctx: CanvasRenderingContext2D,
snake0: Snake,
snake1: Snake,
k: number,
o: Options
) => {
const m = 0.8;
const n = snake0.length / 2;
for (let i = 0; i < n; i++) {
const u = (i + 1) * 0.6;
const a = (1 - m) * (i / Math.max(n - 1, 1));
const ki = clamp((k - a) / m, 0, 1);
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
ctx.save();
ctx.fillStyle = o.colorSnake;
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
ctx.beginPath();
pathRoundedRect(
ctx,
o.sizeCell - u * 2,
o.sizeCell - u * 2,
(o.sizeCell - u * 2) * 0.25
);
ctx.fill();
ctx.restore();
}
};

View File

@@ -1,9 +1,9 @@
import { Grid, Color } from "@snk/compute/grid";
import { drawGrid } from "./drawGrid";
import { Snake } from "@snk/compute/snake";
import { drawSnake } from "./drawSnake";
import { drawSnake, drawSnakeLerp } from "./drawSnake";
type Options = {
export type Options = {
colorDots: Record<Color, string>;
colorEmpty: string;
colorBorder: string;
@@ -62,3 +62,26 @@ export const drawWorld = (
// drawCircleStack(ctx, stack, o);
// ctx.restore();
};
export const drawLerpWorld = (
ctx: CanvasRenderingContext2D,
grid: Grid,
snake0: Snake,
snake1: Snake,
stack: Color[],
k: number,
o: Options
) => {
ctx.save();
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
drawGrid(ctx, grid, o);
drawSnakeLerp(ctx, snake0, snake1, k, o);
ctx.translate(0, (grid.height + 2) * o.sizeCell);
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
ctx.restore();
};

View File

@@ -5,6 +5,8 @@ import { getBestRoute } from "@snk/compute/getBestRoute";
import * as grids from "@snk/compute/__fixtures__/grid";
import { snake3 as snake } from "@snk/compute/__fixtures__/snake";
jest.setTimeout(20 * 1000);
const drawOptions = {
sizeBorderRadius: 2,
sizeCell: 16,
@@ -15,7 +17,7 @@ const drawOptions = {
colorSnake: "purple",
};
const gifOptions = { delay: 18 };
const gifOptions = { frameDuration: 20, step: 1 };
const dir = path.resolve(__dirname, "__snapshots__");

View File

@@ -3,7 +3,7 @@ import * as path from "path";
import { createCanvas } from "canvas";
import { Grid, copyGrid, Color } from "@snk/compute/grid";
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 * as tmp from "tmp";
import * as execa from "execa";
@@ -11,15 +11,11 @@ import * as execa from "execa";
export const createGif = async (
grid0: Grid,
chain: Snake[],
drawOptions: Parameters<typeof drawWorld>[4],
gifOptions: { delay: number }
drawOptions: Options,
gifOptions: { frameDuration: number; step: number }
) => {
let snake = chain[0];
const grid = copyGrid(grid0);
const stack: Color[] = [];
const width = drawOptions.sizeCell * (grid.width + 2);
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
const width = drawOptions.sizeCell * (grid0.width + 2);
const height = drawOptions.sizeCell * (grid0.height + 4) + 100;
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
unsafeCleanup: true,
@@ -28,28 +24,41 @@ export const createGif = async (
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d")!;
const writeImage = (i: number) => {
ctx.clearRect(0, 0, 99999, 99999);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 99999, 99999);
drawWorld(ctx, grid, snake, stack, drawOptions);
const buffer = canvas.toBuffer("image/png", {
compressionLevel: 0,
filters: canvas.PNG_FILTER_NONE,
});
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
fs.writeFileSync(fileName, buffer);
};
try {
for (let i = 0; i < chain.length; i++) {
snake = chain[i];
const grid = copyGrid(grid0);
const stack: Color[] = [];
step(grid, stack, snake);
writeImage(i);
for (let i = 0; i < chain.length; i += 1) {
const snake0 = chain[i];
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
step(grid, stack, snake0);
for (let k = 0; k < gifOptions.step; k++) {
ctx.clearRect(0, 0, 99999, 99999);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 99999, 99999);
drawLerpWorld(
ctx,
grid,
snake0,
snake1,
stack,
k / gifOptions.step,
drawOptions
);
const buffer = canvas.toBuffer("image/png", {
compressionLevel: 0,
filters: canvas.PNG_FILTER_NONE,
});
const fileName = path.join(
dir,
`${(i * gifOptions.step + k).toString().padStart(4, "0")}.png`
);
fs.writeFileSync(fileName, buffer);
}
}
const outFileName = path.join(dir, "out.gif");
@@ -60,7 +69,7 @@ export const createGif = async (
[
"convert",
["-loop", "0"],
["-delay", gifOptions.delay.toString()],
["-delay", gifOptions.frameDuration.toString()],
["-dispose", "2"],
// ["-layers", "OptimizeFrame"],
["-compress", "LZW"],