Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/viewer/e2e/sgcr-annotations.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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.x<b.r-m&&a.r>b.x+m&&a.y<b.b-m&&a.b>b.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);
1 change: 1 addition & 0 deletions packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
55 changes: 55 additions & 0 deletions packages/viewer/src/client/renderers/annotation-layer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Overlay layer for SGCR annotations. Renders each PLACED annotation (box + dashed leader line to its
// anchor) inside React Flow's <ViewportPortal>, 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<string, string> = {
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 (
<ViewportPortal>
{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 (
<div key={a.id} className="tc-annotation" data-ann-id={a.id} data-ann-layer={a.layer ?? "default"}
style={{ position: "absolute", transform: `translate(${a.x}px, ${a.y}px)`, width: a.width, height: a.height, pointerEvents: "none" }}>
<svg style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, overflow: "visible" }}>
<line x1={fx} y1={fy} x2={tx} y2={ty} stroke={c} strokeWidth={1} strokeDasharray="2 2" opacity={0.85} />
</svg>
<div style={{
width: "100%", height: "100%", boxSizing: "border-box", display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: 4, border: `1px solid ${c}`, background: bg, color: c, fontSize: 10, fontWeight: 600, lineHeight: 1.15,
padding: "1px 4px", textAlign: "center", whiteSpace: "pre", overflow: "hidden",
}}>
{a.text ?? a.id}
</div>
</div>
);
})}
</ViewportPortal>
);
}
17 changes: 17 additions & 0 deletions packages/viewer/src/client/renderers/flow-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 56 additions & 7 deletions packages/viewer/src/client/renderers/flow.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -238,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);
Expand Down Expand Up @@ -297,13 +310,24 @@ 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<Annotation[]>(() => (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 <ReactFlow> 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<PlacedAnnotation[]>(() => placeAnnotations(initialLay, specAnns).placed);
const [hiddenLayers, setHiddenLayers] = useState<Set<string>>(() => 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<HTMLDivElement>(null);
Expand All @@ -327,14 +351,25 @@ 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);
const pa = placeAnnotations(lay, specAnns).placed;
setNodes(re.nodes);
setEdges(re.edges);
requestAnimationFrame(() => fitView({ duration: 0, maxZoom: 1.5, padding: 0.12, minZoom: floor }));
}, [inited, nodes, build, toRf, setNodes, setEdges, fitView, floor, spec.nodes]);
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;
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 (
<div ref={wrapRef} className="tc-flow-wrap" style={{ width: "100%", ...(inlineHeight ? { height: inlineHeight } : {}), minHeight: typeof spec.height === "number" ? spec.height : 360 }}>
<ReactFlow
Expand All @@ -345,7 +380,21 @@ function SgcrFlowInner({ spec }: { spec: FlowSpec }) {
>
<Background />
<Controls showInteractive={false} />
<AnnotationLayer placed={visibleAnns} />
{legend && <Panel position="top-right"><Legend items={legend} /></Panel>}
{layers.length > 1 && (
<Panel position="top-left">
<div className="tc-legend" role="group" aria-label="Annotation layers">
{layers.map((l) => (
<button key={l} type="button" onClick={() => toggleLayer(l)} title={`Toggle ${l} layer`}
style={{ display: "flex", alignItems: "center", gap: 5, background: "none", border: "none", cursor: "pointer", padding: "1px 2px", opacity: hiddenLayers.has(l) ? 0.4 : 1, font: "inherit" }}>
<span className="tc-legend-swatch" style={{ background: annotationColor({ layer: l }), borderColor: annotationColor({ layer: l }) }} />
<span style={{ textDecoration: hiddenLayers.has(l) ? "line-through" : "none" }}>{l}</span>
</button>
))}
</div>
</Panel>
)}
</ReactFlow>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/viewer/src/client/renderers/sgcr/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading