From b97ef72850465816226e2e55167bfe1b5e523985 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 00:40:58 +0800 Subject: [PATCH 1/9] feat(viewer): route between two nodes, radar minimap and presentation mode --- packages/viewer/e2e/present.spec.ts | 59 +++++++++++++++ packages/viewer/src/canvas/DiagramCanvas.tsx | 62 +++++++++++++--- packages/viewer/src/canvas/Minimap.tsx | 20 ++++- packages/viewer/src/canvas/ViewportContext.ts | 10 +++ packages/viewer/src/chrome/Inspector.tsx | 74 ++++++++++++++++++- packages/viewer/src/chrome/Presentation.tsx | 58 +++++++++++++++ packages/viewer/src/chrome/Toolbar.tsx | 26 ++++++- packages/viewer/src/chrome/ViewerShell.tsx | 47 +++++++++--- packages/viewer/src/chrome/ZoomBar.tsx | 2 +- packages/viewer/src/explore/emphasis.ts | 18 +++-- packages/viewer/src/explore/graph.test.ts | 28 ++++++- packages/viewer/src/explore/graph.ts | 65 ++++++++++++++++ packages/viewer/src/explore/state.test.ts | 21 ++++++ packages/viewer/src/explore/state.ts | 21 +++++- 14 files changed, 475 insertions(+), 36 deletions(-) create mode 100644 packages/viewer/e2e/present.spec.ts create mode 100644 packages/viewer/src/chrome/Presentation.tsx diff --git a/packages/viewer/e2e/present.spec.ts b/packages/viewer/e2e/present.spec.ts new file mode 100644 index 0000000..fbd1b76 --- /dev/null +++ b/packages/viewer/e2e/present.spec.ts @@ -0,0 +1,59 @@ +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(); +}); diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 1c0705f..50fea29 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -3,6 +3,7 @@ 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 { CanvasPanel } from './CanvasPanel'; import { Minimap } from './Minimap'; import { toScene, type Scene } from './scene'; @@ -10,7 +11,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 +25,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 +37,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 +88,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 +100,36 @@ 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. + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key !== 'm' && e.key !== 'M') return; + const t = e.target as HTMLElement | null; + if (e.metaKey || e.ctrlKey || e.altKey || (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 +153,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..5432449 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 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,8 @@ 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 }) { +/** 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 }; @@ -75,13 +78,26 @@ export function Minimap({ scene }: { scene: Scene }) { style={{ fill: 'none', stroke: 'var(--sm-group-border)', strokeWidth: 1, strokeDasharray: '3 2' }} /> ))} + {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) => ( ))} {/* --sm-text instead of a fixed translucent black: the React Flow mask vanished on dark panels. */} diff --git a/packages/viewer/src/canvas/ViewportContext.ts b/packages/viewer/src/canvas/ViewportContext.ts index 02ff58a..c6d28d7 100644 --- a/packages/viewer/src/canvas/ViewportContext.ts +++ b/packages/viewer/src/canvas/ViewportContext.ts @@ -1,5 +1,6 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@stackmap/core'; +import type { Scene } from './scene'; import type { Camera, ViewportApi } from './useZoom'; const ViewportContext = createContext(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/Inspector.tsx b/packages/viewer/src/chrome/Inspector.tsx index b536087..8c3cb2c 100644 --- a/packages/viewer/src/chrome/Inspector.tsx +++ b/packages/viewer/src/chrome/Inspector.tsx @@ -2,6 +2,7 @@ import { ArrowDownLeft, ArrowUpRight, Copy, PanelRightClose, PanelRightOpen, X } import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react'; import { countByType, TYPE_LABELS, type DiagramDraft, type DiagramEdge, type DiagramNode, type Evidence } from '@stackmap/core'; import { focusCard } from '../canvas/SceneLayers'; +import { routeBetween } from '../explore/graph'; import { useExplore } from '../explore/ExploreContext'; import { swapIn } from '../motion/motion'; import { IconButton, PANEL_STYLE } from './ui'; @@ -219,6 +220,75 @@ function NodeDetail({ node, toggle }: { node: DiagramNode; toggle: ReactNode }) ); } +/** The route between two picked nodes: its steps along one shortest path, or why there is none. */ +function RouteDetail({ toggle }: { toggle: ReactNode }) { + const { draft, state, graph, dispatch } = useExplore(); + const byId = new Map(draft.nodes.map((n) => [n.id, n])); + const edgeById = new Map(draft.edges.map((e) => [e.id, e])); + const title = (id: string) => byId.get(id)?.card.title ?? id; + const header = ( +
+
Route
+
+ dispatch({ type: 'toggleRoute' })}> + + + {toggle} +
+
+ ); + if (!state.route) { + return ( + <> + {header} +

+ {state.routing?.next === 'to' ? `From ${title(state.routing.start)}: pick where the route ends.` : 'Pick the node the route starts from.'} +

+ + ); + } + const route = routeBetween(graph, state.route.from, state.route.to); + return ( + <> + {header} +

+ {title(state.route.from)} → {title(state.route.to)} +

+ {!route ? ( +

No directed connections lead from one to the other.

+ ) : ( + <> +

+ {route.steps.length} {route.steps.length === 1 ? 'hop' : 'hops'} + {route.reversed ? ', running the other way' : ''} · {route.nodes.size} nodes on some path +

+
+
    + {[route.from, ...route.steps.map((e) => edgeById.get(e)!.to)].map((id, i) => { + const via = i ? edgeById.get(route.steps[i - 1]!) : undefined; + const node = byId.get(id)!; + return ( +
  1. + +
  2. + ); + })} +
+
+ + )} + + ); +} + function Toggle({ collapsed, onToggle, ref }: { collapsed: boolean; onToggle: () => void; ref: Ref }) { return ( @@ -266,7 +336,9 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl return (