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:
2026-05-15 14:48:02 +08:00
parent 8892ab9978
commit 9c44c709e9
@@ -36,124 +36,140 @@ function edgeKey(e: WorkflowGraphEdge): string {
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"):
* a « b = a ~> b AND NOT b ~> a (a strictly precedes 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)
* For each node, rank = max(rank(pred) + 1) over all predecessors.
* This guarantees that if a -> b (and not b -> a), rank(a) < rank(b).
*
* 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
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
const nodeList = [...ids];
// Build adjacency (excluding self-loops)
const adj = new Map<string, string[]>();
for (const id of ids) adj.set(id, []);
for (const e of edges) {
if (e.from !== e.to) {
adj.get(e.from)?.push(e.to);
}
const inEdges = new Map<string, string[]>();
for (const id of ids) {
adj.set(id, []);
inEdges.set(id, []);
}
// Compute reachability via BFS from each node
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>();
// Detect back-edges via DFS to break cycles
const backEdges = new Set<string>();
{
const queue = [START_ID];
depth.set(START_ID, 0);
while (queue.length > 0) {
const cur = queue.shift()!;
const d = depth.get(cur)!;
for (const next of adj.get(cur) ?? []) {
if (!depth.has(next)) {
depth.set(next, d + 1);
queue.push(next);
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const id of ids) color.set(id, WHITE);
// Temporary full adjacency for cycle detection
const fullAdj = new Map<string, string[]>();
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))
const lessThan = (a: string, b: string): boolean => {
if (strictlyPrecedes(a, b)) return true;
if (strictlyPrecedes(b, a)) return false;
// a ~ b: incomparable under «
return depthOf(a) < depthOf(b);
};
// 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);
// Build DAG adjacency (without back-edges)
for (const e of edges) {
if (e.from === e.to) continue;
if (backEdges.has(`${e.from}->${e.to}`)) continue;
adj.get(e.from)?.push(e.to);
inEdges.get(e.to)?.push(e.from);
}
// Topological sort the groups by <
groups.sort((ga, gb) => {
// Use representative: if any a in ga < any b in gb, ga comes first
for (const a of ga) {
for (const b of gb) {
if (lessThan(a, b)) return -1;
if (lessThan(b, a)) return 1;
// Longest-path ranking via topological order (Kahn's algorithm)
const inDegree = new Map<string, number>();
for (const id of ids) inDegree.set(id, 0);
for (const id of ids) {
for (const next of adj.get(id) ?? []) {
inDegree.set(next, (inDegree.get(next) ?? 0) + 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(
id: string,
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
function computeLayout(input: LayoutInput): LayoutResult {
const layers = computeLayers(input.edges);
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
const layers = computeLayersLongestPath(input.edges);
// Flatten layers into a rank map (layer index = rank)
const rank = new Map<string, number>();
@@ -247,9 +265,6 @@ function computeLayout(input: LayoutInput): LayoutResult {
}
// 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 edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK";
@@ -268,7 +283,6 @@ function computeLayout(input: LayoutInput): LayoutResult {
if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback || isSkipForward) {
// Route to side — alternate left/right per target node
const count = routedCountByTarget.get(e.to) ?? 0;
routedCountByTarget.set(e.to, count + 1);
feedbackSide = count % 2 === 0 ? "right" : "left";
@@ -280,13 +294,11 @@ function computeLayout(input: LayoutInput): LayoutResult {
labelX = offsetX;
labelY = midY;
} else if (!isSelfLoop) {
// Forward edge: label between source bottom and target top
const midX = centerX;
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
labelX = midX;
labelY = midY;
}
// Self-loop: let ReactFlow default handle it
}
return {
@@ -318,6 +330,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
return { nodes, edges };
}
// ── Public hook ─────────────────────────────────────────────────────
export function useLayout(input: LayoutInput): LayoutResult {
return useMemo(() => computeLayout(input), [input]);
return useMemo(() => computeLayoutLongestPath(input), [input]);
}