feat: implement Story 2.3 — BPMN diagram type renderer

Add first diagram type renderer with 9 custom BPMN node components,
3 edge types, compound ELK layout for pool/lane hierarchy, BFS path
highlighting on node click, and group container rendering. Includes
review fixes: integrated compound layout into computeLayout pipeline,
wired BFS highlighting via onNodeClick handler, replaced hardcoded
SVG colors with CSS custom properties for dark mode, and added 4
handles to all event nodes for multi-direction layout support.

81 web tests passing (31 new), no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 21:06:02 +00:00
parent 7dd5af17ac
commit 0a7838aa60
30 changed files with 3024 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -9,11 +10,91 @@ import {
BackgroundVariant,
Panel,
} from "@xyflow/react";
import type { Node } from "@xyflow/react";
import { useGraphStore } from "../../stores/useGraphStore";
import { useAutoLayout } from "../../hooks/useAutoLayout";
import { bfsPath } from "../../lib/bfs-path";
import {
BpmnActivityNode,
BpmnSubprocessNode,
BpmnStartEventNode,
BpmnEndEventNode,
BpmnTimerEventNode,
BpmnMessageEventNode,
BpmnGatewayNode,
BpmnDataObjectNode,
BpmnAnnotationNode,
BpmnPoolNode,
BpmnLaneNode,
BpmnGroupNode,
BpmnSequenceEdge,
BpmnMessageEdge,
BpmnAssociationEdge,
} from "../../types/bpmn";
const nodeTypes = {};
const nodeTypes = {
bpmnActivity: BpmnActivityNode,
bpmnSubprocess: BpmnSubprocessNode,
bpmnStartEvent: BpmnStartEventNode,
bpmnEndEvent: BpmnEndEventNode,
bpmnTimerEvent: BpmnTimerEventNode,
bpmnMessageEvent: BpmnMessageEventNode,
bpmnGateway: BpmnGatewayNode,
bpmnDataObject: BpmnDataObjectNode,
bpmnAnnotation: BpmnAnnotationNode,
bpmnPool: BpmnPoolNode,
bpmnLane: BpmnLaneNode,
bpmnGroup: BpmnGroupNode,
};
const edgeTypes = {
bpmnSequence: BpmnSequenceEdge,
bpmnMessage: BpmnMessageEdge,
bpmnAssociation: BpmnAssociationEdge,
};
/** Container node types that should not participate in BFS highlighting */
const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup"]);
function BpmnMarkerDefs() {
return (
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
<marker
id="bpmn-arrow-filled"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--edge-default, #666)"
/>
</marker>
<marker
id="bpmn-arrow-open"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10"
fill="none"
stroke="var(--edge-default, #666)"
strokeWidth={1.5}
/>
</marker>
</defs>
</svg>
);
}
function CanvasInner() {
const nodes = useGraphStore((s) => s.nodes);
@@ -21,18 +102,83 @@ function CanvasInner() {
const onNodesChange = useGraphStore((s) => s.onNodesChange);
const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
const onViewportChange = useGraphStore((s) => s.onViewportChange);
const highlightedNodeId = useGraphStore((s) => s.highlightedNodeId);
const setHighlightedNodeId = useGraphStore((s) => s.setHighlightedNodeId);
const setNodes = useGraphStore((s) => s.setNodes);
const setEdges = useGraphStore((s) => s.setEdges);
const { isLayouting } = useAutoLayout();
const clearHighlight = useCallback(() => {
if (!highlightedNodeId) return;
setHighlightedNodeId(null);
setNodes(nodes.map((n) => ({ ...n, className: undefined })));
setEdges(edges.map((e) => ({ ...e, className: undefined })));
}, [highlightedNodeId, nodes, edges, setHighlightedNodeId, setNodes, setEdges]);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
// Skip container nodes (pools, lanes, groups)
if (CONTAINER_TYPES.has(node.type ?? "")) return;
const store = useGraphStore.getState();
// Toggle off if clicking the same node
if (store.highlightedNodeId === node.id) {
store.setHighlightedNodeId(null);
store.setNodes(
store.nodes.map((n) => ({ ...n, className: undefined })),
);
store.setEdges(
store.edges.map((e) => ({ ...e, className: undefined })),
);
return;
}
// Compute BFS path from clicked node
const graphEdges = store.edges
.filter((e) => e.type !== "bpmnGroup")
.map((e) => ({ from: e.source, to: e.target }));
const { nodeSet, edgeSet } = bfsPath(node.id, graphEdges);
// Apply highlight/dim classes
store.setHighlightedNodeId(node.id);
store.setNodes(
store.nodes.map((n) => {
if (CONTAINER_TYPES.has(n.type ?? "")) {
return { ...n, className: undefined };
}
return {
...n,
className: nodeSet.has(n.id) ? "highlighted" : "dimmed",
};
}),
);
store.setEdges(
store.edges.map((e) => ({
...e,
className: edgeSet.has(`${e.source}->${e.target}`)
? "highlighted"
: "dimmed",
})),
);
},
[],
);
return (
<div className="w-full h-full">
<BpmnMarkerDefs />
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onViewportChange={onViewportChange}
onNodeClick={handleNodeClick}
onPaneClick={clearHighlight}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
colorMode="system"
proOptions={{ hideAttribution: true }}