VisualSite
Scope story
Dot animation of subdomain scope and crawl limits.
$ npx context-ds add story-scopeUsage
Use it for
Scoping link discovery on a site card. Plays only on screen; shows the meter at its limit with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: dot-engine
Example
examples/story-scope.tsx
import { Card } from "@/components/ui/card";import { ScopeStory } from "@/components/visuals/stories/scope-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]"> <ScopeStory /> </div> </Card> );}Source
components/visuals/stories/scope-story.tsx
'use client';import { SCOPE_RESTING, SCOPE_STORY_GRID } from '@/components/visuals/stories/scope-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadScopeStory: LoadDotStory = async (stage) => { const { createScopeStoryPlayer } = await import('@/components/visuals/stories/scope-story-player'); return createScopeStoryPlayer(stage);};/** * The "Scope the discovery" card's picture: a domain takes in its subdomains, then becomes a meter that fills with * links and stops at the limit. With reduced motion it shows the meter at its limit. */export function ScopeStory({ className }: { className?: string }) { return <DotStory grid={SCOPE_STORY_GRID} resting={SCOPE_RESTING} load={loadScopeStory} className={className} />;}components/visuals/stories/scope-story-player.ts
import { CAPTIONS, DOMAIN_ALONE, DOMAIN_WITH_SUBDOMAINS, LINK_LIMIT_SLOTS, MAX_LINKS, METER_AT_LIMIT, METER_EMPTY, SCOPE_STORY_GRID, SUBDOMAIN_COUNT, paintDomains, paintMeter } from '@/components/visuals/stories/scope-story-scenes';import { createBeatStoryPlayer, typingSeconds } 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 request's scope, played as a loop: subdomains are switched on and the domain takes each of them in; it melts * into a meter that fills with links, slot by slot, and stops at the limit. */const MELT_SECONDS = 1.1;const TYPING = { startsAt: 0.3, charactersPerSecond: 12 };const INCLUDING = { afterTypingSeconds: 0.3, everySeconds: 0.45 };const FILLING = { startsAt: 0.3, everySeconds: 0.16 };const DETAIL_FADE_SECONDS = 0.3;const stepsAt = (heldSeconds: number, startsAt: number, everySeconds: number, most: number) => (heldSeconds < startsAt ? 0 : Math.min(most, Math.floor((heldSeconds - startsAt) / everySeconds) + 1));export function createScopeStoryPlayer(stage: DotStoryStage): DotStoryPlayer { const [subdomainsLabel, subdomainsValue, subdomainsDetail] = CAPTIONS.subdomains; const [limitLabel, limitValue, limitDetail] = CAPTIONS.limit; const includingAt = TYPING.startsAt + typingSeconds(subdomainsValue, TYPING.charactersPerSecond) + INCLUDING.afterTypingSeconds; const includedAt = includingAt + (SUBDOMAIN_COUNT - 1) * INCLUDING.everySeconds; const limitReachedAt = FILLING.startsAt + (LINK_LIMIT_SLOTS - 1) * FILLING.everySeconds; const linksAt = (heldSeconds: number) => stepsAt(heldSeconds, FILLING.startsAt, FILLING.everySeconds, LINK_LIMIT_SLOTS); return createBeatStoryPlayer(stage, { grid: SCOPE_STORY_GRID, resting: { beat: 1, heldSeconds: limitReachedAt + 1 }, beats: [ { picture: DOMAIN_ALONE, departure: DOMAIN_WITH_SUBDOMAINS, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: includedAt + 1.8, animate: (states, heldSeconds) => void paintDomains(states, stepsAt(heldSeconds, includingAt, INCLUDING.everySeconds, SUBDOMAIN_COUNT)), write(pen, heldSeconds) { pen.text(subdomainsLabel); pen.type(subdomainsValue, heldSeconds, TYPING); pen.text(subdomainsDetail, clamp01((heldSeconds - includingAt) / DETAIL_FADE_SECONDS)); }, }, { picture: METER_EMPTY, departure: METER_AT_LIMIT, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: limitReachedAt + 2.6, animate: (states, heldSeconds) => void paintMeter(states, linksAt(heldSeconds)), write(pen, heldSeconds) { pen.text(limitLabel); pen.text({ ...limitValue, text: String(Math.round((linksAt(heldSeconds) / LINK_LIMIT_SLOTS) * MAX_LINKS)) }); pen.text(limitDetail, clamp01((heldSeconds - limitReachedAt) / DETAIL_FADE_SECONDS)); }, }, ], });}components/visuals/stories/scope-story-scenes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, distanceToSegment, isFilledAmongSolids, paintShape, type DotShape } from '@/components/visuals/dot-engine/dot-story-cells';import { storyCaption } from '@/components/visuals/dot-engine/story-caption';/** * The "Scope the discovery" card's pictures, in the brand dot grid: a domain with its subdomains around it, taken * in one by one, and a meter of links that fills to the limit set for the request and stops. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const SCOPE_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = SCOPE_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(SCOPE_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const DOMAIN = { center: { column: 16, row: 17 }, radius: 6.2 };const SUBDOMAIN = { radius: 3.6, out: 12 };// The subdomains stand around the domain: upper left, upper right and straight below.const SUBDOMAINS = [-5 / 6, -1 / 6, 1 / 2].map((turnsOfPi) => ({ column: DOMAIN.center.column + SUBDOMAIN.out * Math.cos(turnsOfPi * Math.PI), row: DOMAIN.center.row + SUBDOMAIN.out * Math.sin(turnsOfPi * Math.PI) }));const isDomain: DotShape = (column, row) => Math.hypot(column - DOMAIN.center.column, row - DOMAIN.center.row) <= DOMAIN.radius;const subdomainAt = (column: number, row: number) => SUBDOMAINS.findIndex((subdomain) => Math.hypot(column - subdomain.column, row - subdomain.row) <= SUBDOMAIN.radius);const isSubdomain: DotShape = (column, row) => subdomainAt(column, row) >= 0;const SPOKE_WIDTH = 0.6;/** The domain and its subdomains, the first `included` of them taken in: an included one is solid, and so is its spoke. */export function paintDomains(states: Uint8Array, included: number) { states.fill(DOT_ABSENT); for (let row = 0; row < ROWS; row++) { for (let column = 0; column < COLUMNS / 2; column++) { const spoke = SUBDOMAINS.findIndex((subdomain) => distanceToSegment(column, row, DOMAIN.center, subdomain) < SPOKE_WIDTH); if (spoke >= 0) states[row * COLUMNS + column] = spoke < included ? DOT_FILLED : DOT_HOLLOW; } } paintShape(SCOPE_STORY_GRID, states, isSubdomain, (column, row) => (subdomainAt(column, row) < included && isFilledAmongSolids(column, row) ? DOT_FILLED : DOT_HOLLOW)); return paintShape(SCOPE_STORY_GRID, states, isDomain, (column, row) => (isFilledAmongSolids(column, row) ? DOT_FILLED : DOT_HOLLOW));}export const SUBDOMAIN_COUNT = SUBDOMAINS.length;export const DOMAIN_ALONE = pictureOfStates(paintDomains(blank(), 0));export const DOMAIN_WITH_SUBDOMAINS = pictureOfStates(paintDomains(blank(), SUBDOMAIN_COUNT));const METER = { left: 8, right: 22, top: 3, bottom: 34, link: { left: 10, right: 20, from: 32, every: 2 }, slots: 14, limit: 9, marker: { column: 24, reach: 3 } };const isMeter: DotShape = (column, row) => between(column, METER.left, METER.right) && between(row, METER.top, METER.bottom);const slotRow = (slot: number) => METER.link.from - slot * METER.link.every;const LIMIT_ROW = slotRow(METER.limit) + 1;/** The meter with `links` of its slots filled from the bottom; the marker beside it points at the limit. */export function paintMeter(states: Uint8Array, links: number) { states.fill(DOT_ABSENT); paintShape(SCOPE_STORY_GRID, states, isMeter, (column, row) => { if (row === LIMIT_ROW) return DOT_FILLED; const slot = (METER.link.from - row) / METER.link.every; if (!Number.isInteger(slot) || !between(slot, 0, METER.slots - 1) || !between(column, METER.link.left, METER.link.right)) return DOT_ABSENT; return slot < links ? DOT_FILLED : DOT_HOLLOW; }); // An arrowhead pointing in at the limit line. for (let reach = 0; reach <= METER.marker.reach; reach++) { for (let row = LIMIT_ROW - reach; row <= LIMIT_ROW + reach; row++) states[row * COLUMNS + METER.marker.column + reach] = DOT_FILLED; } return states;}export const LINK_LIMIT_SLOTS = METER.limit;export const METER_EMPTY = pictureOfStates(paintMeter(blank(), 0));export const METER_AT_LIMIT = pictureOfStates(paintMeter(blank(), METER.limit));export const MAX_LINKS = 500;export const CAPTIONS = { subdomains: storyCaption(SCOPE_STORY_GRID, 34, 'includeSubdomains', 'true', 'docs. blog. and app. too'), limit: storyCaption(SCOPE_STORY_GRID, 34, 'maxLinks', String(MAX_LINKS), 'and the map stops there'),};/** The meter at its limit: what the picture shows before it plays, and instead of playing with reduced motion. */export const SCOPE_RESTING = { ...METER_AT_LIMIT, texts: CAPTIONS.limit };Reference: site src/components/sections/products/scope-story.tsx.