refactor(dashboard): reachability-based partial order for graph layout

Replace linear spine walk with a proper partial order:
- a « b = a ~> b AND NOT b ~> a (strict precedence)
- a ~ b = incomparable under «
- depth tiebreaker for incomparable nodes
- Equivalent nodes (same layer) placed side-by-side horizontally
This commit is contained in:
2026-05-15 14:05:53 +08:00
parent 576df067d4
commit 3431d3070b
@@ -37,73 +37,121 @@ function edgeKey(e: WorkflowGraphEdge): string {
}
/**
* Extract the linear spine from the graph using topological ordering.
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
* Self-loops are neither forward nor feedback — they're handled separately.
* Compute node layers using a reachability-based partial order.
*
* 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)
*
* Nodes in the same equivalence class are placed side-by-side horizontally.
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
function computeLayers(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 for forward edges only (non-self-loop, non-FALLBACK-back)
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
// or FALLBACK if no other option.
const forwardAdj = new Map<string, string[]>();
// 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) continue;
const existing = forwardAdj.get(e.from) ?? [];
existing.push(e.to);
forwardAdj.set(e.from, existing);
}
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
const visited = new Set<string>();
const spine: string[] = [];
// Build a set of "primary" next targets per node (non-FALLBACK first)
const primaryNext = new Map<string, string>();
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const list = edgesByFrom.get(e.from) ?? [];
list.push(e);
edgesByFrom.set(e.from, list);
}
// For each node, the "primary" next is the first non-FALLBACK target,
// or the FALLBACK target if all edges are FALLBACK
for (const [from, edgeList] of edgesByFrom) {
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
}
// Walk the spine from __start__
let current: string | null = START_ID;
while (current !== null && !visited.has(current)) {
visited.add(current);
spine.push(current);
const next = primaryNext.get(current);
if (next !== undefined && next !== "" && !visited.has(next)) {
current = next;
} else {
current = null;
if (e.from !== e.to) {
adj.get(e.from)!.push(e.to);
}
}
// Add any remaining nodes not on the main path (shouldn't normally happen)
for (const id of ids) {
if (!visited.has(id)) {
spine.push(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);
}
return spine;
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];
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 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);
}
// 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;
}
}
return 0;
});
return groups;
}
function buildRoleNode(
@@ -137,36 +185,64 @@ function buildTerminalNode(
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
function computeLayout(input: LayoutInput): LayoutResult {
const spine = extractSpine(input.edges);
const layers = computeLayers(input.edges);
// Flatten layers into a rank map (layer index = rank)
const rank = new Map<string, number>();
for (let i = 0; i < spine.length; i++) {
rank.set(spine[i], i);
for (let i = 0; i < layers.length; i++) {
for (const id of layers[i]) {
rank.set(id, i);
}
}
// Position nodes along a vertical spine, centered horizontally
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
// Horizontal gap between nodes in the same layer
const H_GAP = 40;
// Position nodes: each layer is a horizontal row
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
// Find max layer width for centering
const layerWidths: number[] = [];
for (const layer of layers) {
let w = 0;
for (const id of layer) {
w += nodeSize(id).width;
}
w += (layer.length - 1) * H_GAP;
layerWidths.push(w);
}
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
const centerX = maxLayerWidth / 2;
let y = 0;
for (const id of spine) {
const size = nodeSize(id);
// Center-align all nodes on the spine
const x = centerX - size.width / 2;
nodePositions.set(id, { x, y, w: size.width, h: size.height });
y += size.height + LAYER_GAP;
for (let li = 0; li < layers.length; li++) {
const layer = layers[li];
const totalWidth = layerWidths[li];
let x = centerX - totalWidth / 2;
let maxH = 0;
for (const id of layer) {
const size = nodeSize(id);
nodePositions.set(id, { x, y, w: size.width, h: size.height });
x += size.width + H_GAP;
if (size.height > maxH) maxH = size.height;
}
y += maxH + LAYER_GAP;
}
// Build nodes
const nodes: Node[] = [];
for (const id of spine) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
for (const layer of layers) {
for (const id of layer) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
}
}
}