Skip to content
Draft
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
1 change: 0 additions & 1 deletion packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"isomorphic-dompurify": "^3.19.0"
},
"devDependencies": {
"@dagrejs/dagre": "^3.0.0",
"@ivanmkc/termchart-core": "*",
"@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0",
Expand Down
100 changes: 23 additions & 77 deletions packages/viewer/src/client/renderers/flow-layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dagre from "@dagrejs/dagre";
import { layoutSGCR } from "./sgcr/layout.js";
import type { SGCRInput, Direction } from "./sgcr/types.js";

export interface FlowNode {
id: string;
Expand Down Expand Up @@ -55,10 +56,7 @@ export interface FlowSpec {
* and dagre lays the whole graph out. "manual": honor explicit positions (hand-placed diagrams
* like the sequence recipe's lifelines); only nodes missing a position are dagre-placed. */
layout?: "auto" | "manual";
/** Layout engine. "dagre" (default): the established force-directed-ish layered layout + smoothstep
* edges. "sgcr": the provable-by-construction Slotted Grid + Orthogonal Channel Routing engine —
* 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. */
/** Layout engine. SGCR is the only engine. "dagre" is accepted for backward compat but silently ignored. */
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. */
Expand Down Expand Up @@ -182,9 +180,10 @@ export function estSize(n: FlowNode): { width: number; height: number } {
// An inline sparkline (data.spark) adds a row of trend under the label and needs a little
// width to read; reserve ~24px height and a 150px floor so dagre doesn't pack nodes tight.
const hasSpark = Array.isArray(d.spark) && (d.spark as unknown[]).length >= 2;
// Phase 5: lowered from 150 to 120. A 5-char label ("Redis") at 7.6px/ch + 40px padding = 78px;
// the old 150px floor added 72px of empty space. 120px still gives comfortable padding.
let minW = 120;
// ChangeNode's CSS minWidth is 140px, but the card stretches to fill its SGCR cell (width:100%
// on TB/BT). The cell is at least nodeGap-padded, so the rendered card is typically ~172px for
// short labels. Match this so the estimated layout doesn't under-space and cause overlaps.
let minW = 172;
if (hasSpark) minW = Math.max(minW, 174);
let height = d.sub ? 64 : 46;
if (hasSpark) height += 24;
Expand All @@ -196,58 +195,23 @@ function withEdgeIds(edges: FlowEdge[]): FlowEdge[] {
return edges.map((e, i) => (e.id ? e : { ...e, id: `e${i}-${e.source}-${e.target}` }));
}

/**
* Run dagre over sized nodes and return each node's top-left position (keyed by id).
* Edges with an endpoint not in `nodes` are ignored (a dangling endpoint would make
* dagre synthesize a phantom node and distort the layout). Pure (no DOM).
*/
/** Dagre tuning knobs, overridable per call. A geometry experiment swept these across the recipe
* corpus and found spacing (nodesep/ranksep/edgesep) and ranker do NOT change edge crossings /
* over-node / near-node — those are topological (rank ordering), not spacing-driven. So the
* defaults below are the established readable values; the override hook stays for experiments and
* for the lint to evaluate alternative layouts. The real levers for crossings are flow DIRECTION
* and graph structure (grouping, fewer long-range edges) — see flow-geometry's recommendations. */
export interface DagreOpts {
nodesep?: number;
ranksep?: number;
edgesep?: number;
ranker?: "network-simplex" | "tight-tree" | "longest-path";
}
// Phase 5: tighten defaults. The old 70/110 spacing was tuned before SGCR handled the intra-zone
// layout (Phase 3). Now that zones use SGCR internally, the remaining dagre pass is only the
// inter-zone super-node packing (2-6 zones) + ungrouped fallbacks. Tighter defaults give ~30%
// better space utilization. The bestLayout search can still try even tighter (40/70) variants.
export const DAGRE_DEFAULTS: Required<DagreOpts> = {
nodesep: 70,
ranksep: 110,
edgesep: 28,
ranker: "network-simplex",
};

export function dagreLayout(
function sgcrPositions(
nodes: { id: string; width?: number; height?: number }[],
edges: { source: string; target: string }[],
direction: string = "TB",
opts: DagreOpts = {},
): Record<string, { x: number; y: number }> {
const g = new dagre.graphlib.Graph();
const { nodesep, ranksep, edgesep, ranker } = { ...DAGRE_DEFAULTS, ...opts };
// ranksep/edgesep give orthogonal edges room to route between ranks instead of crossing
// over nodes; dagre reserves edge corridors via dummy nodes when there's space. ranker picks
// the rank-assignment algorithm (network-simplex gives the most compact, fewest-crossing ranks).
g.setGraph({ rankdir: direction, nodesep, ranksep, edgesep, ranker, marginx: 8, marginy: 8 });
g.setDefaultEdgeLabel(() => ({}));
if (nodes.length === 0) return {};
const ids = new Set(nodes.map((n) => n.id));
for (const n of nodes) g.setNode(n.id, { width: n.width ?? NODE_W, height: n.height ?? NODE_H });
for (const e of edges) {
if (ids.has(e.source) && ids.has(e.target)) g.setEdge(e.source, e.target);
}
dagre.layout(g);
const input: SGCRInput = {
direction: (direction || "TB") as Direction,
nodes: nodes.map((n) => ({ id: n.id, width: n.width ?? 172, height: n.height ?? 44 })),
edges: edges
.filter((e) => ids.has(e.source) && ids.has(e.target))
.map((e, i) => ({ id: `e${i}-${e.source}-${e.target}`, source: e.source, target: e.target })),
};
const lay = layoutSGCR(input);
const out: Record<string, { x: number; y: number }> = {};
for (const n of nodes) {
const p = g.node(n.id);
out[n.id] = { x: p.x - (n.width ?? NODE_W) / 2, y: p.y - (n.height ?? NODE_H) / 2 };
}
for (const n of lay.nodes) out[n.id] = { x: n.x, y: n.y };
return out;
}

Expand All @@ -261,9 +225,6 @@ export function dagreLayout(
* the inter-zone packing (the super-node dagre pass) stays unchanged. SGCR's output positions
* are already normalized to (0, 0) via its own margin, so we just translate to min = 0.
*/
import { layoutSGCR } from "./sgcr/layout.js";
import type { SGCRInput, Direction } from "./sgcr/types.js";

function layoutCluster(
ms: FlowNode[],
edges: FlowEdge[],
Expand Down Expand Up @@ -336,7 +297,7 @@ function layoutGroupedFlow(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[])
const s = metaId(e.source), t = metaId(e.target);
if (s !== t && !seen.has(`${s} ${t}`)) { seen.add(`${s} ${t}`); metaEdges.push({ source: s, target: t }); }
}
const metaPos = dagreLayout(metaNodes, metaEdges, dir);
const metaPos = sgcrPositions(metaNodes, metaEdges, dir);

// 3. Assemble: zone containers first (paint behind), then members (parent-relative), then loose.
const out: FlowNode[] = [];
Expand Down Expand Up @@ -410,7 +371,7 @@ function layoutSwimlane(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[]): {
// position, so cross-lane edges stay short and roughly perpendicular. Laying out each lane
// independently (the old approach) left cross-lane-only nodes at rank 0 of their lane,
// producing long backward edges that ran over other nodes.
const full = dagreLayout(nodes.map((n) => ({ id: n.id, ...sizeOf.get(n.id)! })), edges, dir);
const full = sgcrPositions(nodes.map((n) => ({ id: n.id, ...sizeOf.get(n.id)! })), edges, dir);
const alongRaw = (id: string) => (horizontal ? full[id]?.x ?? 0 : full[id]?.y ?? 0);
let minAlong = Infinity;
for (const n of nodes) minAlong = Math.min(minAlong, alongRaw(n.id));
Expand Down Expand Up @@ -588,12 +549,6 @@ function layoutTiers(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[]): { no
return { nodes: [...containers, ...childMembers], edges: tieredEdges };
}

/**
* Position nodes with dagre when any node lacks an explicit position; honor explicit
* positions as-is. Uses each node's declared width/height (or defaults). flow.tsx re-runs
* dagreLayout with MEASURED sizes after first render so long labels don't overlap. When nodes
* declare a `group`, defers to the grouped (zoned / lanes / tiers) layout instead.
*/
/**
* After a grouped layout produces parent-relative positions, compute the ABSOLUTE
* position of each non-group node by walking up the parentId chain. Then feed those
Expand Down Expand Up @@ -651,31 +606,22 @@ function routeGroupedEdges(
}
}

export function layoutFlow(spec: FlowSpec, opts: DagreOpts = {}): { nodes: FlowNode[]; edges: FlowEdge[] } {
// Drop any null/primitive entry before dispatching to a layout variant: every path below (grouped
// / swimlane / tiers / dagre) dereferences n.id/n.data/e.source and would otherwise throw. Malformed
// entries are already rejected as a 400 by the validator; this keeps the pure layout crash-proof.
export function layoutFlow(spec: FlowSpec): { nodes: FlowNode[]; edges: FlowEdge[] } {
const nodes = (Array.isArray(spec.nodes) ? spec.nodes : []).filter((n): n is FlowNode => !!n && typeof n === "object");
const edges = (Array.isArray(spec.edges) ? spec.edges : []).filter((e): e is FlowEdge => !!e && typeof e === "object");
if (nodes.length && nodes.some((n) => groupOf(n))) {
const grouped = spec.lanes ? layoutSwimlane(spec, nodes, edges)
: spec.tiers ? layoutTiers(spec, nodes, edges)
: layoutGroupedFlow(spec, nodes, edges);
// Route ALL edges (intra-zone + cross-zone) through SGCR's orthogonal channel
// router so edge-over-node is impossible by construction (P2). The node positions
// stay as the grouped layout placed them; only the edge polylines change.
return routeGroupedEdges(grouped);
}
if (nodes.length === 0) return { nodes, edges: withEdgeIds(edges) };
// Authoritative by default: ignore any spec-provided `node.position` and let dagre own the layout.
// `layout:"manual"` honors explicit positions (and only dagre-places nodes that lack one).
const manual = spec.layout === "manual";
if (manual && !nodes.some((n) => !n.position)) return { nodes, edges: withEdgeIds(edges) };
const pos = dagreLayout(
nodes.map((n) => ({ id: n.id, width: n.width, height: n.height })),
const pos = sgcrPositions(
nodes.map((n) => { const sz = estSize(n); return { id: n.id, width: n.width ?? sz.width, height: n.height ?? sz.height }; }),
edges,
spec.direction ?? "TB",
opts,
);
const positioned = nodes.map((n) => (manual && n.position ? n : { ...n, position: pos[n.id] }));
return { nodes: positioned, edges: withEdgeIds(edges) };
Expand Down
69 changes: 39 additions & 30 deletions packages/viewer/src/client/renderers/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ import {
type Edge,
} from "@xyflow/react";
import xyflowCss from "@xyflow/react/dist/style.css";
import { layoutFlow, dagreLayout, estSize, readableFloorZoom, anchoredViewport, type FlowSpec, type LegendItem } from "./flow-layout.js";
import { layoutFlow, estSize, readableFloorZoom, anchoredViewport, type FlowSpec, type LegendItem } from "./flow-layout.js";
import { normalizeFlowSpec } from "./flow-normalize.js";
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";
Expand Down Expand Up @@ -61,15 +60,9 @@ function Legend({ items }: { items: LegendItem[] }) {
}

function FlowInner({ spec, handle }: { spec: FlowSpec; handle?: PatchHandle }) {
// Capped, deterministic re-layout search picks the most readable arrangement (direction/compaction)
// before we render — requirement 1 ("lay it out so it reads"). For grouped/manual/pinned specs it
// returns the base layout unchanged.
const chosen = useMemo(() => bestLayout(spec), [spec]);
const dir = chosen.direction;
// Readable-zoom floor: the initial fit never shrinks the smallest content label below MIN_READABLE_PX
// (requirement 2). A graph too big to fit at that zoom overflows and pans instead of zooming out.
const dir = (spec.direction ?? "TB") as "TB" | "LR" | "BT" | "RL";
const floor = useMemo(() => readableFloorZoom(spec), [spec]);
const initial = useMemo(() => layoutFlow({ ...spec, direction: dir }, chosen.opts), [spec, dir, chosen.opts]);
const initial = useMemo(() => layoutFlow({ ...spec, direction: dir }), [spec, dir]);
// Layout is authoritative unless the spec opts into hand-placed positions; the measured re-pass
// (below) runs for auto-laid-out graphs so long labels never overlap.
const autoLayout = spec.layout !== "manual";
Expand All @@ -95,7 +88,7 @@ function FlowInner({ spec, handle }: { spec: FlowSpec; handle?: PatchHandle }) {
},
) as unknown as Node[],
);
const [edges, , onEdgesChange] = useEdgesState(initial.edges as unknown as Edge[]);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges as unknown as Edge[]);
const inited = useNodesInitialized();
const { fitView, getNodes, getNodesBounds, getViewport, setViewport } = useReactFlow();
const relaidOut = useRef(false);
Expand Down Expand Up @@ -146,28 +139,44 @@ function FlowInner({ spec, handle }: { spec: FlowSpec; handle?: PatchHandle }) {
};
}, [handle, setNodes]);

// Re-run dagre with MEASURED node sizes once nodes are measured, so long labels don't
// overlap (the initial pass uses estimated/default sizes). Auto-laid-out graphs only —
// hand-placed diagrams (e.g. sequence) keep their explicit positions.
useEffect(() => {
// Grouped graphs are positioned by the compound layout (with estimated sizes) and must NOT
// be re-flattened by the plain dagre re-pass, which would ignore zones.
if (!autoLayout || grouped || !inited || relaidOut.current) return;
if (!autoLayout || !inited || relaidOut.current) return;
relaidOut.current = true;
const sized = nodes.map((n) => ({
id: n.id,
width: n.measured?.width ?? 172,
height: n.measured?.height ?? 44,
const measuredNodes = nodes
.filter((n) => n.type !== "group")
.map((n) => {
const orig = (spec.nodes ?? []).find((sn) => sn.id === n.id);
return {
...(orig ?? { id: n.id }),
id: n.id,
width: n.measured?.width ?? 172,
height: n.measured?.height ?? 44,
};
});
const measuredSpec: FlowSpec = { ...spec, direction: dir, nodes: measuredNodes };
const re = layoutFlow(measuredSpec);
setNodes((nds) => nds.map((n) => {
const rn = re.nodes.find((r) => r.id === n.id);
if (!rn?.position) return n;
const update: Record<string, unknown> = { ...n, position: rn.position };
if (rn.style) update.style = rn.style;
if (rn.parentId) update.parentId = rn.parentId;
return update as typeof n;
}));
// Grouped layouts hand back EXPLICIT edge polylines, so moving the nodes without republishing
// the routes leaves every edge drawn against the first pass's estimated-size geometry — nodes
// in one arrangement, edges in another. (The old dagre re-pass returned positions only and let
// React Flow derive each path from the live node positions, so it never had this failure mode.)
const reRouted = new Map(re.edges.filter((r) => r.id).map((r) => [r.id as string, r]));
setEdges((eds) => eds.map((e) => {
const r = reRouted.get(e.id) as { type?: string; data?: Record<string, unknown> } | undefined;
if (!r?.data?.points) return e;
// Carry the type too: an edge the first pass left un-routed only renders its polyline once
// it's switched to the "sgcr" edge component.
return { ...e, type: r.type ?? e.type, data: { ...(e.data ?? {}), ...r.data } };
}));
const pos = dagreLayout(
sized,
edges as unknown as { source: string; target: string }[],
dir,
chosen.opts,
);
setNodes((nds) => nds.map((n) => (pos[n.id] ? { ...n, position: pos[n.id] } : n)));
requestAnimationFrame(anchorFit);
}, [autoLayout, grouped, inited, nodes, edges, anchorFit, setNodes, dir, chosen.opts]);
}, [autoLayout, inited, nodes, edges, anchorFit, setNodes, setEdges, dir, spec]);

// Re-fit when the container resizes (e.g. iPad rotation, sidebar collapse, maximize).
useEffect(() => {
Expand Down Expand Up @@ -459,7 +468,7 @@ export const mount: Mount = (el, content) => {
// `engine:"dagre"` opts out. Grouped graphs still use the dagre render path (FlowInner)
// because zone containers + parentId + smoothstep edges are dagre-path infrastructure —
// but their INTRA-zone layout is already SGCR (Phase 3, layoutCluster).
const useSgcr = spec.engine !== "dagre" && !grouped;
const useSgcr = !grouped;
const { teardown } = mountReact(
el,
<RenderBoundary>
Expand Down
Loading
Loading