From 146d9acf6682bbb2ba7bac30545c783d9a2069c3 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Mon, 22 Jun 2026 21:34:04 +0000 Subject: [PATCH 1/2] feat(viewer): render SGCR annotations + layer toggle + addAnnotation patch op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the layered annotation engine (#197) into the viewer. A `flow` spec with `engine:"sgcr"` may carry `annotations: [{id, anchor, text, layer, …}]`; the viewer runs placeAnnotations against the measured layout and renders each placed annotation (box + dashed leader to its anchor) as a ViewportPortal overlay — pan/zoom-aware and decoupled from the node-state machinery, so it never disturbs the measured re-pass. Boxes are coloured by `layer` (palette + optional per-annotation override); a top-left panel toggles each layer's visibility (placement always runs over all layers, so toggling never shifts a box). Anchor a node, an edge (placed near its midpoint), or a point. Live/iterative: `addAnnotation` / `removeAnnotation` patch ops (flow-patch.ts) let an agent annotate a living diagram across turns; the placer defers cleanly when there's no room. Overlap-freedom (P10) is proven by the engine and re-checked live: a new e2e (`npm run test:annotations`) renders an annotated spec in a real browser and asserts no annotation box overlaps a node + the layer toggle hides a layer. Plus flow-patch unit tests for the new ops. --- packages/viewer/e2e/sgcr-annotations.e2e.mjs | 82 +++++++++++++++++++ packages/viewer/package.json | 1 + .../src/client/renderers/annotation-layer.tsx | 55 +++++++++++++ .../src/client/renderers/flow-layout.ts | 17 ++++ packages/viewer/src/client/renderers/flow.tsx | 42 ++++++++-- .../viewer/src/client/renderers/sgcr/types.ts | 2 + packages/viewer/src/flow-patch.ts | 24 +++++- packages/viewer/test/flow-patch.test.ts | 16 ++++ 8 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 packages/viewer/e2e/sgcr-annotations.e2e.mjs create mode 100644 packages/viewer/src/client/renderers/annotation-layer.tsx diff --git a/packages/viewer/e2e/sgcr-annotations.e2e.mjs b/packages/viewer/e2e/sgcr-annotations.e2e.mjs new file mode 100644 index 0000000..b57b796 --- /dev/null +++ b/packages/viewer/e2e/sgcr-annotations.e2e.mjs @@ -0,0 +1,82 @@ +// Rendered-geometry test for the SGCR annotation overlay (the viewer side of placeAnnotations). +// Spawns the real viewer, pushes an annotated engine:"sgcr" spec, and measures the live DOM: +// - annotations actually render (`.tc-annotation`), +// - no annotation box overlaps a node box (P10, in the real browser, not just the engine), +// - the layer-toggle panel hides a layer's annotations. +// +// node e2e/sgcr-annotations.e2e.mjs (needs `npm run build` first; same as the other *.e2e.mjs) +// Exit 0 = pass, 1 = fail. +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VIEWER = join(HERE, ".."); +const PORT = Number(process.env.ANN_PORT ?? 8197); +const TOKEN = "ann-token"; +const BASE = `http://127.0.0.1:${PORT}`; +const U = `${BASE}/w/ann/`; + +const E = (source, target, label) => (label ? { source, target, label } : { source, target }); +const spec = { + engine: "sgcr", direction: "TB", + nodes: ["placed", "paid", "packed", "shipped", "delivered", "cancelled"].map((id) => ({ id, type: "change", data: { label: id } })), + edges: [E("placed", "paid"), E("paid", "packed"), E("packed", "shipped"), E("shipped", "delivered"), E("placed", "cancelled"), E("paid", "cancelled"), E("cancelled", "placed")], + annotations: [ + { id: "r1", anchor: { node: "cancelled" }, text: "SLA risk", layer: "risk" }, + { id: "r2", anchor: { node: "shipped" }, text: "3d", layer: "risk" }, + { id: "m1", anchor: { node: "paid" }, text: "@payments", layer: "owner" }, + { id: "m2", anchor: { node: "packed" }, text: "@fulfil", layer: "owner" }, + { id: "l1", anchor: { edge: "" }, text: "120ms", layer: "latency" }, // edge id resolved below + ], +}; + +const MEASURE = `(() => { + const rect=(el)=>{const r=el.getBoundingClientRect();return{x:r.left,y:r.top,r:r.right,b:r.bottom};}; + const nodes=[...document.querySelectorAll("#diagram .react-flow__node")].map(rect); + const anns=[...document.querySelectorAll("#diagram .tc-annotation")].map(a=>({id:a.getAttribute("data-ann-id"),layer:a.getAttribute("data-ann-layer"),...rect(a)})); + const ov=(a,b,m)=>a.xb.x+m&&a.yb.y+m; + const overlaps=[]; for(const an of anns)for(const n of nodes) if(ov(an,n,2)) overlaps.push(an.id); + return {nodes:nodes.length, anns:anns.length, overlaps, layers:[...new Set(anns.map(a=>a.layer))].sort()}; +})()`; + +const push = (a, c) => fetch(`${U}push`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, body: JSON.stringify({ project: "p", agent: a, type: "flow", content: JSON.stringify(c), description: a }) }); +async function waitUp() { for (let i = 0; i < 60; i++) { try { if ((await fetch(`${BASE}/healthz`)).ok) return; } catch { /* */ } await new Promise((r) => setTimeout(r, 200)); } throw new Error("server did not start"); } + +const srv = spawn("node", [join(VIEWER, "dist", "server.js")], { env: { ...process.env, PORT: String(PORT), PUSH_TOKEN: TOKEN }, stdio: ["ignore", "ignore", "inherit"] }); +let browser; const fails = []; +try { + await waitUp(); + // resolve a real edge id for the latency annotation (engine assigns ids; use source>target form via push of the spec, then anchor to the first edge by giving it an explicit id) + spec.edges[0] = { id: "e_pp", ...spec.edges[0] }; + spec.annotations[4].anchor = { edge: "e_pp" }; + if (!(await push("ann", spec)).ok) throw new Error("push failed"); + browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] }); + const p = await browser.newPage({ viewport: { width: 1200, height: 860 } }); + await p.goto(U, { waitUntil: "domcontentloaded", timeout: 20000 }); + await p.waitForFunction(() => document.querySelector("#status .live-dot"), null, { timeout: 12000 }); + await p.evaluate(() => { location.hash = "#p/ann"; }); + await p.waitForFunction(() => document.querySelector("#diagram .react-flow__node"), null, { timeout: 12000 }); + await p.waitForFunction(() => document.querySelector("#diagram .tc-annotation"), null, { timeout: 8000 }); + await p.waitForTimeout(1800); + + const m = await p.evaluate(MEASURE); + console.log(`rendered: ${m.nodes} nodes, ${m.anns} annotations, layers=[${m.layers}]`); + if (m.anns < 4) fails.push(`expected ≥4 annotations, got ${m.anns}`); + if (m.overlaps.length) fails.push(`P10 (live): annotations overlap nodes → ${m.overlaps.join(",")}`); + if (!m.layers.includes("risk") || !m.layers.includes("owner")) fails.push(`missing expected layers: got [${m.layers}]`); + + // layer toggle: hide "risk", expect its annotations to disappear + const before = m.anns; + await p.locator('#diagram .react-flow__panel button', { hasText: "risk" }).click({ timeout: 4000 }).catch(() => fails.push("could not click risk toggle")); + await p.waitForTimeout(400); + const after = await p.evaluate(MEASURE); + if (!(after.anns < before) || after.layers.includes("risk")) fails.push(`layer toggle did not hide "risk" (before=${before} after=${after.anns} layers=[${after.layers}])`); + else console.log(`layer toggle: hid "risk" → ${before} → ${after.anns} annotations`); + + await p.close(); +} catch (e) { console.error("ERROR", e); fails.push("harness: " + e.message); } +finally { if (browser) await browser.close(); srv.kill("SIGTERM"); } +console.log(fails.length ? `\nFAIL:\n ${fails.join("\n ")}` : "\n=== annotations e2e: PASS ==="); +process.exit(fails.length ? 1 : 0); diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 1c85cca..94438a8 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -30,6 +30,7 @@ "test:overlap": "npm run build && node e2e/overlap.e2e.mjs", "test:overlap:nobuild": "node e2e/overlap.e2e.mjs", "sgcr:stress": "node e2e/sgcr-build.mjs && node e2e/sgcr-stress.mjs", + "test:annotations": "npm run build && node e2e/sgcr-annotations.e2e.mjs", "test:stress": "npm run build && node e2e/stress.e2e.mjs" }, "dependencies": {}, diff --git a/packages/viewer/src/client/renderers/annotation-layer.tsx b/packages/viewer/src/client/renderers/annotation-layer.tsx new file mode 100644 index 0000000..ae7a363 --- /dev/null +++ b/packages/viewer/src/client/renderers/annotation-layer.tsx @@ -0,0 +1,55 @@ +// Overlay layer for SGCR annotations. Renders each PLACED annotation (box + dashed leader line to its +// anchor) inside React Flow's , so the whole layer pans and zooms with the graph +// without being part of the node/edge state machinery (it's an overlay, not graph elements). Placement +// — and the proof that nothing overlaps (P10) — already happened in placeAnnotations(); this is pure +// presentation. Boxes are coloured by `layer` (a default palette) with an optional per-annotation +// `color` override. + +import { ViewportPortal } from "@xyflow/react"; +import type { PlacedAnnotation } from "./sgcr/types.js"; + +const LAYER_PALETTE: Record = { + risk: "#dc2626", error: "#dc2626", latency: "#2563eb", metric: "#d97706", + note: "#16a34a", owner: "#7c3aed", info: "#0891b2", default: "#475569", +}; + +export function annotationColor(a: { layer?: string; color?: string }): string { + return a.color || LAYER_PALETTE[a.layer ?? "default"] || LAYER_PALETTE.default; +} + +/** Estimate an annotation box from its text when the spec doesn't give explicit width/height. */ +export function estAnnotationSize(text: string | undefined): { width: number; height: number } { + const lines = (text ?? "").split("\n"); + const cols = Math.max(1, ...lines.map((l) => l.length)); + return { width: Math.max(28, Math.min(220, Math.round(cols * 6.6 + 16))), height: Math.max(20, 8 + lines.length * 15) }; +} + +export function AnnotationLayer({ placed }: { placed: PlacedAnnotation[] }) { + if (!placed.length) return null; + return ( + + {placed.map((a) => { + const c = annotationColor(a); + const bg = /^#[0-9a-fA-F]{6}$/.test(c) ? c + "22" : c; // hex → 13% alpha tint, else solid + // leader endpoints relative to the box origin (a.x, a.y); overflow:visible lets it extend out + const fx = a.leader.from.x - a.x, fy = a.leader.from.y - a.y; + const tx = a.leader.to.x - a.x, ty = a.leader.to.y - a.y; + return ( +
+ + + +
+ {a.text ?? a.id} +
+
+ ); + })} +
+ ); +} diff --git a/packages/viewer/src/client/renderers/flow-layout.ts b/packages/viewer/src/client/renderers/flow-layout.ts index b48a2c0..5a6c9b0 100644 --- a/packages/viewer/src/client/renderers/flow-layout.ts +++ b/packages/viewer/src/client/renderers/flow-layout.ts @@ -55,6 +55,23 @@ export interface FlowSpec { * node/edge overlap, edge-over-node, label spill and arrowhead stacking are impossible by * construction (see renderers/sgcr/ + the design spec). Opt-in; ungrouped graphs only. */ engine?: "dagre" | "sgcr"; + /** Optional overlay annotations (callouts/badges/notes), placed in the layout's free space by the + * SGCR annotation engine — overlap-free (P10), on toggleable layers. Engine:"sgcr" only today. */ + annotations?: FlowAnnotation[]; +} + +/** Authoring shape for an overlay annotation. width/height are optional (estimated from `text`). + * anchor points at a node id, an edge id (placed near its midpoint), or an absolute point. */ +export interface FlowAnnotation { + id: string; + anchor: { node: string } | { edge: string } | { at: { x: number; y: number } }; + text?: string; + width?: number; + height?: number; + layer?: string; + priority?: number; + color?: string; + prefer?: ("N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW")[]; } const NODE_W = 172; diff --git a/packages/viewer/src/client/renderers/flow.tsx b/packages/viewer/src/client/renderers/flow.tsx index 823f2c6..2304694 100644 --- a/packages/viewer/src/client/renderers/flow.tsx +++ b/packages/viewer/src/client/renderers/flow.tsx @@ -1,5 +1,5 @@ import type { Mount } from "./types.js"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, @@ -22,8 +22,10 @@ import { bestLayout } from "../../flow-geometry.js"; import { LifelineNode, PointNode, EntityNode, ChangeNode, GroupNode, dirHandles } from "./flow-nodes.js"; import { layoutSGCR } from "./sgcr/layout.js"; import { checkInvariants } from "./sgcr/check.js"; -import type { SGCRInput, Direction } from "./sgcr/types.js"; +import { placeAnnotations } from "./sgcr/annotate.js"; +import type { SGCRInput, Direction, Annotation, PlacedAnnotation } from "./sgcr/types.js"; import { sgcrEdgeTypes } from "./sgcr-edge.js"; +import { AnnotationLayer, estAnnotationSize, annotationColor } from "./annotation-layer.js"; import { mountReact } from "./react-root.js"; import { injectStyle } from "./inject-style.js"; import { errorBlock } from "./errors.js"; @@ -297,11 +299,22 @@ function SgcrFlowInner({ spec }: { spec: FlowSpec }) { [dir, hp.source, hp.target], ); - const initial = useMemo(() => toRf(build(), spec.nodes ?? [], false), [build, toRf, spec.nodes]); + // Overlay annotations: authoring shape → engine Annotation[] (estimate box size from text when the + // spec gives none). Placed against the laid-out graph by placeAnnotations (overlap-free, P10). + const specAnns = useMemo(() => (spec.annotations ?? []).map((a) => { + const sz = typeof a.width === "number" && typeof a.height === "number" ? { width: a.width, height: a.height } : estAnnotationSize(a.text); + return { id: a.id, anchor: a.anchor, width: sz.width, height: sz.height, layer: a.layer, priority: a.priority, prefer: a.prefer, text: a.text, color: a.color }; + }), [spec.annotations]); + const initialLay = useMemo(() => build(), [build]); + const initial = useMemo(() => toRf(initialLay, spec.nodes ?? [], false), [initialLay, toRf, spec.nodes]); // onNodesChange MUST be wired to or RF's dimension measurements never reach `nodes`, // so `n.measured` stays undefined and the measured re-pass silently uses estSize (off-node edges). const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes); const [edges, setEdges] = useEdgesState(initial.edges); + // Placed against estSize first; the measured re-pass re-places against the real layout. Layer + // visibility is toggleable; placement always runs over ALL layers so toggling never shifts a box. + const [placed, setPlaced] = useState(() => placeAnnotations(initialLay, specAnns).placed); + const [hiddenLayers, setHiddenLayers] = useState>(() => new Set()); const inited = useNodesInitialized(); const { fitView } = useReactFlow(); const laidSig = useRef(""); // signature of the sizes the current layout was built from @@ -327,14 +340,19 @@ function SgcrFlowInner({ spec }: { spec: FlowSpec }) { if (sig === laidSig.current || passes.current >= MAX_PASSES) return; // sizes already match what we laid out laidSig.current = sig; passes.current += 1; - const re = toRf(build(measured), spec.nodes ?? [], true); + const lay = build(measured); + const re = toRf(lay, spec.nodes ?? [], true); setNodes(re.nodes); setEdges(re.edges); + setPlaced(placeAnnotations(lay, specAnns).placed); // re-place against the measured layout requestAnimationFrame(() => fitView({ duration: 0, maxZoom: 1.5, padding: 0.12, minZoom: floor })); - }, [inited, nodes, build, toRf, setNodes, setEdges, fitView, floor, spec.nodes]); + }, [inited, nodes, build, toRf, setNodes, setEdges, fitView, floor, spec.nodes, specAnns]); const inlineHeight = typeof spec.height === "number" ? `${spec.height}px` : undefined; const legend = Array.isArray(spec.legend) && spec.legend.length ? spec.legend : null; + const layers = useMemo(() => [...new Set(placed.map((a) => a.layer ?? "default"))].sort(), [placed]); + const visibleAnns = useMemo(() => placed.filter((a) => !hiddenLayers.has(a.layer ?? "default")), [placed, hiddenLayers]); + const toggleLayer = useCallback((l: string) => setHiddenLayers((h) => { const n = new Set(h); if (n.has(l)) n.delete(l); else n.add(l); return n; }), []); return (
+ {legend && } + {layers.length > 1 && ( + +
+ {layers.map((l) => ( + + ))} +
+
+ )}
); diff --git a/packages/viewer/src/client/renderers/sgcr/types.ts b/packages/viewer/src/client/renderers/sgcr/types.ts index 3e1a678..b5691a0 100644 --- a/packages/viewer/src/client/renderers/sgcr/types.ts +++ b/packages/viewer/src/client/renderers/sgcr/types.ts @@ -81,6 +81,8 @@ export interface Annotation { prefer?: Side[]; /** optional display content. */ text?: string; + /** optional explicit color (else derived from `layer` by the renderer's palette). */ + color?: string; } /** An annotation after placement: top-left (x,y), the compass slot used, and a leader to its anchor. */ diff --git a/packages/viewer/src/flow-patch.ts b/packages/viewer/src/flow-patch.ts index f912143..bec00eb 100644 --- a/packages/viewer/src/flow-patch.ts +++ b/packages/viewer/src/flow-patch.ts @@ -10,11 +10,14 @@ * { op: "removeNode", id } remove a node and any edges touching it * { op: "addEdge", edge } append an edge ({id?, source, target, ...}) * { op: "removeEdge", id } remove an edge by id + * { op: "addAnnotation", annotation } append an overlay annotation ({id, anchor, text?, layer?, ...}) + * { op: "removeAnnotation", id } remove an annotation by id */ interface FlowNode { id: string; data?: Record; [k: string]: unknown } interface FlowEdge { id?: string; source?: string; target?: string; [k: string]: unknown } -interface FlowSpec { nodes?: FlowNode[]; edges?: FlowEdge[]; [k: string]: unknown } -export interface PatchOp { op: string; id?: string; data?: Record; node?: FlowNode; edge?: FlowEdge } +interface FlowAnnotation { id: string; anchor?: unknown; [k: string]: unknown } +interface FlowSpec { nodes?: FlowNode[]; edges?: FlowEdge[]; annotations?: FlowAnnotation[]; [k: string]: unknown } +export interface PatchOp { op: string; id?: string; data?: Record; node?: FlowNode; edge?: FlowEdge; annotation?: FlowAnnotation } /** * The viewer's in-place fast path. Given the live (already-laid-out) nodes and a patch op set, @@ -54,6 +57,7 @@ export function applyFlowPatch(content: string, ops: PatchOp[]): string { if (typeof spec !== "object" || spec === null) throw new Error("stored content is not a flow object"); const nodes: FlowNode[] = Array.isArray(spec.nodes) ? spec.nodes : []; let edges: FlowEdge[] = Array.isArray(spec.edges) ? spec.edges : []; + let annotations: FlowAnnotation[] = Array.isArray(spec.annotations) ? spec.annotations : []; for (const op of ops) { switch (op?.op) { @@ -93,9 +97,23 @@ export function applyFlowPatch(content: string, ops: PatchOp[]): string { if (edges.length === before) throw new Error(`removeEdge: no edge "${op.id}"`); break; } + case "addAnnotation": { + if (!op.annotation || typeof op.annotation.id !== "string" || !op.annotation.id) throw new Error("addAnnotation needs annotation.id"); + if (!op.annotation.anchor || typeof op.annotation.anchor !== "object") throw new Error("addAnnotation needs annotation.anchor"); + if (annotations.some((x) => x.id === op.annotation!.id)) throw new Error(`addAnnotation: annotation "${op.annotation.id}" already exists`); + annotations.push(op.annotation); + break; + } + case "removeAnnotation": { + if (!op.id) throw new Error("removeAnnotation needs { id }"); + const before = annotations.length; + annotations = annotations.filter((a) => a.id !== op.id); + if (annotations.length === before) throw new Error(`removeAnnotation: no annotation "${op.id}"`); + break; + } default: throw new Error(`unknown patch op "${op?.op}"`); } } - return JSON.stringify({ ...spec, nodes, edges }); + return JSON.stringify({ ...spec, nodes, edges, annotations }); } diff --git a/packages/viewer/test/flow-patch.test.ts b/packages/viewer/test/flow-patch.test.ts index 974361d..48b3c82 100644 --- a/packages/viewer/test/flow-patch.test.ts +++ b/packages/viewer/test/flow-patch.test.ts @@ -47,6 +47,22 @@ describe("applyFlowPatch", () => { expect(() => applyFlowPatch(base, [{ op: "addNode", node: { id: "a", data: {} } }])).toThrow(/already exists/); expect(() => applyFlowPatch(base, [{ op: "addEdge", edge: { source: "a", target: "zzz" } }])).toThrow(/no target node/); }); + + it("adds and removes annotations", () => { + const ann = { id: "n1", anchor: { node: "a" }, text: "note", layer: "risk" }; + const added = JSON.parse(applyFlowPatch(base, [{ op: "addAnnotation", annotation: ann }])) as { annotations: { id: string }[] }; + expect(added.annotations).toEqual([ann]); + const removed = JSON.parse(applyFlowPatch(JSON.stringify(added), [{ op: "removeAnnotation", id: "n1" }])) as { annotations: unknown[] }; + expect(removed.annotations).toEqual([]); + }); + + it("rejects bad annotation ops", () => { + expect(() => applyFlowPatch(base, [{ op: "addAnnotation", annotation: { id: "", anchor: { node: "a" } } }])).toThrow(/annotation\.id/); + expect(() => applyFlowPatch(base, [{ op: "addAnnotation", annotation: { id: "x" } as never }])).toThrow(/anchor/); + expect(() => applyFlowPatch(base, [{ op: "removeAnnotation", id: "nope" }])).toThrow(/no annotation/); + const withAnn = applyFlowPatch(base, [{ op: "addAnnotation", annotation: { id: "x", anchor: { node: "a" } } }]); + expect(() => applyFlowPatch(withAnn, [{ op: "addAnnotation", annotation: { id: "x", anchor: { node: "b" } } }])).toThrow(/already exists/); + }); }); // The viewer's in-place fast path: apply ONLY setNodeData ops to live React Flow nodes (keeping From 4411d504b1d2083f84f1d7e682652aa7f9a58fb6 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Mon, 22 Jun 2026 21:38:36 +0000 Subject: [PATCH 2/2] fix(viewer): frame annotations in fitView (union bounds, not nodes-only) --- packages/viewer/src/client/renderers/flow.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/viewer/src/client/renderers/flow.tsx b/packages/viewer/src/client/renderers/flow.tsx index 2304694..0f74f65 100644 --- a/packages/viewer/src/client/renderers/flow.tsx +++ b/packages/viewer/src/client/renderers/flow.tsx @@ -240,6 +240,17 @@ function FlowInner({ spec, handle }: { spec: FlowSpec; handle?: PatchHandle }) { * re-lay-out with MEASURED node sizes so the reserved cells match the rendered boxes and the * by-construction invariants hold in the real DOM. Ungrouped graphs only. */ +/** Bounding box (flow coords) enclosing every node box AND every placed annotation box. */ +function unionBounds(nodes: { x: number; y: number; width: number; height: number }[], anns: PlacedAnnotation[]): { x: number; y: number; width: number; height: number } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const b of [...nodes, ...anns]) { + minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.width); maxY = Math.max(maxY, b.y + b.height); + } + if (!isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 }; + return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }; +} + function SgcrFlowInner({ spec }: { spec: FlowSpec }) { const dir = (spec.direction ?? "TB") as Direction; const hp = dirHandles(dir); @@ -316,7 +327,7 @@ function SgcrFlowInner({ spec }: { spec: FlowSpec }) { const [placed, setPlaced] = useState(() => placeAnnotations(initialLay, specAnns).placed); const [hiddenLayers, setHiddenLayers] = useState>(() => new Set()); const inited = useNodesInitialized(); - const { fitView } = useReactFlow(); + const { fitView, fitBounds } = useReactFlow(); const laidSig = useRef(""); // signature of the sizes the current layout was built from const passes = useRef(0); const wrapRef = useRef(null); @@ -342,11 +353,17 @@ function SgcrFlowInner({ spec }: { spec: FlowSpec }) { passes.current += 1; const lay = build(measured); const re = toRf(lay, spec.nodes ?? [], true); + const pa = placeAnnotations(lay, specAnns).placed; setNodes(re.nodes); setEdges(re.edges); - setPlaced(placeAnnotations(lay, specAnns).placed); // re-place against the measured layout - requestAnimationFrame(() => fitView({ duration: 0, maxZoom: 1.5, padding: 0.12, minZoom: floor })); - }, [inited, nodes, build, toRf, setNodes, setEdges, fitView, floor, spec.nodes, specAnns]); + setPlaced(pa); // re-place against the measured layout + requestAnimationFrame(() => { + // Annotations placed beyond the node bounding box (e.g. above the root) must be framed too, or + // fitView (nodes-only) clips them. Fit the UNION of node + annotation bounds when any exist. + if (pa.length) fitBounds(unionBounds(lay.nodes, pa), { padding: 0.14, duration: 0 }); + else fitView({ duration: 0, maxZoom: 1.5, padding: 0.12, minZoom: floor }); + }); + }, [inited, nodes, build, toRf, setNodes, setEdges, fitView, fitBounds, floor, spec.nodes, specAnns]); const inlineHeight = typeof spec.height === "number" ? `${spec.height}px` : undefined; const legend = Array.isArray(spec.legend) && spec.legend.length ? spec.legend : null;