fix(dashboard): replace broken partial-order layering with longest-path
The previous computeLayers used a reachability-based relation (a « b) with depth tiebreaker to define a < b, then grouped nodes by == into equivalence classes. However == is NOT an equivalence relation (fails transitivity), making the grouping order-dependent and incorrect for graphs with parallel branches. Replace with standard Sugiyama longest-path layering: 1. DFS to detect and remove back-edges (break cycles) 2. Kahn's topological sort on the resulting DAG 3. rank(n) = max(rank(pred) + 1) for longest-path assignment 4. Group nodes by rank into layers Also removes the experimental dagre layout strategy that was added for comparison — longest-path produces better results for our workflow graphs.
This commit is contained in:
@@ -36,124 +36,140 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
|||||||
return `${e.from}->${e.to}::${e.condition}`;
|
return `${e.from}->${e.to}::${e.condition}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute node layers using a reachability-based partial order.
|
* Assign layers via longest path from sources.
|
||||||
*
|
*
|
||||||
* Definitions (where ~> means "has a directed path"):
|
* For each node, rank = max(rank(pred) + 1) over all predecessors.
|
||||||
* a « b = a ~> b AND NOT b ~> a (a strictly precedes b)
|
* This guarantees that if a -> b (and not b -> a), rank(a) < rank(b).
|
||||||
* a ~ b = NOT a « b AND NOT b « a (incomparable)
|
|
||||||
* depth(a) = shortest path length from __start__ to a
|
|
||||||
* a < b = a « b OR (a ~ b AND depth(a) < depth(b))
|
|
||||||
* a == b = NOT a < b AND NOT b < a (equivalence class → same row)
|
|
||||||
*
|
*
|
||||||
* Nodes in the same equivalence class are placed side-by-side horizontally.
|
* Back-edges (cycles) are detected and excluded from ranking:
|
||||||
|
* we first remove edges that create cycles (DFS-based), compute ranks
|
||||||
|
* on the resulting DAG, then the removed edges become feedback edges.
|
||||||
*/
|
*/
|
||||||
function computeLayers(edges: readonly WorkflowGraphEdge[]): string[][] {
|
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||||
// Collect all node IDs
|
// Collect all node IDs
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
ids.add(e.from);
|
ids.add(e.from);
|
||||||
ids.add(e.to);
|
ids.add(e.to);
|
||||||
}
|
}
|
||||||
const nodeList = [...ids];
|
|
||||||
|
|
||||||
// Build adjacency (excluding self-loops)
|
// Build adjacency (excluding self-loops)
|
||||||
const adj = new Map<string, string[]>();
|
const adj = new Map<string, string[]>();
|
||||||
for (const id of ids) adj.set(id, []);
|
const inEdges = new Map<string, string[]>();
|
||||||
for (const e of edges) {
|
for (const id of ids) {
|
||||||
if (e.from !== e.to) {
|
adj.set(id, []);
|
||||||
adj.get(e.from)?.push(e.to);
|
inEdges.set(id, []);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Detect back-edges via DFS to break cycles
|
||||||
// Compute reachability via BFS from each node
|
const backEdges = new Set<string>();
|
||||||
const reachable = new Map<string, Set<string>>();
|
|
||||||
for (const source of nodeList) {
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const queue = [source];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const cur = queue.shift()!;
|
|
||||||
for (const next of adj.get(cur) ?? []) {
|
|
||||||
if (!visited.has(next)) {
|
|
||||||
visited.add(next);
|
|
||||||
queue.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reachable.set(source, visited);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reaches = (a: string, b: string): boolean => reachable.get(a)?.has(b) ?? false;
|
|
||||||
|
|
||||||
// a « b = a ~> b AND NOT b ~> a
|
|
||||||
const strictlyPrecedes = (a: string, b: string): boolean => reaches(a, b) && !reaches(b, a);
|
|
||||||
|
|
||||||
// Compute depth = shortest path from __start__ via BFS
|
|
||||||
const depth = new Map<string, number>();
|
|
||||||
{
|
{
|
||||||
const queue = [START_ID];
|
const WHITE = 0;
|
||||||
depth.set(START_ID, 0);
|
const GRAY = 1;
|
||||||
while (queue.length > 0) {
|
const BLACK = 2;
|
||||||
const cur = queue.shift()!;
|
const color = new Map<string, number>();
|
||||||
const d = depth.get(cur)!;
|
for (const id of ids) color.set(id, WHITE);
|
||||||
for (const next of adj.get(cur) ?? []) {
|
|
||||||
if (!depth.has(next)) {
|
// Temporary full adjacency for cycle detection
|
||||||
depth.set(next, d + 1);
|
const fullAdj = new Map<string, string[]>();
|
||||||
queue.push(next);
|
for (const id of ids) fullAdj.set(id, []);
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dfs(u: string): void {
|
||||||
|
color.set(u, GRAY);
|
||||||
|
for (const v of fullAdj.get(u) ?? []) {
|
||||||
|
const c = color.get(v) ?? WHITE;
|
||||||
|
if (c === GRAY) {
|
||||||
|
// Back-edge: u -> v where v is an ancestor
|
||||||
|
backEdges.add(`${u}->${v}`);
|
||||||
|
} else if (c === WHITE) {
|
||||||
|
dfs(v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
color.set(u, BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start DFS from __start__ first for determinism
|
||||||
|
if (ids.has(START_ID)) dfs(START_ID);
|
||||||
|
for (const id of ids) {
|
||||||
|
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const depthOf = (a: string): number => depth.get(a) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
// a < b = a « b OR (a ~ b AND depth(a) < depth(b))
|
// Build DAG adjacency (without back-edges)
|
||||||
const lessThan = (a: string, b: string): boolean => {
|
for (const e of edges) {
|
||||||
if (strictlyPrecedes(a, b)) return true;
|
if (e.from === e.to) continue;
|
||||||
if (strictlyPrecedes(b, a)) return false;
|
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||||
// a ~ b: incomparable under «
|
adj.get(e.from)?.push(e.to);
|
||||||
return depthOf(a) < depthOf(b);
|
inEdges.get(e.to)?.push(e.from);
|
||||||
};
|
|
||||||
|
|
||||||
// Group into equivalence classes: a == b iff NOT a < b AND NOT b < a
|
|
||||||
const assigned = new Set<string>();
|
|
||||||
const groups: string[][] = [];
|
|
||||||
|
|
||||||
// Process in a stable order (sorted by depth, then alphabetical)
|
|
||||||
const sorted = [...nodeList].sort((a, b) => {
|
|
||||||
const dd = depthOf(a) - depthOf(b);
|
|
||||||
if (dd !== 0) return dd;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const node of sorted) {
|
|
||||||
if (assigned.has(node)) continue;
|
|
||||||
const group = [node];
|
|
||||||
assigned.add(node);
|
|
||||||
for (const other of sorted) {
|
|
||||||
if (assigned.has(other)) continue;
|
|
||||||
if (!lessThan(node, other) && !lessThan(other, node)) {
|
|
||||||
group.push(other);
|
|
||||||
assigned.add(other);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups.push(group);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topological sort the groups by <
|
// Longest-path ranking via topological order (Kahn's algorithm)
|
||||||
groups.sort((ga, gb) => {
|
const inDegree = new Map<string, number>();
|
||||||
// Use representative: if any a in ga < any b in gb, ga comes first
|
for (const id of ids) inDegree.set(id, 0);
|
||||||
for (const a of ga) {
|
for (const id of ids) {
|
||||||
for (const b of gb) {
|
for (const next of adj.get(id) ?? []) {
|
||||||
if (lessThan(a, b)) return -1;
|
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||||
if (lessThan(b, a)) return 1;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = new Map<string, number>();
|
||||||
|
const queue: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if ((inDegree.get(id) ?? 0) === 0) {
|
||||||
|
queue.push(id);
|
||||||
|
rank.set(id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
const curRank = rank.get(cur) ?? 0;
|
||||||
|
for (const next of adj.get(cur) ?? []) {
|
||||||
|
// Longest path: take max
|
||||||
|
const prevRank = rank.get(next) ?? 0;
|
||||||
|
if (curRank + 1 > prevRank) {
|
||||||
|
rank.set(next, curRank + 1);
|
||||||
|
}
|
||||||
|
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||||
|
inDegree.set(next, deg);
|
||||||
|
if (deg === 0) {
|
||||||
|
queue.push(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
// Group by rank
|
||||||
|
const maxRank = Math.max(...[...rank.values()], 0);
|
||||||
|
const layers: string[][] = [];
|
||||||
|
for (let r = 0; r <= maxRank; r++) {
|
||||||
|
layers.push([]);
|
||||||
|
}
|
||||||
|
for (const [id, r] of rank) {
|
||||||
|
layers[r].push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
|
||||||
|
for (const layer of layers) {
|
||||||
|
layer.sort((a, b) => {
|
||||||
|
if (a === START_ID) return -1;
|
||||||
|
if (b === START_ID) return 1;
|
||||||
|
if (a === END_ID) return 1;
|
||||||
|
if (b === END_ID) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty layers
|
||||||
|
return layers.filter((l) => l.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRoleNode(
|
function buildRoleNode(
|
||||||
id: string,
|
id: string,
|
||||||
pos: { x: number; y: number },
|
pos: { x: number; y: number },
|
||||||
@@ -185,9 +201,11 @@ function buildTerminalNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||||
|
|
||||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
||||||
function computeLayout(input: LayoutInput): LayoutResult {
|
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||||
const layers = computeLayers(input.edges);
|
const layers = computeLayersLongestPath(input.edges);
|
||||||
|
|
||||||
// Flatten layers into a rank map (layer index = rank)
|
// Flatten layers into a rank map (layer index = rank)
|
||||||
const rank = new Map<string, number>();
|
const rank = new Map<string, number>();
|
||||||
@@ -247,9 +265,6 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build edges with label positions
|
// Build edges with label positions
|
||||||
// Feedback edges (target rank < source rank) and skip-forward edges (span > 1 layer)
|
|
||||||
// are routed to the side. Adjacent forward edges go straight down.
|
|
||||||
// Track routed edge count per side for alternating
|
|
||||||
const routedCountByTarget = new Map<string, number>();
|
const routedCountByTarget = new Map<string, number>();
|
||||||
const edges: Edge[] = input.edges.map((e) => {
|
const edges: Edge[] = input.edges.map((e) => {
|
||||||
const isFallback = e.condition === "FALLBACK";
|
const isFallback = e.condition === "FALLBACK";
|
||||||
@@ -268,7 +283,6 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
|||||||
|
|
||||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||||
if (isFeedback || isSkipForward) {
|
if (isFeedback || isSkipForward) {
|
||||||
// Route to side — alternate left/right per target node
|
|
||||||
const count = routedCountByTarget.get(e.to) ?? 0;
|
const count = routedCountByTarget.get(e.to) ?? 0;
|
||||||
routedCountByTarget.set(e.to, count + 1);
|
routedCountByTarget.set(e.to, count + 1);
|
||||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||||
@@ -280,13 +294,11 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
|||||||
labelX = offsetX;
|
labelX = offsetX;
|
||||||
labelY = midY;
|
labelY = midY;
|
||||||
} else if (!isSelfLoop) {
|
} else if (!isSelfLoop) {
|
||||||
// Forward edge: label between source bottom and target top
|
|
||||||
const midX = centerX;
|
const midX = centerX;
|
||||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||||
labelX = midX;
|
labelX = midX;
|
||||||
labelY = midY;
|
labelY = midY;
|
||||||
}
|
}
|
||||||
// Self-loop: let ReactFlow default handle it
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -318,6 +330,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Public hook ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
export function useLayout(input: LayoutInput): LayoutResult {
|
||||||
return useMemo(() => computeLayout(input), [input]);
|
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user