// Cinematic process graph. dagre auto-layout, animated active edge, // glass nodes, minimap, selection ring. import { useMemo, type ReactElement } from "react"; import ReactFlow, { Background, Controls, MiniMap, Handle, Position, type Edge, type Node, type NodeProps, } from "reactflow"; import { useApp, scenarioById } from "../state/store"; import { layoutGraph, NODE_SIZE } from "../graph/layout"; import { Bot, User, Cog, Shield, Flag } from "./icons"; import type { ProcessStep, StepKind } from "../data/types"; type NodeData = ProcessStep & { selected: boolean }; const KIND_ICON: Record ReactElement> = { start: Flag, end: Flag, human: User, agent: Bot, service: Cog, decision: Cog, }; function StepNode({ data, id }: NodeProps) { const Icon = KIND_ICON[data.kind] ?? Cog; return (
{data.name}
{data.kind} {data.owner} {data.governs.length > 0 && ( {data.governs.length} )}
); } const nodeTypes = { step: StepNode }; const EDGE_COLOR: Record = { done: "var(--ok)", running: "var(--run)", errored: "var(--block)", default: "var(--border-strong)", }; export default function ProcessGraph() { const scenarioId = useApp((s) => s.scenarioId); const selectedStepId = useApp((s) => s.selectedStepId); const setSelectedStepId = useApp((s) => s.setSelectedStepId); const sc = scenarioById(scenarioId); const { nodes, edges } = useMemo(() => { if (!sc) return { nodes: [] as Node[], edges: [] as Edge[] }; const positions = layoutGraph(sc.steps, sc.edges); const posById = new Map(positions.map((p) => [p.id, p.position])); const stepById = new Map(sc.steps.map((s) => [s.id, s])); const nodes: Node[] = sc.steps.map((s) => ({ id: s.id, type: "step", position: posById.get(s.id) ?? { x: 0, y: 0 }, data: { ...s, selected: s.id === selectedStepId }, })); const edges: Edge[] = sc.edges.map((e) => { const srcStep = stepById.get(e.source); const isRunningEdge = srcStep?.state === "running"; const color = srcStep?.state === "errored" ? EDGE_COLOR.errored : srcStep?.state === "done" ? EDGE_COLOR.done : srcStep?.state === "running" ? EDGE_COLOR.running : EDGE_COLOR.default; return { id: e.id, source: e.source, target: e.target, label: e.label, animated: isRunningEdge, type: "smoothstep", style: { stroke: color, strokeWidth: isRunningEdge ? 2.2 : 1.6, opacity: srcStep?.state === "idle" ? 0.55 : 1 }, labelStyle: { fill: "var(--text-2)", fontSize: 11, fontFamily: "Fira Sans", fontWeight: 500 }, labelBgPadding: [6, 4], labelBgBorderRadius: 4, labelBgStyle: { fill: "var(--surface)" }, }; }); return { nodes, edges }; }, [sc, selectedStepId]); if (!sc) return
No scenario loaded
; return (
setSelectedStepId(n.id)} fitView fitViewOptions={{ padding: 0.2, minZoom: 0.75, maxZoom: 1 }} proOptions={{ hideAttribution: true }} minZoom={0.4} maxZoom={1.8} panOnScroll panOnDrag nodesDraggable={false} nodesConnectable={false} elementsSelectable defaultEdgeOptions={{ type: "smoothstep" }} style={{ width: "100%", height: "100%" }} > { const d = (n.data as NodeData) || {}; switch (d.state) { case "running": return "var(--run)"; case "done": return "var(--ok)"; case "errored": return "var(--block)"; case "queued": return "var(--queue)"; default: return "var(--border-strong)"; } }} maskColor="rgba(8,11,20,0.7)" style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8 }} pannable zoomable />
{sc.live ? "LIVE" : "BLUEPRINT"} {sc.defName} · {sc.version} · {NODE_SIZE.width}×{NODE_SIZE.height} dagre LR
); }