VisualSite
Batch story
Dot animation of a URL batch becoming a dataset.
$ npx context-ds add story-batchUsage
Use it for
Batch jobs on a site card. Plays only on screen; shows its resting picture with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: dot-engine
Example
examples/story-batch.tsx
import { Card } from "@/components/ui/card";import { BatchStory } from "@/components/visuals/stories/batch-story";export function Site() { return ( <Card variant="ring" className="min-h-[22rem] w-full max-w-xl items-center justify-center"> <div className="relative w-full max-w-[26rem]"> <BatchStory /> </div> </Card> );}Source
components/visuals/stories/batch-story.tsx
'use client';import { BATCH_RESTING, BATCH_STORY_GRID } from '@/components/visuals/stories/batch-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadBatchStory: LoadDotStory = async (stage) => { const { createBatchStoryPlayer } = await import('@/components/visuals/stories/batch-story-player'); return createBatchStoryPlayer(stage);};/** * The "Run batches at scale" card's picture: a job counts up to 25,000 URLs, becomes a page of results beside an open * box, and the page is packed into the box as one dataset. With reduced motion it shows the closed box. */export function BatchStory({ className }: { className?: string }) { return <DotStory grid={BATCH_STORY_GRID} resting={BATCH_RESTING} load={loadBatchStory} className={className} />;}components/visuals/stories/batch-story-player.ts
import { BATCH_RESTING, BATCH_SIZE, BATCH_STORY_GRID, BOX_BACK, BOX_FRONT, BOX_RIM_ROW, BOX_SHIFT, CENTERED_BOX, CLOSED_BOX, COUNTER, EMPTY_PROGRESS, FULL_PROGRESS, OPEN_BOX, PAGE_STOPS, PROGRESS_CELLS, RESULTS_PAGE, SUMMARY } from '@/components/visuals/stories/batch-story-scenes';import type { GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { createGlyphShader, drawGlyphCells } from '@/components/visuals/dot-engine/dot-glyph-morph';import { DOT_ABSENT, DOT_FILLED } from '@/components/visuals/dot-engine/dot-morph-dots';import { cellIndexIn, cellState, clamp01, hasRippleReached, placeCells } from '@/components/visuals/dot-engine/dot-story-cells';import { createDotStoryPlayer, dotStoryFont, paintDotStoryText, type DotStoryPlayer, type DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One batch, played as a loop: the job counts up to 25,000 URLs while its progress fills dot by dot, the full * progress melts into a page of results beside an open box, the page lifts over the box and drops in, the box * closes and moves to the middle, and its summary appears over it. Then the box melts back into an empty job. */const RUN = { startsAt: 0.4, seconds: 3 };const SPLIT = { startsAt: RUN.startsAt + RUN.seconds + 0.5, seconds: 1.1 };const PACK = { startsAt: SPLIT.startsAt + SPLIT.seconds + 0.4, seconds: 1.7, liftShare: 0.55, liftInCells: 3 };const CLOSE = { startsAt: PACK.startsAt + PACK.seconds + 0.2, seconds: 0.6 };const CENTER = { startsAt: CLOSE.startsAt + CLOSE.seconds + 0.15, seconds: 0.7 };const SUMMARY_AT = CENTER.startsAt + CENTER.seconds + 0.15;const RESET = { startsAt: SUMMARY_AT + 2, seconds: 1.1 };const LOOP_SECONDS = RESET.startsAt + RESET.seconds + 0.3;// The box stands in the middle with its summary over it: the picture the static art shows.const RESTING_AT_SECONDS = SUMMARY_AT + 1;const TEXT_FADE_SECONDS = 0.3;const { columns, rows, pitch } = BATCH_STORY_GRID;const cellIndex = cellIndexIn(BATCH_STORY_GRID);const WHOLE_GRID = { left: 0, top: 0, cellsPerSide: Math.max(columns, rows) };const FARTHEST_FROM_RIM = Math.max(BOX_RIM_ROW, rows - 1 - BOX_RIM_ROW);const easeInOut = (t: number) => t * t * (3 - 2 * t);// Slows only a little toward the end, so the count is still visibly counting when it lands on the total.const settle = (t: number) => 1 - (1 - t) ** 1.6;const between = (from: number, to: number, share: number) => from + (to - from) * share;const melt = (from: GlyphCell[], into: GlyphCell[]) => createGlyphShader({ grid: BATCH_STORY_GRID, box: WHOLE_GRID, cells: from, drawTarget: (target) => drawGlyphCells(target, into, pitch) });// Over to the box in an arc, then straight down into it.function pageAt(packed: number) { const { start, over, packed: rest } = PAGE_STOPS; if (packed >= PACK.liftShare) return { column: over.column, row: Math.round(between(over.row, rest.row, easeInOut((packed - PACK.liftShare) / (1 - PACK.liftShare)))) }; const lifted = easeInOut(clamp01(packed / PACK.liftShare)); return { column: Math.round(between(start.column, over.column, lifted)), row: Math.round(between(start.row, over.row, lifted) - PACK.liftInCells * Math.sin(Math.PI * lifted)) };}export function createBatchStoryPlayer({ canvas, dotColor, surfaceColor, fontFamily, onFirstFrame }: DotStoryStage): DotStoryPlayer { const states = new Uint8Array(columns * rows); const pageAtStart = placeCells(RESULTS_PAGE.ink, { left: PAGE_STOPS.start.column, top: PAGE_STOPS.start.row }); const split = melt(FULL_PROGRESS.cells, [...OPEN_BOX.cells, ...pageAtStart]); const reset = melt(CENTERED_BOX.cells, EMPTY_PROGRESS.cells); // The canvas draws with whatever is loaded at the time, so the fonts are asked for before any text is set. for (const text of [COUNTER, ...SUMMARY]) void document.fonts.load(dotStoryFont(text, fontFamily), text.text); const paintProgress = (run: number) => { states.set(EMPTY_PROGRESS.states); const done = Math.round(settle(clamp01(run)) * PROGRESS_CELLS.length); for (let step = 0; step < done; step++) states[cellIndex(PROGRESS_CELLS[step])] = DOT_FILLED; return states; }; // The page goes in front of the box's back wall and behind its front, so it sinks out of sight below the rim. const paintPacking = (packed: number) => { states.set(BOX_BACK.states); const corner = pageAt(packed); const isOnGrid = (cell: GlyphCell) => cell.column >= 0 && cell.column < columns && cell.row >= 0 && cell.row < rows; for (const cell of placeCells(RESULTS_PAGE.paper, { left: corner.column, top: corner.row }).filter(isOnGrid)) states[cellIndex(cell)] = DOT_ABSENT; for (const cell of placeCells(RESULTS_PAGE.ink, { left: corner.column, top: corner.row }).filter(isOnGrid)) states[cellIndex(cell)] = cellState(cell); BOX_FRONT.states.forEach((state, cell) => { if (state !== DOT_ABSENT) states[cell] = state; }); return states; }; // The lid comes down onto the rim, so the change spreads up and down from there. const paintClosing = (closed: number) => { for (let cell = 0; cell < states.length; cell++) { const fromRim = Math.abs(Math.floor(cell / columns) - BOX_RIM_ROW) / FARTHEST_FROM_RIM; states[cell] = hasRippleReached(fromRim, closed) ? CLOSED_BOX.states[cell] : OPEN_BOX.states[cell]; } return states; }; const paintCentring = (centered: number) => { if (centered >= 1) return CENTERED_BOX.states; states.fill(DOT_ABSENT); for (const cell of placeCells(CLOSED_BOX.cells, { left: Math.round(BOX_SHIFT * easeInOut(clamp01(centered))), top: 0 })) states[cellIndex(cell)] = cellState(cell); return states; }; const statesAt = (seconds: number) => { if (seconds >= RESET.startsAt) return reset({ morph: clamp01((seconds - RESET.startsAt) / RESET.seconds), spin: 0 }); if (seconds >= CENTER.startsAt) return paintCentring((seconds - CENTER.startsAt) / CENTER.seconds); if (seconds >= CLOSE.startsAt) return paintClosing((seconds - CLOSE.startsAt) / CLOSE.seconds); if (seconds >= SPLIT.startsAt + SPLIT.seconds) return paintPacking(clamp01((seconds - PACK.startsAt) / PACK.seconds)); if (seconds >= SPLIT.startsAt) return split({ morph: (seconds - SPLIT.startsAt) / SPLIT.seconds, spin: 0 }); return paintProgress((seconds - RUN.startsAt) / RUN.seconds); }; const paintCounter = (context: CanvasRenderingContext2D, seconds: number) => { const presence = 1 - clamp01((seconds - SPLIT.startsAt) / TEXT_FADE_SECONDS); const counted = Math.round(settle(clamp01((seconds - RUN.startsAt) / RUN.seconds)) * BATCH_SIZE); paintDotStoryText(context, fontFamily, { ...COUNTER, text: `${counted.toLocaleString('en-US')} URLs` }, presence); }; const paintSummary = (context: CanvasRenderingContext2D, seconds: number) => { const presence = clamp01((seconds - SUMMARY_AT) / TEXT_FADE_SECONDS) * (1 - clamp01((seconds - RESET.startsAt) / TEXT_FADE_SECONDS)); for (const text of SUMMARY) paintDotStoryText(context, fontFamily, text, presence); }; return createDotStoryPlayer({ canvas, dotColor, surfaceColor, onFirstFrame, grid: BATCH_STORY_GRID, resting: { states: BATCH_RESTING.states, atSeconds: RESTING_AT_SECONDS }, frameAt(storySeconds) { const seconds = storySeconds % LOOP_SECONDS; return { states: statesAt(seconds), paintAbove(context) { paintCounter(context, seconds); paintSummary(context, seconds); }, }; }, });}components/visuals/stories/batch-story-scenes.ts
import type { GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { cellStates, cellsOfStates, fillCells, outlineCells, paintedStates, placeCells, polygon, rectangle, ringsWithSpeckle, solidWithSpeckle, stack, type DotPainter, type Region } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The "Run batches at scale" card's pictures, in the brand dot grid: a job counts its way through 25,000 URLs, then * becomes its results, a page of them and the box they are packed into as one dataset. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const BATCH_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, pitch: PITCH } = BATCH_STORY_GRID;const x = (column: number) => column * PITCH;const y = (row: number) => row * PITCH;export interface StoryPicture { states: Uint8Array; cells: GlyphCell[];}const pictureOf = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(BATCH_STORY_GRID, states) });const painted = (painter: DotPainter) => pictureOf(paintedStates(BATCH_STORY_GRID, painter));export const BATCH_SIZE = 25000;/** One dot per share of the batch, filled in reading order as the job runs. */export const PROGRESS: Region = { left: 4, top: 20, columns: 50, rows: 8 };export const PROGRESS_CELLS = fillCells(PROGRESS, false);export const EMPTY_PROGRESS = pictureOf(cellStates(BATCH_STORY_GRID, PROGRESS_CELLS));export const FULL_PROGRESS = pictureOf(cellStates(BATCH_STORY_GRID, fillCells(PROGRESS, true)));export const COUNTER: DotStoryText = { text: `${BATCH_SIZE.toLocaleString('en-US')} URLs`, x: x(COLUMNS / 2), y: y(15), size: 64, weight: 800, anchor: 'middle' };const PAGE_SIZE = { columns: 12, rows: 14 };const PAGE_LINES = [ { row: 2, columns: 5, filled: true }, { row: 5, columns: 8, filled: false }, { row: 7, columns: 6, filled: false }, { row: 9, columns: 8, filled: false }, { row: 11, columns: 4, filled: false },];/** * A page of results, drawn from its own top-left corner so it can be set down anywhere on its way into the box. Its * paper is the dots it clears rather than draws, so whatever the page passes in front of never shows through it. */export const RESULTS_PAGE = { paper: fillCells({ left: 1, top: 1, columns: PAGE_SIZE.columns - 2, rows: PAGE_SIZE.rows - 2 }, false), ink: [...outlineCells({ left: 0, top: 0, ...PAGE_SIZE }), ...PAGE_LINES.flatMap(({ row, columns, filled }) => Array.from({ length: columns }, (_, step) => ({ column: 2 + step, row, filled })))],};const BOX = { left: 33, right: 54, top: 21, bottom: 36 };const LID = { left: BOX.left - 1, right: BOX.right + 1, top: BOX.top - 5, bottom: BOX.top - 1 };const MIDDLE = (BOX.left + BOX.right) / 2;const TAPE_COLUMNS = [Math.floor(MIDDLE), Math.ceil(MIDDLE)];const TAPE_BOTTOM = BOX.top + 5;const OPENING_DEPTH = 4;const OPENING_INSET = 3;const FLAP_REACH = 3;const OPENING_BACK = BOX.top - OPENING_DEPTH;export const BOX_RIM_ROW = BOX.top;/** Where the page starts, where it hangs over the box, and where it comes to rest inside it, by its top-left corner. */export const PAGE_STOPS = { start: { column: 8, row: 12 }, over: { column: BOX.left + Math.floor((BOX.right - BOX.left + 1 - PAGE_SIZE.columns) / 2), row: 3 }, packed: { column: BOX.left + Math.floor((BOX.right - BOX.left + 1 - PAGE_SIZE.columns) / 2), row: BOX.top + 1 },};const onTape = (column: number) => TAPE_COLUMNS.includes(column);const boxFront: DotPainter = (column, row) => { const front = rectangle(BOX, solidWithSpeckle)(column, row); if (front === DOT_ABSENT) return DOT_ABSENT; return onTape(column) && row > BOX.top && row <= TAPE_BOTTOM ? DOT_HOLLOW : front;};const closedLid: DotPainter = (column, row) => { const lid = rectangle(LID, ringsWithSpeckle)(column, row); return lid !== DOT_ABSENT && onTape(column) ? DOT_FILLED : lid;};const opening = polygon( [ { column: BOX.left, row: BOX.top }, { column: BOX.right, row: BOX.top }, { column: BOX.right - OPENING_INSET, row: OPENING_BACK }, { column: BOX.left + OPENING_INSET, row: OPENING_BACK }, ], ringsWithSpeckle);function sideFlap(hinge: number, outwards: 1 | -1) { return polygon( [ { column: hinge, row: BOX.top }, { column: hinge - outwards * OPENING_INSET, row: OPENING_BACK }, { column: hinge - outwards * OPENING_INSET + outwards * FLAP_REACH, row: OPENING_BACK - FLAP_REACH }, { column: hinge + outwards * FLAP_REACH, row: BOX.top - FLAP_REACH }, ], ringsWithSpeckle );}/** The open box in two layers, so the page can pass between them: in front of the back wall, behind the front. */export const BOX_FRONT = painted(boxFront);export const BOX_BACK = painted(stack(sideFlap(BOX.left, -1), sideFlap(BOX.right, 1), opening));export const OPEN_BOX = painted(stack(boxFront, sideFlap(BOX.left, -1), sideFlap(BOX.right, 1), opening));// The lid's shadow: the front's top edge drops out under the closed lid.export const CLOSED_BOX = painted((column, row) => (row === BOX.top ? DOT_ABSENT : stack(closedLid, boxFront)(column, row)));/** How far the closed box travels, in columns, to stand in the middle of the picture. */export const BOX_SHIFT = Math.round((COLUMNS - 1) / 2 - MIDDLE);export const CENTERED_BOX = pictureOf(cellStates(BATCH_STORY_GRID, placeCells(CLOSED_BOX.cells, { left: BOX_SHIFT, top: 0 })));const SUMMARY_BOTTOM = LID.top - 2;export const SUMMARY: DotStoryText[] = [ { text: COUNTER.text, x: x(COLUMNS / 2), y: y(SUMMARY_BOTTOM - 3), size: 28, weight: 800, anchor: 'middle' }, { text: 'One dataset', x: x(COLUMNS / 2), y: y(SUMMARY_BOTTOM), size: 18, weight: 700, muted: true, anchor: 'middle' },];/** The batch packed: what the picture shows before it plays, and instead of playing with reduced motion. */export const BATCH_RESTING = { ...CENTERED_BOX, texts: SUMMARY };Reference: site src/components/sections/home/batch-story.tsx.