diff --git a/README.md b/README.md index 28cb652..7b50314 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ stackmap: architecture diagrams your coding agent writes, as one offline HTML file -You ask your agent to diagram a codebase or a system. It writes a small typed JSON, stackmap validates it with repair hints the agent acts on, lays it out, and hands you a single self-contained viewer — pan and zoom, search, trace upstream and downstream, guided views, dark and light themes, PNG/SVG export. No server, no account, nothing to install to open it. +You ask your agent to diagram a codebase, a system, a process or a request. It writes a small typed JSON, stackmap validates it with repair hints the agent acts on, lays it out, and hands you a single self-contained viewer — pan and zoom, search, trace, routes between two nodes, guided views, presentation mode, dark and light themes, image and video export. No server, no account, nothing to install to open it. + +Five kinds of diagram: **architecture** (components and what they call), **dataflow** (data moving through stages), **workflow** (steps across owner lanes), **lifecycle** (the states of one thing) and **sequence** (messages over time).

stackmap viewer in light mode: a Commerce API topology with the Orders database selected and its upstream traced; the inspector shows its details, connections and source evidence @@ -57,15 +59,16 @@ npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that re } ``` -Nine node types set the colour (`client`, `gateway`, `service`, `database`, `cache`, `queue`, `storage`, `external`, `security`); cards can carry rows, stats, a footer and a link; `brand` adds one of 146 [Simple Icons](https://simpleicons.org) logos. There are no coordinates — [ELK](https://eclipse.dev/elk/) lays everything out. Full reference: [skill/references/schema.md](skill/references/schema.md); modelling guidance: [authoring contract](skill/references/authoring-contract.md). +Nine node types set the colour (`client`, `gateway`, `service`, `database`, `cache`, `queue`, `storage`, `external`, `security`); lifecycles use seven state types (`start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`). Cards can carry rows, stats, a footer and a link, or be compact (title, subtitle, tag); `brand` adds one of 146 [Simple Icons](https://simpleicons.org) logos. Edges can be `async` or a `return`, and a `tone` marks the main path, security crossings and failure paths. There are no coordinates: [ELK](https://eclipse.dev/elk/) lays out architecture and dataflow diagrams, and stackmap's own layout places swimlanes (workflow, lifecycle) and sequences, deriving columns, message rows and activation bars from the structure. Full reference: [skill/references/schema.md](skill/references/schema.md); modelling guidance: [authoring contract](skill/references/authoring-contract.md). ## The viewer -- **Explore:** click a card for its details, connections and source evidence (linked to your repo when `source.url` is set). **Trace** keeps a node's upstream and downstream and dims the rest. +- **Explore:** click a card for its details, connections and source evidence (linked to your repo when `source.url` is set). **Trace** keeps a node's upstream and downstream and dims the rest. **Route** (`R`) picks two nodes and lights every directed path between them, listing the shortest. - **Find:** `/` searches titles, subtitles, ids and types; the **lens** dims node types you don't care about. -- **Guided views:** tabs the agent defines (“Checkout path”, “Data tier”) that dim everything else and fit the camera. -- **Share:** state lives in the URL hash (`#view=…&node=…&lens=…`); export PNG (1×/2×), copy PNG, or an SVG snapshot. -- **Keyboard:** Tab reaches the toolbar, then the canvas; arrows move between cards, Enter selects, Esc clears; with the canvas focused, arrows pan and `+`/`-`/`0` zoom. Honours reduced motion. +- **Guided views:** tabs the agent defines (“Checkout path”, “Data tier”) that dim everything else and fit the camera. **Present** (`F`) goes full screen and steps through them with the arrow keys. +- **Radar:** the minimap (`M`) mirrors what's lit and dimmed, and drags the camera. +- **Share:** state lives in the URL hash (`#view=…&node=…&lens=…&route=…`); export PNG (1×/2×), copy PNG, JPEG, WebP, an SVG snapshot, or a short video of the diagram with its flow animated (WebM, or MP4 where that's what the browser records). +- **Keyboard:** Tab reaches the toolbar, then the canvas; arrows move between cards, Enter selects (or picks a route end), Esc clears a selection or ends a route; `/`, `R`, `F` and `M` open search, route, presentation and the radar; with the canvas focused, arrows pan and `+`/`-`/`0` zoom. Honours reduced motion. ## Development diff --git a/packages/viewer/e2e/export.spec.ts b/packages/viewer/e2e/export.spec.ts index a89ea11..b92a2de 100644 --- a/packages/viewer/e2e/export.spec.ts +++ b/packages/viewer/e2e/export.spec.ts @@ -158,3 +158,24 @@ test('toned edges keep their colour in the export (main path in ink)', async ({ ); expect(darkest).toBeLessThan(80); }); + +test('JPEG and WebP export the whole diagram at 2×', async ({ page }) => { + const jpeg = await exportAs(page, /^JPEG/); + expect(jpeg.name).toBe('commerce-api.jpg'); + expect([...jpeg.bytes.subarray(0, 3)]).toEqual([0xff, 0xd8, 0xff]); + const webp = await exportAs(page, /^WebP/); + expect(webp.name).toBe('commerce-api.webp'); + expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP'); + // VP8/VP8L/VP8X headers all carry the canvas size; the extended one (VP8X) is 24-bit little-endian minus one. + const chunk = webp.bytes.subarray(12, 16).toString('ascii'); + if (chunk === 'VP8X') expect(webp.bytes.readUIntLE(24, 3) + 1).toBe(Math.ceil(w + 64) * 2); +}); + +test('Video records the flow animation as a WebM', async ({ page }) => { + test.slow(); + const video = await exportAs(page, /^Video/); + expect(video.name).toBe('commerce-api.webm'); + // EBML magic, and several seconds' worth of frames. + expect([...video.bytes.subarray(0, 4)]).toEqual([0x1a, 0x45, 0xdf, 0xa3]); + expect(video.bytes.length).toBeGreaterThan(20_000); +}); diff --git a/packages/viewer/e2e/motion.spec.ts b/packages/viewer/e2e/motion.spec.ts index 01f5c1b..9164da7 100644 --- a/packages/viewer/e2e/motion.spec.ts +++ b/packages/viewer/e2e/motion.spec.ts @@ -32,18 +32,30 @@ test('after the intro, emphasis is still CSS: tracing dims to 0.22', async ({ pa }); test('popovers opened by pointer grow in; search opened with "/" appears at once', async ({ page }) => { + // Count animate() calls per element instead of polling for a running animation: a 160ms pop-in can finish + // before the first poll on a busy machine. + await page.addInitScript(() => { + const seen: Element[] = []; + (window as unknown as { __animated: Element[] }).__animated = seen; + const animate = Element.prototype.animate; + Element.prototype.animate = function (this: Element, ...args: Parameters) { + seen.push(this); + return animate.apply(this, args); + }; + }); + const animated = (loc: import('@playwright/test').Locator) => loc.evaluate((el) => (window as unknown as { __animated: Element[] }).__animated.includes(el)); await page.goto('/?page=sample'); await settled(page); await page.getByRole('button', { name: 'Filter by type' }).click(); const lens = page.getByRole('group', { name: 'Show node types' }); - await expect.poll(() => lens.evaluate((el) => el.getAnimations().length)).toBeGreaterThan(0); + await expect.poll(() => animated(lens)).toBe(true); await settled(page); await page.keyboard.press('Escape'); await page.locator('body').click({ position: { x: 5, y: 5 } }); await page.keyboard.press('/'); const search = page.getByRole('combobox', { name: 'Search nodes' }); await expect(search).toBeFocused(); - expect(await search.evaluate((el) => el.parentElement!.getAnimations().length)).toBe(0); + expect(await animated(search.locator('..'))).toBe(false); }); test('reduced motion: nothing animates', async ({ browser }) => { diff --git a/packages/viewer/e2e/present.spec.ts b/packages/viewer/e2e/present.spec.ts new file mode 100644 index 0000000..d0791f3 --- /dev/null +++ b/packages/viewer/e2e/present.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; + +test('R picks a route between two cards, lights it, lists it and deep-links it', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.keyboard.press('r'); + await expect(page.getByRole('status').filter({ hasText: 'Pick where the route starts' })).toBeVisible(); + await page.locator('.sm-card[data-card-id="commit"]').click(); + await expect(page.getByRole('status').filter({ hasText: 'Now pick where it ends' })).toBeVisible(); + await page.locator('.sm-card[data-card-id="announce"]').click(); + await expect(page).toHaveURL(/#route=commit~announce$/); + const steps = page.getByRole('list', { name: 'Route steps' }); + await expect(steps.getByRole('listitem')).toHaveCount(8); + await expect(steps).toContainText('merge'); + await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'dim'); + await expect(page.locator('.sm-card[data-card-id="commit"]')).toHaveAttribute('data-emphasis', 'focus'); + await expect(page.locator('path[data-edge-id="e2"]')).toHaveAttribute('data-tint', 'client'); + await page.keyboard.press('Escape'); + await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'normal'); + await expect(page).not.toHaveURL(/route=/); +}); + +test('a route runs the other way when only that direction exists, and says when none does', async ({ page }) => { + await page.goto('/?page=release-delivery#route=announce~commit'); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toContainText('running the other way'); + await page.goto('/?page=release-delivery#route=failed~announce'); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toContainText('No directed connections lead from one to the other'); + await expect(page.locator('.sm-card[data-card-id="deploy"]')).toHaveAttribute('data-emphasis', 'dim'); +}); + +test('M toggles the radar, which mirrors what the explorer dims', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.keyboard.press('m'); + const radar = page.getByRole('img', { name: 'Minimap' }); + await expect(radar).toBeVisible(); + await expect(radar.locator('[data-minimap-edge]')).toHaveCount(10); + await page.getByRole('tab', { name: 'Stop conditions' }).click(); + await expect(radar.locator('rect.sm-minimap-card[data-dim]')).toHaveCount(5); + await page.keyboard.press('m'); + await expect(radar).toBeHidden(); +}); + +test('F presents: chrome hidden, one step per view, arrows step, Esc ends', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.keyboard.press('f'); + const bar = page.getByRole('group', { name: 'Presentation' }); + await expect(bar).toContainText('Overview'); + await expect(bar).toContainText('1 / 3'); + await expect(page.getByRole('heading', { level: 1 })).toBeHidden(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); + await page.keyboard.press('ArrowRight'); + await expect(bar).toContainText('Happy path'); + await expect(bar).toContainText('2 / 3'); + await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'dim'); + await page.keyboard.press('End'); + await expect(bar).toContainText('Stop conditions'); + await page.keyboard.press('Escape'); + await expect(bar).toBeHidden(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); +}); + +test('Esc cancels route picking even with focus on the Route button', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.getByRole('button', { name: 'Route between two nodes (R)' }).click(); + await expect(page.getByText('Pick where the route starts')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Pick where the route starts')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Route between two nodes (R)' })).toHaveAttribute('aria-pressed', 'false'); +}); + +test('a route in a sequence lights only the messages on its way, in time', async ({ page }) => { + await page.goto('/?page=cache-miss#route=user~db'); + for (const id of ['open', 'get', 'query']) await expect(page.locator(`path[data-edge-id="${id}"]`)).toHaveAttribute('data-tint', /.+/); + for (const id of ['render', 'json', 'emit', 'set']) await expect(page.locator(`path[data-edge-id="${id}"]`)).toHaveAttribute('data-dim', 'true'); + await expect(page.locator('.sm-card[data-card-id="trace"]')).toHaveAttribute('data-emphasis', 'dim'); +}); + +test('in a presentation, Space presses the focused button and M leaves the radar alone', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.keyboard.press('f'); + const bar = page.getByRole('group', { name: 'Presentation' }); + await expect(page.getByRole('button', { name: 'Previous step' })).toBeDisabled(); + await page.keyboard.press('ArrowRight'); + await page.getByRole('button', { name: 'Previous step' }).focus(); + await page.keyboard.press(' '); + await expect(bar).toContainText('1 / 3'); + await page.keyboard.press('m'); + await page.getByRole('button', { name: 'End presentation (Esc)' }).focus(); + await page.keyboard.press(' '); + await expect(bar).toBeHidden(); + await expect(page.getByRole('img', { name: 'Minimap' })).toBeHidden(); +}); diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png index 05010b1..f089642 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png index ac9f728..ad3d375 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png index 423272f..11c275c 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png index e7f43c3..eca4dca 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png index 994493e..df56ffc 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png index ddddda8..9ab7bbc 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png index 5dc9fa0..77d4651 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png index b627d8e..726c250 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png index 5c97d25..bf9d152 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png index 8a3f26a..ff2d138 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png index 99c3944..d5a4747 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png index 9fdbeb8..16b8bc1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png differ diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 1c0705f..9afd236 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -3,6 +3,8 @@ import type { LaidOutDiagram, Rect } from '@stackmap/core'; import { ZoomBar } from '../chrome/ZoomBar'; import { useSceneReveal } from '../motion/useSceneReveal'; import { useExplore } from '../explore/ExploreContext'; +import { routeBetween } from '../explore/graph'; +import { inMenu } from '../chrome/Toolbar'; import { CanvasPanel } from './CanvasPanel'; import { Minimap } from './Minimap'; import { toScene, type Scene } from './scene'; @@ -10,7 +12,7 @@ import { SceneLayers } from './SceneLayers'; import { useZoom } from './useZoom'; import type { Camera } from './useZoom'; import type { Transform } from './viewport'; -import { CameraProvider, ContentProvider, ViewportProvider } from './ViewportContext'; +import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext'; const PAN_STEP = 80; @@ -24,7 +26,7 @@ function union(rects: Rect[]): Rect | null { // Camera follows the explorer: a view fits its members (Overview fits everything, Q27); a revealing // selection (search, deep link) centres the node. The initial Overview needs nothing: useZoom already fit it. function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) { - const { draft, state } = useExplore(); + const { draft, state, graph } = useExplore(); const rectOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.rect])), [scene]); // Compare against the previous view, not a "first run" flag: StrictMode re-runs effects on mount. const shownView = useRef(restored ? state.view : null); @@ -36,6 +38,16 @@ function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) { if (box) camera.fitRect(box); else camera.fit(); }, [state.view, draft, rectOf, camera]); + // A new route frames everything on it. + const shownRoute = useRef(restored ? state.route : null); + useEffect(() => { + if (shownRoute.current === state.route) return; + shownRoute.current = state.route; + if (!state.route) return; + const on = routeBetween(graph, state.route.from, state.route.to)?.nodes ?? new Set([state.route.from, state.route.to]); + const box = union([...on].flatMap((id) => rectOf.get(id) ?? [])); + if (box) camera.fitRect(box); + }, [state.route, graph, rectOf, camera]); // The reveal a live reload restores from the hash is already on screen. const restoredReveal = useRef(restored ? state.reveal : -1); const latest = useRef({ state, draft }); @@ -77,7 +89,8 @@ function DotGrid({ x, y, k }: Transform) { ); } -export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram; children?: ReactNode }) { +/** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */ +export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: LaidOutDiagram; children?: ReactNode; chrome?: boolean }) { const scene = useMemo(() => toScene(diagram), [diagram]); const stageRef = useRef(null); const sceneRef = useRef(null); @@ -88,10 +101,38 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram; return union([scene.content, ...points.map((p) => ({ x: p.x, y: p.y, width: 0, height: 0 }))])!; }, [scene]); const { camera } = viewport; - const { emphasis, dispatch, state } = useExplore(); + const explore = useExplore(); + const { emphasis, dispatch, state } = explore; const [minimap, setMinimap] = useState(false); const { x, y, k } = viewport.transform; useCameraEffects(scene, camera, viewport.restored); + // The stage changes size when the chrome comes or goes: frame the current view again once it has. + const framed = useRef(chrome); + useEffect(() => { + if (framed.current === chrome) return; + framed.current = chrome; + const id = requestAnimationFrame(() => { + const members = state.view ? (explore.draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : []; + const box = union(members.flatMap((m) => scene.cards.find((c) => c.node.id === m)?.rect ?? [])); + if (box) camera.fitRect(box); + else camera.fit(); + }); + return () => cancelAnimationFrame(id); + }, [chrome, state.view, explore.draft, scene, camera]); + // M toggles the radar from anywhere but a text field or menu; not while presenting (it's hidden then). + const chromeRef = useRef(chrome); + chromeRef.current = chrome; + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if ((e.key !== 'm' && e.key !== 'M') || !chromeRef.current) return; + const t = e.target as HTMLElement | null; + if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return; + e.preventDefault(); + setMinimap((v) => !v); + }; + addEventListener('keydown', onKey); + return () => removeEventListener('keydown', onKey); + }, []); useSceneReveal(sceneRef, scene, viewport.restored); // Keys when the stage itself has focus (cards handle their own and stop propagation). @@ -115,9 +156,10 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram; +

