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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user