VisualSite
Grants hero story
Dot animation of experiment progress and grant credits, with captions.
$ npx context-ds add story-grants-heroUsage
Use it for
The research grants page hero on the site. Plays only on screen; shows the full award with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: story-parts, dot-engine
Example
examples/story-grants-hero.tsx
import { Card } from "@/components/ui/card";import { GrantsHeroStory } from "@/components/visuals/stories/grants-hero-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]"> <GrantsHeroStory /> </div> </Card> );}Source
components/visuals/stories/grants-hero-story.tsx
'use client';import { GRANTS_RESTING } from '@/components/visuals/stories/grants-hero-story-scenes';import { HERO_STORY_GRID } from '@/components/visuals/stories/hero-story-kit';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadGrantsHeroStory: LoadDotStory = async (stage) => { const { createGrantsHeroStoryPlayer } = await import('@/components/visuals/stories/grants-hero-story-player'); return createGrantsHeroStoryPlayer(stage);};/** * The research grants hero's picture: a reward curve climbing as an experiment runs, then the grant's usage credits * awarded a tranche at a time. With reduced motion it shows the full award. */export function GrantsHeroStory({ className }: { className?: string }) { return <DotStory grid={HERO_STORY_GRID} resting={GRANTS_RESTING} load={loadGrantsHeroStory} className={className} />;}components/visuals/stories/grants-hero-story-player.ts
import { CREDITS_AWARDED, CREDITS_UNAWARDED, CREDIT_CAPTIONS, CURVE_CAPTION, CURVE_DRAWN, CURVE_END, CURVE_START, CURVE_UNDRAWN, TRANCHE_COUNT, paintCredits, paintCurve } from '@/components/visuals/stories/grants-hero-story-scenes';import { HERO_STORY_GRID, stepAt } from '@/components/visuals/stories/hero-story-kit';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import { clamp01 } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One grant, played as a loop: an experiment's reward curve is drawn across its chart, then melts into the award's * usage credits, stacked up a tranche at a time as the total climbs. */const DRAWING = { startsAt: 0.4, seconds: 2.2 };const AWARDING = { startsAt: 0.5, everySeconds: 1.2, textSeconds: 0.25 };const AWARDED_AT = AWARDING.startsAt + (TRANCHE_COUNT - 1) * AWARDING.everySeconds;const awardedAt = (heldSeconds: number) => stepAt(heldSeconds, AWARDING, TRANCHE_COUNT - 1);export function createGrantsHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: HERO_STORY_GRID, resting: { beat: 1, heldSeconds: AWARDED_AT + 0.8 }, beats: [ captionedBeat({ picture: CURVE_UNDRAWN, departure: CURVE_DRAWN, caption: CURVE_CAPTION, holdSeconds: DRAWING.startsAt + DRAWING.seconds + 1.4, isTyped: true, animate: (states, heldSeconds) => void paintCurve(states, CURVE_START + Math.round((CURVE_END - CURVE_START) * clamp01((heldSeconds - DRAWING.startsAt) / DRAWING.seconds))), }), { ...captionedBeat({ picture: CREDITS_UNAWARDED, departure: CREDITS_AWARDED, caption: CREDIT_CAPTIONS[0], holdSeconds: AWARDED_AT + 2.2, animate: (states, heldSeconds) => void paintCredits(states, awardedAt(heldSeconds) + 1) }), write(pen, heldSeconds) { const awarded = awardedAt(heldSeconds); if (awarded < 0) return; const written = clamp01((heldSeconds - AWARDING.startsAt - awarded * AWARDING.everySeconds) / AWARDING.textSeconds); CREDIT_CAPTIONS[awarded].forEach((text, line) => pen.text(text, line === 0 ? 1 : written)); }, }, ], });}components/visuals/stories/grants-hero-story-scenes.ts
import { HERO_STORY_GRID, blankScreen, cellAt, heroCaption, pictureOfStates } from '@/components/visuals/stories/hero-story-kit';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { paintShape, rings, roundedBox, solidWithSpeckle, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** * The research grants hero's pictures, in the brand dot grid: an experiment's reward curve climbing over its * episodes, and the grant's usage credits as a stack awarded a tranche at a time. */const AXES = { left: 5, right: 29, top: 6, bottom: 31 };const CURVE = { firstColumn: AXES.left + 2, climb: 21, settlesOver: 7, thickness: 2 };const EPISODE_EVERY = 3;/** The last column the curve reaches. */export const CURVE_END = AXES.right;const rewardRow = (column: number) => AXES.bottom - 3 - Math.round(CURVE.climb * (1 - Math.exp(-(column - CURVE.firstColumn) / CURVE.settlesOver)));// Single episodes land a little above or below the curve they average out to.const episodeScatter = (column: number) => ((column * 7) % 5) - 2;/** The chart with its curve drawn as far as column `reached`, and the episodes behind it as rings. */export function paintCurve(states: Uint8Array, reached: number) { states.fill(DOT_ABSENT); for (let row = AXES.top; row <= AXES.bottom; row++) states[cellAt(AXES.left, row)] = DOT_FILLED; for (let column = AXES.left; column <= AXES.right; column++) states[cellAt(column, AXES.bottom)] = DOT_FILLED; for (let column = CURVE.firstColumn; column <= Math.min(reached, CURVE_END); column++) { const scatter = episodeScatter(column); if (column % EPISODE_EVERY === 0 && scatter !== 0) states[cellAt(column, rewardRow(column) + scatter * 2)] = DOT_HOLLOW; for (let down = 0; down < CURVE.thickness; down++) states[cellAt(column, rewardRow(column) + down)] = DOT_FILLED; } return states;}export const CURVE_START = CURVE.firstColumn - 1;export const CURVE_UNDRAWN = pictureOfStates(paintCurve(blankScreen(), CURVE_START));export const CURVE_DRAWN = pictureOfStates(paintCurve(blankScreen(), CURVE_END));const STACK = { left: 8, right: 24 };/** Each tranche of an award, the total it brings the credits to, and when it is added. */const TRANCHES = [ { top: 26, bottom: 32, total: '$5,000', when: 'on acceptance' }, { top: 18, bottom: 24, total: '$10,000', when: 'after a progress review' }, { top: 4, bottom: 16, total: '$20,000', when: 'after a research milestone' },];export const TRANCHE_COUNT = TRANCHES.length;const trancheBox = ({ top, bottom }: (typeof TRANCHES)[number]): DotBox => ({ ...STACK, top, bottom });/** The stack with its first `awarded` tranches solid, and those still to come as rings. */export function paintCredits(states: Uint8Array, awarded: number) { TRANCHES.forEach((tranche, index) => paintShape(HERO_STORY_GRID, states, roundedBox(trancheBox(tranche), 2), index < awarded ? solidWithSpeckle : rings)); return states;}export const CREDITS_UNAWARDED = pictureOfStates(paintCredits(blankScreen(), 0));export const CREDITS_AWARDED = pictureOfStates(paintCredits(blankScreen(), TRANCHE_COUNT));export const CURVE_CAPTION = heroCaption('RL experiments', 'web data', 'search, scrape, company data');export const CREDIT_CAPTIONS = TRANCHES.map(({ total, when }) => heroCaption('usage credits', total, when));/** The full award: what the picture shows before it plays, and instead of playing with reduced motion. */export const GRANTS_RESTING = { ...CREDITS_AWARDED, texts: CREDIT_CAPTIONS[TRANCHE_COUNT - 1] };Reference: site src/components/sections/page-heroes/grants-hero-story.tsx.