{/* Top-left chrome first in the DOM: Tab reaches search/lens/trace before the cards. */} - {children} + {chrome && children}
{/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */} - - setMinimap((v) => !v)} /> - - {minimap && ( + {chrome && ( + + setMinimap((v) => !v)} /> + + )} + {chrome && minimap && ( - + )}
+ diff --git a/packages/viewer/src/canvas/Minimap.tsx b/packages/viewer/src/canvas/Minimap.tsx index 98f4ea6..19d7a7a 100644 --- a/packages/viewer/src/canvas/Minimap.tsx +++ b/packages/viewer/src/canvas/Minimap.tsx @@ -1,6 +1,8 @@ -import { useRef, type PointerEvent } from 'react'; +import { memo, useRef, type PointerEvent } from 'react'; import type { Rect } from '@stackmap/core'; import { PANEL_CLASS, PANEL_STYLE } from '../chrome/ui'; +import type { Emphasis } from '../explore/emphasis'; +import { edgeStroke } from './ArrowMarker'; import type { Scene } from './scene'; import { viewportRect } from './viewport'; import { useViewport } from './ViewportContext'; @@ -10,7 +12,53 @@ const PAD = 24; const attrs = (r: Rect) => ({ x: r.x, y: r.y, width: r.width, height: r.height }); -export function Minimap({ scene }: { scene: Scene }) { +// Everything but the viewport box, memoised: the radar re-renders on every pan/zoom frame, the diagram doesn't change. +const RadarLayer = memo(function RadarLayer({ scene, emphasis }: { scene: Scene; emphasis: Emphasis }) { + return ( + <> + {[...scene.lanes, ...(scene.phaseStyle === 'band' ? scene.phases : [])].map((l) => ( + + ))} + {scene.lifelines.map((l) => ( + + ))} + {scene.frames.map((f) => ( + + ))} + {scene.edges.map((e) => { + const { dim, tint } = emphasis.edges.get(e.id) ?? { dim: false, tint: null }; + return ( + `${p.x},${p.y}`).join(' ')} + vectorEffect="non-scaling-stroke" + style={{ fill: 'none', stroke: edgeStroke(tint), strokeWidth: tint ? 1.5 : 1, opacity: dim ? 0.2 : 0.7 }} + /> + ); + })} + {scene.cards.map((card) => ( + + ))} + + ); +}); + +/** The radar: the whole diagram in miniature, mirroring what the explorer lights and dims, plus the visible area. */ +export function Minimap({ scene, emphasis }: { scene: Scene; emphasis: Emphasis }) { const { transform, stage, centerOn } = useViewport(); const c = scene.content; const box = { x: c.x - PAD, y: c.y - PAD, width: c.width + PAD * 2, height: c.height + PAD * 2 }; @@ -60,30 +108,7 @@ export function Minimap({ scene }: { scene: Scene }) { onPointerUp={stop} onPointerCancel={stop} > - {[...scene.lanes, ...(scene.phaseStyle === 'band' ? scene.phases : [])].map((l) => ( - - ))} - {scene.lifelines.map((l) => ( - - ))} - {scene.frames.map((f) => ( - - ))} - {scene.cards.map((card) => ( - - ))} + {/* --sm-text instead of a fixed translucent black: the React Flow mask vanished on dark panels. */} (null); @@ -19,6 +20,15 @@ export function useSceneContent(): Rect { } export const CameraProvider = CameraContext.Provider; +// The scene itself, for exports that need more than the frame (the video's flow animation). +const SceneContext = createContext(null); +export const SceneProvider = SceneContext.Provider; +export function useScene(): Scene { + const s = useContext(SceneContext); + if (!s) throw new Error('useScene must be used inside DiagramCanvas'); + return s; +} + export function useViewport(): ViewportApi { const api = useContext(ViewportContext); if (!api) throw new Error('useViewport must be used inside DiagramCanvas'); diff --git a/packages/viewer/src/chrome/ExportMenu.tsx b/packages/viewer/src/chrome/ExportMenu.tsx index d1db580..1c6871b 100644 --- a/packages/viewer/src/chrome/ExportMenu.tsx +++ b/packages/viewer/src/chrome/ExportMenu.tsx @@ -1,16 +1,40 @@ import { Download } from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { download, exportFileName, exportPng, exportSvg } from './export'; +import { canEncode, download, exportFileName, exportPng, exportRaster, exportSvg, exportVideo, videoExtension, videoType, type FlowEdge } from './export'; import { popIn } from '../motion/motion'; import { PANEL_CLASS, PANEL_STYLE } from './ui'; import { useExplore } from '../explore/ExploreContext'; -import { useSceneContent } from '../canvas/ViewportContext'; +import { useScene, useSceneContent } from '../canvas/ViewportContext'; +import type { Scene } from '../canvas/scene'; -type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'done'; text: string } | { kind: 'error'; text: string }; +type Status = { kind: 'idle' } | { kind: 'busy'; text?: string } | { kind: 'done'; text: string } | { kind: 'error'; text: string }; + +/** The video's pulses: every edge, tinted by its source, setting off in reading order (time, for a sequence). */ +function flowEdges(scene: Scene): FlowEdge[] { + const css = getComputedStyle(document.documentElement); + const typeOf = new Map(scene.cards.map((c) => [c.node.id, c.node.type])); + const rect = new Map(scene.cards.map((c) => [c.node.id, c.rect])); + const along = (id: string, e: Scene['edges'][number]) => { + if (scene.kind === 'sequence') return e.points[0]!.y; + const r = rect.get(id)!; + return scene.direction === 'RIGHT' ? r.x : r.y; + }; + const positions = scene.edges.map((e) => along(e.from, e)); + const min = Math.min(...positions); + const span = Math.max(...positions) - min || 1; + return scene.edges.map((e, i) => ({ + points: e.points, + color: css.getPropertyValue(`--sm-${typeOf.get(e.from)}-accent`).trim() || css.getPropertyValue('--sm-edge').trim(), + start: (positions[i]! - min) / span, + })); +} export function ExportMenu() { const { draft } = useExplore(); const content = useSceneContent(); + const scene = useScene(); + // What this browser can write, checked once. + const [formats] = useState(() => ({ jpeg: canEncode('image/jpeg'), webp: canEncode('image/webp'), video: videoType() })); const [open, setOpen] = useState(false); const [status, setStatus] = useState({ kind: 'idle' }); const root = useRef(null); @@ -35,9 +59,9 @@ export function ExportMenu() { return () => removeEventListener('pointerdown', onDown); }, [open]); - const run = async (job: () => Promise) => { + const run = async (job: () => Promise, busy?: string) => { setOpen(false); - setStatus({ kind: 'busy' }); + setStatus({ kind: 'busy', text: busy }); try { setStatus({ kind: 'done', text: await job() }); } catch (e) { @@ -68,6 +92,23 @@ export function ExportMenu() { return `Copied PNG${scaled(2, scale)}`; }), }, + ...( + [ + ['JPEG', 'image/jpeg', 'jpg', formats.jpeg], + ['WebP', 'image/webp', 'webp', formats.webp], + ] as const + ) + .filter(([, , , ok]) => ok) + .map(([label, type, ext]) => ({ + label, + hint: '2×', + act: () => + run(async () => { + const { blob, scale } = await exportRaster(content, type); + download(blob, exportFileName(draft.title, ext)); + return `Saved ${label}${scaled(2, scale)}`; + }), + })), { label: 'SVG', hint: 'snapshot', @@ -77,6 +118,19 @@ export function ExportMenu() { return 'Saved SVG'; }), }, + ...(formats.video + ? [ + { + label: 'Video', + hint: `flow · ${videoExtension(formats.video)}`, + act: () => + run(async () => { + download(await exportVideo(content, flowEdges(scene), formats.video!), exportFileName(draft.title, videoExtension(formats.video!))); + return 'Saved video'; + }, 'Recording…'), + }, + ] + : []), ]; return ( @@ -101,7 +155,7 @@ export function ExportMenu() { className="sm-press flex h-9 items-center gap-2 rounded-full bg-primary px-4 text-[14px] font-medium text-primary-fg disabled:opacity-60" >