Merge pull request 'refactor(dashboard): replace ELK with custom spine layout' (#235) from refactor/dashboard-custom-spine-layout into main
This commit is contained in:
@@ -13,9 +13,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"elkjs": "^0.11.1",
|
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import type { ConditionEdgeData } from "./types.ts";
|
import type { ConditionEdgeData } from "./types.ts";
|
||||||
|
|
||||||
|
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||||
|
const FEEDBACK_OFFSET_X = 100;
|
||||||
|
// Radius for feedback edge corners
|
||||||
|
const FEEDBACK_RADIUS = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
|
||||||
|
* The path goes: source right → arc → vertical up → arc → target right
|
||||||
|
*/
|
||||||
|
function feedbackPath(
|
||||||
|
sourceX: number,
|
||||||
|
sourceY: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
): string {
|
||||||
|
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||||
|
const r = FEEDBACK_RADIUS;
|
||||||
|
|
||||||
|
// Start from source right side, go right, then up, then left to target right side
|
||||||
|
const segments = [
|
||||||
|
`M ${sourceX} ${sourceY}`,
|
||||||
|
// Horizontal to the right
|
||||||
|
`L ${rightX - r} ${sourceY}`,
|
||||||
|
// Arc turning upward
|
||||||
|
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
|
||||||
|
// Vertical upward
|
||||||
|
`L ${rightX} ${targetY + r}`,
|
||||||
|
// Arc turning left
|
||||||
|
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
|
||||||
|
// Horizontal left to target
|
||||||
|
`L ${targetX} ${targetY}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return segments.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export function ConditionEdge(props: EdgeProps) {
|
export function ConditionEdge(props: EdgeProps) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -23,25 +59,41 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
const edgeData = data as ConditionEdgeData | undefined;
|
const edgeData = data as ConditionEdgeData | undefined;
|
||||||
const isFallback = edgeData?.isFallback ?? false;
|
const isFallback = edgeData?.isFallback ?? false;
|
||||||
const isSelfLoop = source === target;
|
const isSelfLoop = source === target;
|
||||||
|
const isFeedback = edgeData?.isFeedback ?? false;
|
||||||
|
|
||||||
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
|
let path: string;
|
||||||
sourceX,
|
let defaultLabelX: number;
|
||||||
sourceY,
|
let defaultLabelY: number;
|
||||||
targetX,
|
|
||||||
targetY,
|
if (isFeedback) {
|
||||||
sourcePosition,
|
// Custom feedback path routed to the right
|
||||||
targetPosition,
|
path = feedbackPath(sourceX, sourceY, targetX, targetY);
|
||||||
borderRadius: isSelfLoop ? 20 : 8,
|
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||||
offset: isSelfLoop ? 50 : undefined,
|
defaultLabelX = rightX;
|
||||||
});
|
defaultLabelY = (sourceY + targetY) / 2;
|
||||||
|
} else {
|
||||||
|
const result = getSmoothStepPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
borderRadius: isSelfLoop ? 20 : 8,
|
||||||
|
offset: isSelfLoop ? 50 : undefined,
|
||||||
|
});
|
||||||
|
path = result[0];
|
||||||
|
defaultLabelX = result[1];
|
||||||
|
defaultLabelY = result[2];
|
||||||
|
}
|
||||||
|
|
||||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
||||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||||
const label = edgeData?.condition ?? "";
|
const label = edgeData?.condition ?? "";
|
||||||
|
|
||||||
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
|
// Use pre-computed label position if available, otherwise fall back to default
|
||||||
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
|
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||||
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
|
const labelY = edgeData?.labelY ?? defaultLabelY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ export type ConditionEdgeData = {
|
|||||||
condition: string;
|
condition: string;
|
||||||
conditionDescription: string | null;
|
conditionDescription: string | null;
|
||||||
isFallback: boolean;
|
isFallback: boolean;
|
||||||
elkLabelX: number | null;
|
isFeedback: boolean;
|
||||||
elkLabelY: number | null;
|
isSelfLoop: boolean;
|
||||||
|
labelX: number | null;
|
||||||
|
labelY: number | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Edge, Node } from "@xyflow/react";
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
|
import { useMemo } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||||
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||||
|
|
||||||
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
|
|||||||
const ROLE_NODE_HEIGHT = 60;
|
const ROLE_NODE_HEIGHT = 60;
|
||||||
const TERMINAL_NODE_SIZE = 40;
|
const TERMINAL_NODE_SIZE = 40;
|
||||||
|
|
||||||
|
// Vertical gap between nodes in the spine
|
||||||
|
const LAYER_GAP = 80;
|
||||||
|
// Horizontal offset for feedback (back) edges routed on the right side
|
||||||
|
const FEEDBACK_OFFSET_X = 100;
|
||||||
|
|
||||||
type LayoutInput = {
|
type LayoutInput = {
|
||||||
edges: readonly WorkflowGraphEdge[];
|
edges: readonly WorkflowGraphEdge[];
|
||||||
roles: Record<string, { description: string }>;
|
roles: Record<string, { description: string }>;
|
||||||
@@ -21,15 +25,6 @@ type LayoutResult = {
|
|||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const e of edges) {
|
|
||||||
ids.add(e.from);
|
|
||||||
ids.add(e.to);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeSize(id: string): { width: number; height: number } {
|
function nodeSize(id: string): { width: number; height: number } {
|
||||||
if (id === START_ID || id === END_ID) {
|
if (id === START_ID || id === END_ID) {
|
||||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
||||||
@@ -41,6 +36,75 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
|||||||
return `${e.from}->${e.to}::${e.condition}`;
|
return `${e.from}->${e.to}::${e.condition}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function extractSpine(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spine;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRoleNode(
|
function buildRoleNode(
|
||||||
id: string,
|
id: string,
|
||||||
pos: { x: number; y: number },
|
pos: { x: number; y: number },
|
||||||
@@ -72,143 +136,95 @@ function buildTerminalNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
|
function computeLayout(input: LayoutInput): LayoutResult {
|
||||||
const isFallback = e.condition === "FALLBACK";
|
const spine = extractSpine(input.edges);
|
||||||
const key = edgeKey(e);
|
const rank = new Map<string, number>();
|
||||||
const elkEdge = elkEdgeMap.get(key);
|
for (let i = 0; i < spine.length; i++) {
|
||||||
|
rank.set(spine[i], i);
|
||||||
// Extract ELK's computed label position
|
|
||||||
let labelX: number | null = null;
|
|
||||||
let labelY: number | null = null;
|
|
||||||
if (elkEdge?.labels && elkEdge.labels.length > 0) {
|
|
||||||
const label = elkEdge.labels[0];
|
|
||||||
if (label.x !== undefined && label.y !== undefined) {
|
|
||||||
labelX = label.x + (label.width ?? 0) / 2;
|
|
||||||
labelY = label.y + (label.height ?? 0) / 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Position nodes along a vertical spine, centered horizontally
|
||||||
id: key,
|
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
||||||
source: e.from,
|
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
target: e.to,
|
|
||||||
type: "condition",
|
|
||||||
data: {
|
|
||||||
condition: e.condition,
|
|
||||||
conditionDescription: e.conditionDescription,
|
|
||||||
isFallback,
|
|
||||||
elkLabelX: labelX,
|
|
||||||
elkLabelY: labelY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const elk = new ELK();
|
let y = 0;
|
||||||
|
for (const id of spine) {
|
||||||
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
|
|
||||||
const ids = collectNodeIds(input.edges);
|
|
||||||
|
|
||||||
const elkNodes: ElkNode[] = [];
|
|
||||||
for (const id of ids) {
|
|
||||||
const size = nodeSize(id);
|
const size = nodeSize(id);
|
||||||
elkNodes.push({ id, width: size.width, height: size.height });
|
// Center-align all nodes on the spine
|
||||||
}
|
const x = centerX - size.width / 2;
|
||||||
|
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||||
const elkEdges: ElkExtendedEdge[] = input.edges
|
y += size.height + LAYER_GAP;
|
||||||
.filter((e) => e.from !== e.to)
|
|
||||||
.map((e) => ({
|
|
||||||
id: edgeKey(e),
|
|
||||||
sources: [e.from],
|
|
||||||
targets: [e.to],
|
|
||||||
labels: e.condition !== ""
|
|
||||||
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
|
|
||||||
: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const graph: ElkNode = {
|
|
||||||
id: "root",
|
|
||||||
layoutOptions: {
|
|
||||||
"elk.algorithm": "layered",
|
|
||||||
"elk.direction": "DOWN",
|
|
||||||
// Node spacing
|
|
||||||
"elk.spacing.nodeNode": "30",
|
|
||||||
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
|
|
||||||
// Edge spacing — keep edges apart from each other and from nodes
|
|
||||||
"elk.spacing.edgeNode": "25",
|
|
||||||
"elk.spacing.edgeEdge": "15",
|
|
||||||
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
|
|
||||||
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
|
|
||||||
// Edge routing
|
|
||||||
"elk.edgeRouting": "ORTHOGONAL",
|
|
||||||
"elk.layered.mergeEdges": "false",
|
|
||||||
// Node placement
|
|
||||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
|
||||||
// Edge label placement
|
|
||||||
"elk.edgeLabels.placement": "CENTER",
|
|
||||||
// Crossing minimization
|
|
||||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
||||||
// Compaction
|
|
||||||
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
|
|
||||||
// Cycle breaking — keep main flow top-to-bottom
|
|
||||||
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
|
|
||||||
},
|
|
||||||
children: elkNodes,
|
|
||||||
edges: elkEdges,
|
|
||||||
};
|
|
||||||
|
|
||||||
const laid = await elk.layout(graph);
|
|
||||||
|
|
||||||
// Build map of ELK edge results for label positions
|
|
||||||
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
|
|
||||||
for (const e of laid.edges ?? []) {
|
|
||||||
elkEdgeMap.set(e.id, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
for (const child of laid.children ?? []) {
|
for (const id of spine) {
|
||||||
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
|
const pos = nodePositions.get(id);
|
||||||
const state = input.nodeStates.get(child.id) ?? "default";
|
if (pos === undefined) continue;
|
||||||
if (child.id === START_ID || child.id === END_ID) {
|
const state = input.nodeStates.get(id) ?? "default";
|
||||||
nodes.push(buildTerminalNode(child.id, pos, state));
|
if (id === START_ID || id === END_ID) {
|
||||||
|
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||||
} else {
|
} else {
|
||||||
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
|
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
|
// Build edges with label positions
|
||||||
|
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
||||||
|
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
||||||
|
const edges: Edge[] = input.edges.map((e) => {
|
||||||
|
const isFallback = e.condition === "FALLBACK";
|
||||||
|
const isSelfLoop = e.from === e.to;
|
||||||
|
const sourceRank = rank.get(e.from) ?? 0;
|
||||||
|
const targetRank = rank.get(e.to) ?? 0;
|
||||||
|
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||||
|
|
||||||
|
const sourcePos = nodePositions.get(e.from);
|
||||||
|
const targetPos = nodePositions.get(e.to);
|
||||||
|
|
||||||
|
let labelX: number | null = null;
|
||||||
|
let labelY: number | null = null;
|
||||||
|
|
||||||
|
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||||
|
if (isFeedback) {
|
||||||
|
// Label on the right side of the feedback arc
|
||||||
|
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
|
||||||
|
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||||
|
labelX = rightX;
|
||||||
|
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 {
|
||||||
|
id: edgeKey(e),
|
||||||
|
source: e.from,
|
||||||
|
target: e.to,
|
||||||
|
type: "condition",
|
||||||
|
data: {
|
||||||
|
condition: e.condition,
|
||||||
|
conditionDescription: e.conditionDescription,
|
||||||
|
isFallback,
|
||||||
|
isFeedback,
|
||||||
|
isSelfLoop,
|
||||||
|
labelX,
|
||||||
|
labelY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
|
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
export function useLayout(input: LayoutInput): LayoutResult {
|
||||||
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
|
return useMemo(
|
||||||
|
() => computeLayout(input),
|
||||||
const edgeJson = JSON.stringify(input.edges);
|
[input.edges, input.roles, input.nodeStates],
|
||||||
const roleJson = JSON.stringify(input.roles);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const parsed = {
|
|
||||||
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
|
|
||||||
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
|
|
||||||
nodeStates: input.nodeStates,
|
|
||||||
};
|
|
||||||
computeLayout(parsed)
|
|
||||||
.then((result) => {
|
|
||||||
if (!cancelled) setLayout(result);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
// biome-ignore lint/suspicious/noConsole: layout error reporting
|
|
||||||
console.error("ELK layout failed:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [edgeJson, roleJson, input.nodeStates]);
|
|
||||||
|
|
||||||
return layout;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
type OnNodeClick,
|
type OnNodeClick,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow,
|
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||||
import { ConditionEdge } from "./condition-edge.tsx";
|
import { ConditionEdge } from "./condition-edge.tsx";
|
||||||
import { RoleNode } from "./role-node.tsx";
|
import { RoleNode } from "./role-node.tsx";
|
||||||
@@ -39,30 +37,12 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node
|
|||||||
onRoleClick(node.id);
|
onRoleClick(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||||
const { fitView } = useReactFlow();
|
|
||||||
|
|
||||||
const onNodeClickHandler: OnNodeClick | undefined =
|
const onNodeClickHandler: OnNodeClick | undefined =
|
||||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||||
|
|
||||||
// Re-fit when layout changes (ELK is async)
|
|
||||||
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
|
|
||||||
useEffect(() => {
|
|
||||||
if (layout.nodes.length > 0) {
|
|
||||||
let cancelled = false;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [layout.nodes, layout.edges, fitView]);
|
|
||||||
|
|
||||||
const styledEdges = useMemo(
|
const styledEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
layout.edges.map((e) => ({
|
layout.edges.map((e) => ({
|
||||||
@@ -77,25 +57,17 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
|||||||
[layout.edges],
|
[layout.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
|
|
||||||
const layoutKey = useMemo(
|
|
||||||
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
|
|
||||||
[layout.nodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
key={layoutKey}
|
|
||||||
nodes={layout.nodes}
|
nodes={layout.nodes}
|
||||||
edges={styledEdges}
|
edges={styledEdges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onNodeClick={onNodeClickHandler}
|
onNodeClick={onNodeClickHandler}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
minZoom={0.1}
|
minZoom={0.3}
|
||||||
maxZoom={1.5}
|
maxZoom={2}
|
||||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
|
||||||
nodesDraggable={false}
|
nodesDraggable={false}
|
||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
@@ -107,11 +79,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowGraph(props: Props) {
|
|
||||||
return (
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<WorkflowGraphInner {...props} />
|
|
||||||
</ReactFlowProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user