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
67 changes: 64 additions & 3 deletions packages/viewer/src/client/renderers/flow-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,16 +594,77 @@ function layoutTiers(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[]): { no
* 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
* absolute positions + all edges into SGCR to get orthogonal edge routing that is
* PROVEN to not cross any node (P2). The routed polylines are stored as `data.points`
* on each edge, and the edge type is set to "sgcr" so SgcrEdge renders them.
*/
function routeGroupedEdges(
result: { nodes: FlowNode[]; edges: FlowEdge[] },
): { nodes: FlowNode[]; edges: FlowEdge[] } {
const { nodes, edges } = result;
// Build a map of node positions (absolute)
const nodeById = new Map(nodes.map(n => [n.id, n]));
function absPos(n: FlowNode): { x: number; y: number } {
const pos = n.position ?? { x: 0, y: 0 };
const pid = n.parentId as string | undefined;
if (pid) {
const parent = nodeById.get(pid);
if (parent) {
const pp = absPos(parent);
return { x: pp.x + pos.x, y: pp.y + pos.y };
}
}
return pos;
}
// Collect non-group nodes with absolute positions + sizes
const realNodes = nodes.filter(n => n.type !== "group");
if (realNodes.length === 0 || edges.length === 0) return result;
const sgcrNodes = realNodes.map(n => {
const ap = absPos(n);
const sz = estSize(n);
return { id: n.id, width: n.width ?? sz.width, height: n.height ?? sz.height, x: ap.x, y: ap.y };
});
// Build SGCR input — use the EXISTING positions (don't re-layout, just route edges)
const sgcrEdges = edges.filter(e => e.source && e.target).map((e, i) => ({
id: e.id ?? `ge${i}`, source: e.source, target: e.target,
label: typeof (e as { label?: unknown }).label === "string" ? (e as { label?: string }).label : undefined,
}));
try {
const input: SGCRInput = { direction: "TB", nodes: sgcrNodes, edges: sgcrEdges };
const lay = layoutSGCR(input);
// Map SGCR's routed edge polylines back onto the original edges
const routeMap = new Map(lay.edges.map(e => [e.id, e]));
const routedEdges = edges.map(e => {
const routed = routeMap.get(e.id ?? "");
if (routed?.points?.length) {
return { ...e, type: "sgcr", data: { ...(e.data ?? {}), points: routed.points, label: routed.label, labelBox: routed.labelBox } };
}
return e; // fallback: keep original edge
});
return { nodes, edges: routedEdges as FlowEdge[] };
} catch {
// SGCR routing failed — keep smoothstep edges as fallback
return result;
}
}

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.
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))) {
if (spec.lanes) return layoutSwimlane(spec, nodes, edges);
if (spec.tiers) return layoutTiers(spec, nodes, edges);
return layoutGroupedFlow(spec, nodes, edges);
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.
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/client/renderers/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ function FlowInner({ spec, handle }: { spec: FlowSpec; handle?: PatchHandle }) {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={sgcrEdgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
fitView
// The fit floor keeps the smallest content label ≥ MIN_READABLE_PX on first paint: a graph too
Expand Down
Loading