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