VisualSite
Monitor ways stories
Dot animations for page changes, sitemap changes, and extracted-record monitoring.
$ npx context-ds add story-monitor-waysUsage
Use it for
The ways to monitor on a site product page, one picture per card. Exports PageMonitorStory, SitemapMonitorStory and ExtractMonitorStory. Each plays only on screen; with reduced motion they show the diff, the changed list, the scored record.
Avoid
- Stories as the main content of a page
Dependencies: dot-engine, story-parts
Example
examples/story-monitor-ways.tsx
import { Card } from "@/components/ui/card";import { PageMonitorStory, SitemapMonitorStory, ExtractMonitorStory } from "@/components/visuals/stories/monitor-ways-story";// One card per way to monitor, stacked so each picture is drawn at a size its dots can be read at.export function Site() { return ( <div className="mx-auto grid w-full max-w-md gap-6"> <Card variant="ring" className="items-center justify-center p-4 md:p-5"> <div className="relative w-full"> <PageMonitorStory /> </div> </Card> <Card variant="ring" className="items-center justify-center p-4 md:p-5"> <div className="relative w-full"> <SitemapMonitorStory /> </div> </Card> <Card variant="ring" className="items-center justify-center p-4 md:p-5"> <div className="relative w-full"> <ExtractMonitorStory /> </div> </Card> </div> );}Source
components/visuals/stories/monitor-ways-story.tsx
'use client';import { EXTRACT_MONITOR_RESTING, MONITOR_WAYS_GRID, PAGE_MONITOR_RESTING, SITEMAP_MONITOR_RESTING } from '@/components/visuals/stories/monitor-ways-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadPlayers = () => import('@/components/visuals/stories/monitor-ways-story-player');const loadPageMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createPageMonitorStoryPlayer(stage);const loadSitemapMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createSitemapMonitorStoryPlayer(stage);const loadExtractMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createExtractMonitorStoryPlayer(stage);/** A page monitor's picture: a page re-scraped until a line changes, then that line's diff. With reduced motion it shows the diff. */export function PageMonitorStory({ className }: { className?: string }) { return <DotStory grid={MONITOR_WAYS_GRID} resting={PAGE_MONITOR_RESTING} load={loadPageMonitorStory} className={className} />;}/** A sitemap monitor's picture: a list of URLs loses one and gains one, then the new page. With reduced motion it shows the changed list. */export function SitemapMonitorStory({ className }: { className?: string }) { return <DotStory grid={MONITOR_WAYS_GRID} resting={SITEMAP_MONITOR_RESTING} load={loadSitemapMonitorStory} className={className} />;}/** An extract monitor's picture: a plain-language instruction, then the record read from the page and its confidence. With reduced motion it shows the scored record. */export function ExtractMonitorStory({ className }: { className?: string }) { return <DotStory grid={MONITOR_WAYS_GRID} resting={EXTRACT_MONITOR_RESTING} load={loadExtractMonitorStory} className={className} />;}components/visuals/stories/monitor-ways-story-player.ts
import { CONFIDENT_SEGMENTS, EXTRACT_CAPTION, METER_SEGMENTS, MONITOR_WAYS_GRID, NEW_PAGE_PICTURE, PAGE_CAPTIONS, PAGE_CHANGED, PAGE_UNCHANGED, PROMPT_FIELD, PROMPT_FILLED, PROMPT_TEXT, RECORD_SCORED, RECORD_UNSCORED, SITEMAP_AFTER, SITEMAP_BEFORE, SITEMAP_CAPTIONS, TEXT_DIFF, alongPrompt, paintRecord, paintSitemapList, paintWatchedPage,} from '@/components/visuals/stories/monitor-ways-story-scenes';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer, typingSeconds } from '@/components/visuals/dot-engine/dot-story-beats';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';const MELT_SECONDS = 1.1;// The page is scraped twice; the second scrape finds the line changed as its scan passes it.const SCRAPES = { startsAt: 0.4, seconds: 1.1, times: 2, changeSeenAt: 0.55 };/** A page monitor, played as a loop: the page is re-scraped until a line of it changes, then melts into that line's diff. */export function createPageMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer { const scrapesAt = (heldSeconds: number) => (heldSeconds - SCRAPES.startsAt) / SCRAPES.seconds; return createBeatStoryPlayer(stage, { grid: MONITOR_WAYS_GRID, resting: { beat: 1, heldSeconds: 2 }, beats: [ captionedBeat({ picture: PAGE_UNCHANGED, departure: PAGE_CHANGED, caption: PAGE_CAPTIONS.watching, holdSeconds: SCRAPES.startsAt + SCRAPES.seconds * SCRAPES.times + 0.7, isTyped: true, animate(states, heldSeconds) { const scrapes = scrapesAt(heldSeconds); paintWatchedPage(states, scrapes >= 0 && scrapes < SCRAPES.times ? scrapes % 1 : 0, scrapes >= SCRAPES.times - 1 + SCRAPES.changeSeenAt); }, }), captionedBeat({ picture: TEXT_DIFF, caption: PAGE_CAPTIONS.changed, holdSeconds: 3 }), ], });}const LISTING = { removedAt: 1, addedAt: 1.7 };/** A sitemap monitor, played as a loop: its list of URLs loses one and gains one, then melts into the new page. */export function createSitemapMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: MONITOR_WAYS_GRID, resting: { beat: 0, heldSeconds: LISTING.addedAt + 1 }, beats: [ { ...captionedBeat({ picture: SITEMAP_BEFORE, departure: SITEMAP_AFTER, caption: SITEMAP_CAPTIONS.watching, holdSeconds: LISTING.addedAt + 2, animate: (states, heldSeconds) => void paintSitemapList(states, heldSeconds >= LISTING.removedAt, heldSeconds >= LISTING.addedAt) }), write: (pen, heldSeconds) => (heldSeconds >= LISTING.addedAt ? SITEMAP_CAPTIONS.changed : SITEMAP_CAPTIONS.watching).forEach((text) => pen.text(text)), }, captionedBeat({ picture: NEW_PAGE_PICTURE, caption: SITEMAP_CAPTIONS.added, holdSeconds: 2.6, isTyped: true }), ], });}const PROMPT_TYPING = { startsAt: 0.4, charactersPerSecond: 11 };const SCORING = { startsAt: 0.5, everySeconds: 0.12 };const FIELD_WIPE_SECONDS = 0.45;/** An extract monitor, played as a loop: what to watch is typed in plain words, and melts into the record read from the page, scored. */export function createExtractMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer { const [label, score, detail] = EXTRACT_CAPTION; const scoredAt = (heldSeconds: number) => (heldSeconds < SCORING.startsAt ? 0 : Math.min(CONFIDENT_SEGMENTS, Math.floor((heldSeconds - SCORING.startsAt) / SCORING.everySeconds) + 1)); return createBeatStoryPlayer(stage, { grid: MONITOR_WAYS_GRID, resting: { beat: 3, heldSeconds: SCORING.startsAt + CONFIDENT_SEGMENTS * SCORING.everySeconds + 0.8 }, beats: [ { picture: PROMPT_FILLED, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.1 }, { picture: PROMPT_FIELD, arrival: { kind: 'builds', seconds: FIELD_WIPE_SECONDS, order: alongPrompt }, holdSeconds: PROMPT_TYPING.startsAt + typingSeconds(PROMPT_TEXT, PROMPT_TYPING.charactersPerSecond) + 0.9, write: (pen, heldSeconds) => pen.type(PROMPT_TEXT, heldSeconds, { ...PROMPT_TYPING, cursorLingers: Infinity }), }, { picture: PROMPT_FILLED, arrival: { kind: 'builds', seconds: FIELD_WIPE_SECONDS, order: alongPrompt }, holdSeconds: 0.1 }, { ...captionedBeat({ picture: RECORD_UNSCORED, departure: RECORD_SCORED, caption: EXTRACT_CAPTION, holdSeconds: SCORING.startsAt + CONFIDENT_SEGMENTS * SCORING.everySeconds + 2.8, animate: (states, heldSeconds) => void paintRecord(states, scoredAt(heldSeconds)) }), write(pen, heldSeconds) { pen.text(label); pen.text({ ...score, text: (scoredAt(heldSeconds) / METER_SEGMENTS).toFixed(2) }); pen.text(detail); }, }, ], });}components/visuals/stories/monitor-ways-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';import { promptField } from '@/components/visuals/stories/prompt-field';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, paintShape, roundedBox, solidWithSpeckle, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The pictures for the three ways to watch a website, in the brand dot grid. A page monitor: a page re-scraped until * one of its lines changes, and the exact diff of that line. A sitemap monitor: a list of URLs that loses one and * gains one, and the new page. An extract monitor: what to watch, said in plain words, and the fields read from the * page with how sure the monitor is that one of them changed. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const MONITOR_WAYS_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS, pitch: PITCH } = MONITOR_WAYS_GRID;const x = (column: number) => column * PITCH;// Doto sits a third of its size below the middle of the row it is set on.const baselineOn = (row: number, size: number) => (row + 0.5) * PITCH + size / 3;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(MONITOR_WAYS_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const caption = (label: string, value: string, detail: string) => storyCaption(MONITOR_WAYS_GRID, 34, label, value, detail);const SHEET: DotBox = { left: 4, right: 26, top: 3, bottom: 34 };const PAGE = { title: { rows: [6, 7], right: 16 }, left: 7, lines: [{ row: 11, right: 22 }, { row: 13, right: 19 }, { row: 15, right: 21 }, { row: 24, right: 22 }, { row: 26, right: 18 }, { row: 28, right: 21 }, { row: 30, right: 15 }], changed: { rows: [19, 20], right: 20 } };/** The watched page with a scan `scanned` of the way down it (none at 0 or 1); once changed, one line of it is solid. */export function paintWatchedPage(states: Uint8Array, scanned: number, isChanged: boolean) { const bar = SHEET.top + 1 + Math.round(scanned * (SHEET.bottom - SHEET.top - 2)); return paintShape(MONITOR_WAYS_GRID, states, roundedBox(SHEET, 2), (column, row) => { if (scanned > 0 && scanned < 1 && row === bar) return DOT_FILLED; if (!between(column, PAGE.left, SHEET.right - 3)) return DOT_ABSENT; if (PAGE.title.rows.includes(row)) return column <= PAGE.title.right ? DOT_FILLED : DOT_ABSENT; if (PAGE.changed.rows.includes(row)) return column > PAGE.changed.right ? DOT_ABSENT : isChanged ? DOT_FILLED : row === PAGE.changed.rows[0] ? DOT_HOLLOW : DOT_ABSENT; return PAGE.lines.some((line) => line.row === row && column <= line.right) ? DOT_HOLLOW : DOT_ABSENT; });}export const PAGE_UNCHANGED = pictureOfStates(paintWatchedPage(blank(), 0, false));export const PAGE_CHANGED = pictureOfStates(paintWatchedPage(blank(), 0, true));const DIFF = { left: 2, right: 30, tab: 6, removed: { top: 9, bottom: 16 }, added: { top: 21, bottom: 28 } };// The line as it was, in rings behind a minus, and as it is now, solid behind a plus: each sign cut out of its tab.const diffLine = (states: Uint8Array, { top, bottom }: { top: number; bottom: number }, isAdded: boolean) => paintShape(MONITOR_WAYS_GRID, states, roundedBox({ left: DIFF.left, right: DIFF.right, top, bottom }, 2), (column, row) => { const middle = (top + bottom) / 2; const tabCenter = DIFF.left + DIFF.tab / 2; if (column <= DIFF.left + DIFF.tab) { const isBar = Math.abs(row - middle) < 1 && Math.abs(column - tabCenter) <= 2; const isStem = isAdded && Math.abs(column - tabCenter) < 1 && Math.abs(row - middle) <= 2.5; return isBar || isStem ? DOT_HOLLOW : DOT_FILLED; } return isAdded ? solidWithSpeckle(column, row) : DOT_HOLLOW; });export const TEXT_DIFF = pictureOfStates(diffLine(diffLine(blank(), DIFF.removed, false), DIFF.added, true));export const PAGE_CAPTIONS = { watching: caption('every hour', 're-scrape', 'one URL, its visible text'), changed: caption('exact diff', '1 line', '$49 -> $59 a month'),};const LIST = { left: 3, right: 28, tab: 4, rowHeight: 4, every: 5, top: 4, count: 6, removed: 2, added: 5 };const listRow = (index: number): DotBox => ({ left: LIST.left, right: LIST.right, top: LIST.top + index * LIST.every, bottom: LIST.top + index * LIST.every + LIST.rowHeight - 1 });/** The sitemap's URLs. `isRemoved` empties the one that was delisted; `isAdded` brings in the new one, solid. */export function paintSitemapList(states: Uint8Array, isRemoved: boolean, isAdded: boolean) { states.fill(DOT_ABSENT); for (let index = 0; index < LIST.count; index++) { if (index === LIST.added && !isAdded) continue; const box = listRow(index); paintShape(MONITOR_WAYS_GRID, states, roundedBox(box, 1), (column) => { if (index === LIST.removed && isRemoved) return DOT_ABSENT; if (column <= box.left + LIST.tab) return DOT_FILLED; return index === LIST.added ? DOT_FILLED : DOT_HOLLOW; }); } return states;}export const SITEMAP_BEFORE = pictureOfStates(paintSitemapList(blank(), false, false));export const SITEMAP_AFTER = pictureOfStates(paintSitemapList(blank(), true, true));const NEW_PAGE = { plus: { column: 23, row: 28, radius: 5.4, arm: 3, width: 0.8 }, title: { rows: [7, 8], right: 17 }, lines: [{ row: 12, right: 22 }, { row: 14, right: 20 }, { row: 16, right: 22 }, { row: 18, right: 15 }] };const newPageStates = paintShape(MONITOR_WAYS_GRID, blank(), roundedBox(SHEET, 2), (column, row) => { if (!between(column, PAGE.left, SHEET.right - 3)) return DOT_ABSENT; if (NEW_PAGE.title.rows.includes(row)) return column <= NEW_PAGE.title.right ? DOT_FILLED : DOT_ABSENT; return NEW_PAGE.lines.some((line) => line.row === row && column <= line.right) ? DOT_HOLLOW : DOT_ABSENT;});// The new page wears a solid badge with a plus cut out of it.paintShape( MONITOR_WAYS_GRID, newPageStates, (column, row) => Math.hypot(column - NEW_PAGE.plus.column, row - NEW_PAGE.plus.row) <= NEW_PAGE.plus.radius, (column, row) => { const across = Math.abs(column - NEW_PAGE.plus.column); const down = Math.abs(row - NEW_PAGE.plus.row); return (across < NEW_PAGE.plus.width && down <= NEW_PAGE.plus.arm) || (down < NEW_PAGE.plus.width && across <= NEW_PAGE.plus.arm) ? DOT_HOLLOW : DOT_FILLED; });export const NEW_PAGE_PICTURE = pictureOfStates(newPageStates);export const SITEMAP_CAPTIONS = { watching: caption('sitemap.xml', '128 URLs', 'checked on a schedule'), changed: caption('sitemap.xml', '+1 -1', 'one added, one removed'), added: caption('new URL', 'added', '/products/atlas-pro'),};const PROMPT: DotBox = { left: 5, right: 52, top: 13, bottom: 23 };const FIELD = promptField(MONITOR_WAYS_GRID, PROMPT);export const PROMPT_FIELD = FIELD.empty;export const PROMPT_FILLED = FIELD.filled;export const alongPrompt = FIELD.along;export const PROMPT_TEXT: DotStoryText = { text: 'track pricing plans', x: x(PROMPT.left + 4), y: baselineOn((PROMPT.top + PROMPT.bottom) / 2, 22), size: 22, weight: 700 };const RECORD: DotBox = { left: 3, right: 29, top: 4, bottom: 33 };const FIELDS = { rows: [8, 12, 16], key: { left: 6, right: 11 }, value: { left: 14, ends: [25, 21, 24] }, changed: 1 };const METER = { row: 25, rows: 3, left: 6, segments: 10, every: 2 };/** The record read from the page, and under it a meter with `confident` of its segments filled; the changed field is solid. */export function paintRecord(states: Uint8Array, confident: number) { return paintShape(MONITOR_WAYS_GRID, states, roundedBox(RECORD, 2), (column, row) => { const field = FIELDS.rows.findIndex((fieldRow) => row === fieldRow || row === fieldRow + 1); if (field >= 0) { if (between(column, FIELDS.key.left, FIELDS.key.right)) return row === FIELDS.rows[field] ? DOT_HOLLOW : DOT_ABSENT; if (!between(column, FIELDS.value.left, FIELDS.value.ends[field])) return DOT_ABSENT; return field === FIELDS.changed ? DOT_FILLED : row === FIELDS.rows[field] ? DOT_HOLLOW : DOT_ABSENT; } if (row === METER.row - 4) return DOT_HOLLOW; const segment = (column - METER.left) / METER.every; if (!between(row, METER.row, METER.row + METER.rows - 1) || !Number.isInteger(segment) || !between(segment, 0, METER.segments - 1)) return DOT_ABSENT; return segment < confident ? DOT_FILLED : DOT_HOLLOW; });}/** How many of the meter's segments fill: the monitor is 0.9 sure the change is a real one. */export const CONFIDENT_SEGMENTS = 9;export const METER_SEGMENTS = METER.segments;export const RECORD_UNSCORED = pictureOfStates(paintRecord(blank(), 0));export const RECORD_SCORED = pictureOfStates(paintRecord(blank(), CONFIDENT_SEGMENTS));export const EXTRACT_CAPTION = caption('confidence', '0.90', 'Team plan: $49 -> $59');export const PAGE_MONITOR_RESTING = { ...TEXT_DIFF, texts: PAGE_CAPTIONS.changed };export const SITEMAP_MONITOR_RESTING = { ...SITEMAP_AFTER, texts: SITEMAP_CAPTIONS.changed };export const EXTRACT_MONITOR_RESTING = { ...RECORD_SCORED, texts: EXTRACT_CAPTION };Reference: site src/components/sections/products/monitor-ways-story.tsx.