feat(FN-3082): restructure dependency graph plugin with modular architectur

Implements a modular dependency graph feature for the Fusion dashboard plugin, replacing the monolithic `DependencyGraphView` component with a factored architecture: graph types and filtering (Step 1), a data hook (Step 2), auto-layout engine (Step 3), SVG edge rendering (Step 4), an interaction hoo

Fusion-Task-Id: FN-3082
This commit is contained in:
Fusion
2026-05-07 01:34:57 -07:00
committed by gsxdsm
parent fa57034c05
commit 12326eeb00
28 changed files with 1021 additions and 928 deletions

View File

@@ -0,0 +1,91 @@
import type { GraphData, GraphPosition } from "./types";
export interface LayoutOptions {
nodeWidth?: number;
nodeHeight?: number;
horizontalGap?: number;
verticalGap?: number;
}
const DEFAULT_LAYOUT_OPTIONS: Required<LayoutOptions> = {
nodeWidth: 280,
nodeHeight: 100,
horizontalGap: 40,
verticalGap: 80,
};
export function computeAutoLayout(
graphData: GraphData,
options?: LayoutOptions,
): Map<string, GraphPosition> {
const settings = { ...DEFAULT_LAYOUT_OPTIONS, ...options };
const nodeIds = graphData.nodes.map((node) => node.task.id);
if (nodeIds.length === 0) return new Map();
const dependentsByDependency = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const id of nodeIds) {
inDegree.set(id, 0);
dependentsByDependency.set(id, []);
}
for (const edge of graphData.edges) {
if (!inDegree.has(edge.source) || !inDegree.has(edge.target)) continue;
dependentsByDependency.get(edge.target)?.push(edge.source);
inDegree.set(edge.source, (inDegree.get(edge.source) ?? 0) + 1);
}
const queue = nodeIds.filter((id) => (inDegree.get(id) ?? 0) === 0);
const topologicalOrder: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
topologicalOrder.push(current);
for (const dependent of dependentsByDependency.get(current) ?? []) {
const nextInDegree = (inDegree.get(dependent) ?? 0) - 1;
inDegree.set(dependent, nextInDegree);
if (nextInDegree === 0) queue.push(dependent);
}
}
for (const id of nodeIds) {
if (!topologicalOrder.includes(id)) topologicalOrder.push(id);
}
const depthByNode = new Map<string, number>();
for (const id of topologicalOrder) {
const parents = graphData.edges.filter((edge) => edge.source === id).map((edge) => edge.target);
let depth = 0;
for (const parent of parents) {
depth = Math.max(depth, (depthByNode.get(parent) ?? 0) + 1);
}
depthByNode.set(id, depth);
}
const layers = new Map<number, string[]>();
for (const id of nodeIds) {
const depth = depthByNode.get(id) ?? 0;
const layer = layers.get(depth) ?? [];
layer.push(id);
layers.set(depth, layer);
}
const positions = new Map<string, GraphPosition>();
const sortedDepths = Array.from(layers.keys()).sort((a, b) => a - b);
for (const depth of sortedDepths) {
const layer = layers.get(depth) ?? [];
layer.sort();
const layerWidth = layer.length * settings.nodeWidth + Math.max(0, layer.length - 1) * settings.horizontalGap;
const startX = -layerWidth / 2;
layer.forEach((id, index) => {
const x = startX + index * (settings.nodeWidth + settings.horizontalGap);
const y = depth * (settings.nodeHeight + settings.verticalGap);
positions.set(id, { x, y });
});
}
return positions;
}