diff --git a/packages/viewer/src/client/renderers/flow-layout.ts b/packages/viewer/src/client/renderers/flow-layout.ts index 3540aee..d1a4c44 100644 --- a/packages/viewer/src/client/renderers/flow-layout.ts +++ b/packages/viewer/src/client/renderers/flow-layout.ts @@ -594,6 +594,63 @@ 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 @@ -601,9 +658,13 @@ export function layoutFlow(spec: FlowSpec, opts: DagreOpts = {}): { nodes: FlowN 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. diff --git a/packages/viewer/src/client/renderers/flow.tsx b/packages/viewer/src/client/renderers/flow.tsx index 96797a4..0af49e8 100644 --- a/packages/viewer/src/client/renderers/flow.tsx +++ b/packages/viewer/src/client/renderers/flow.tsx @@ -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