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

@@ -27,6 +27,13 @@
--diagram-architecture: oklch(0.552 0.016 286);
--diagram-sequence: oklch(0.795 0.184 86);
--diagram-flowchart: oklch(0.645 0.246 16);
/* BPMN element-specific colors */
--bpmn-start-event: #2ecc71;
--bpmn-end-event: #e74c3c;
--bpmn-timer-event: #3498db;
--bpmn-message-event: #f39c12;
--bpmn-gateway: #3498db;
--bpmn-data-object: #f39c12;
}
.dark {
@@ -38,10 +45,204 @@
--node-hover: oklch(0.623 0.214 260 / 12%);
--edge-default: oklch(0.55 0.01 286);
--edge-selected: oklch(0.623 0.214 260);
--bpmn-start-event: #27ae60;
--bpmn-end-event: #c0392b;
--bpmn-timer-event: #5dade2;
--bpmn-message-event: #f5b041;
--bpmn-gateway: #5dade2;
--bpmn-data-object: #f5b041;
}
/* ELK layout animation — only active during auto-layout transitions */
.react-flow__node.layouting {
transition: transform 200ms ease-out;
}
/* ── BPMN Node Styles ─────────────────────────────────────────────────── */
.bpmn-activity {
background: var(--node-bg);
border: 1.5px solid var(--diagram-bpmn);
border-radius: 8px;
padding: 8px 12px;
min-width: 200px;
max-width: 280px;
font-size: 12px;
cursor: pointer;
}
.bpmn-activity-tag {
font-weight: 600;
font-size: 11px;
color: var(--diagram-bpmn);
margin-bottom: 4px;
}
.bpmn-activity-label {
color: var(--foreground);
line-height: 1.4;
}
.bpmn-subprocess {
background: var(--node-bg);
border: 1.5px solid var(--diagram-bpmn);
border-radius: 8px;
padding: 8px 12px 20px;
min-width: 200px;
max-width: 280px;
font-size: 12px;
position: relative;
cursor: pointer;
}
.bpmn-subprocess-marker {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
border: 1.5px solid var(--diagram-bpmn);
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--diagram-bpmn);
line-height: 1;
}
.bpmn-event-node {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.bpmn-event-label {
margin-top: 4px;
font-size: 11px;
color: var(--muted-foreground);
text-align: center;
max-width: 140px;
word-wrap: break-word;
line-height: 1.3;
}
.bpmn-gateway-node {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.bpmn-data-object-node {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.bpmn-annotation {
border-left: 2px solid var(--muted-foreground);
padding: 6px 10px;
font-size: 11px;
color: var(--muted-foreground);
max-width: 220px;
cursor: pointer;
}
.bpmn-annotation-text {
line-height: 1.4;
}
/* ── BPMN Pool & Lane Styles ──────────────────────────────────────────── */
.bpmn-pool {
border: 2px solid var(--node-border);
border-radius: 4px;
background: transparent;
pointer-events: none;
position: relative;
}
.bpmn-pool-label {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 32px;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
font-weight: 600;
font-size: 12px;
color: var(--foreground);
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid var(--node-border);
pointer-events: auto;
}
.bpmn-lane {
border-top: 1px solid var(--node-border);
background: transparent;
pointer-events: none;
position: relative;
}
.bpmn-lane-label {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 24px;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
font-size: 11px;
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
border-right: 1px dashed var(--node-border);
pointer-events: auto;
}
/* ── BPMN Group Styles ───────────────────────────────────────────────── */
.bpmn-group {
border: 2px dashed var(--node-border);
border-radius: 8px;
background: transparent;
pointer-events: none;
position: relative;
}
.bpmn-group-label {
position: absolute;
top: -10px;
left: 12px;
font-size: 11px;
font-weight: 600;
color: var(--muted-foreground);
background: var(--canvas-bg);
padding: 0 6px;
pointer-events: auto;
}
/* ── Path Highlighting ────────────────────────────────────────────────── */
.react-flow__node.dimmed,
.react-flow__edge.dimmed {
opacity: 0.2;
transition: opacity 200ms ease-out;
}
.react-flow__node.highlighted {
filter: drop-shadow(0 0 6px var(--diagram-bpmn));
transition: filter 200ms ease-out;
}
}

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 }}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { bfsPath } from "./bfs-path";
describe("bfsPath", () => {
const edges = [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "c", to: "d" },
{ from: "a", to: "e" },
{ from: "f", to: "g" },
];
it("should include start node", () => {
const result = bfsPath("a", edges);
expect(result.nodeSet.has("a")).toBe(true);
});
it("should find forward-connected nodes", () => {
const result = bfsPath("a", edges);
expect(result.nodeSet.has("b")).toBe(true);
expect(result.nodeSet.has("c")).toBe(true);
expect(result.nodeSet.has("d")).toBe(true);
expect(result.nodeSet.has("e")).toBe(true);
});
it("should find backward-connected nodes", () => {
const result = bfsPath("d", edges);
expect(result.nodeSet.has("c")).toBe(true);
expect(result.nodeSet.has("b")).toBe(true);
expect(result.nodeSet.has("a")).toBe(true);
});
it("should not include disconnected nodes", () => {
const result = bfsPath("a", edges);
expect(result.nodeSet.has("f")).toBe(false);
expect(result.nodeSet.has("g")).toBe(false);
});
it("should collect edge keys for the path", () => {
const result = bfsPath("b", edges);
expect(result.edgeSet.has("b->c")).toBe(true);
expect(result.edgeSet.has("c->d")).toBe(true);
expect(result.edgeSet.has("a->b")).toBe(true);
});
it("should not include disconnected edge keys", () => {
const result = bfsPath("a", edges);
expect(result.edgeSet.has("f->g")).toBe(false);
});
it("should handle empty edges", () => {
const result = bfsPath("x", []);
expect(result.nodeSet.size).toBe(1);
expect(result.nodeSet.has("x")).toBe(true);
expect(result.edgeSet.size).toBe(0);
});
it("should handle cycles without infinite loop", () => {
const cyclicEdges = [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "c", to: "a" },
];
const result = bfsPath("a", cyclicEdges);
expect(result.nodeSet.size).toBe(3);
expect(result.edgeSet.size).toBe(3);
});
it("should handle node with no connections in graph", () => {
const result = bfsPath("z", edges);
expect(result.nodeSet.size).toBe(1);
expect(result.nodeSet.has("z")).toBe(true);
expect(result.edgeSet.size).toBe(0);
});
});

View File

@@ -0,0 +1,56 @@
/**
* Bidirectional BFS from a start node to find all connected nodes and edges.
* Works with any graph structure (BPMN, flowchart, etc.).
*/
export function bfsPath(
startId: string,
edges: Array<{ from: string; to: string }>,
): { nodeSet: Set<string>; edgeSet: Set<string> } {
const forward: Record<string, Array<{ from: string; to: string }>> = {};
const backward: Record<string, Array<{ from: string; to: string }>> = {};
for (const e of edges) {
(forward[e.from] ??= []).push(e);
(backward[e.to] ??= []).push(e);
}
const nodeSet = new Set([startId]);
const edgeSet = new Set<string>();
// Forward BFS
let queue = [startId];
while (queue.length) {
const next: string[] = [];
for (const nid of queue) {
for (const e of forward[nid] ?? []) {
const key = `${e.from}->${e.to}`;
if (!edgeSet.has(key)) {
edgeSet.add(key);
nodeSet.add(e.to);
next.push(e.to);
}
}
}
queue = next;
}
// Backward BFS
queue = [startId];
const visited = new Set([startId]);
while (queue.length) {
const next: string[] = [];
for (const nid of queue) {
for (const e of backward[nid] ?? []) {
if (!visited.has(e.from)) {
visited.add(e.from);
edgeSet.add(`${e.from}->${e.to}`);
nodeSet.add(e.from);
next.push(e.from);
}
}
}
queue = next;
}
return { nodeSet, edgeSet };
}

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import { buildBpmnElkGraph, resolveBpmnPositions } from "./bpmn-layout";
import type { GraphData } from "../types/graph";
function createTestBpmnData(): GraphData {
return {
meta: {
version: "1.0",
title: "Test BPMN",
diagramType: "bpmn",
layoutDirection: "RIGHT",
edgeRouting: "ORTHOGONAL",
},
pools: [
{
id: "pool1",
label: "Main Pool",
lanes: [
{ id: "lane1", label: "Lane 1" },
{ id: "lane2", label: "Lane 2" },
],
},
],
nodes: [
{ id: "start", type: "bpmn:start-event", label: "Start", lane: "lane1" },
{
id: "task1",
type: "bpmn:activity",
label: "Do Work",
tag: "Task 1",
lane: "lane1",
},
{
id: "gw1",
type: "bpmn:gateway-exclusive",
label: "Decision?",
lane: "lane2",
},
{ id: "end", type: "bpmn:end-event", label: "End", lane: "lane2" },
{
id: "note1",
type: "bpmn:annotation",
label: "Free-floating note",
},
],
edges: [
{ id: "e1", from: "start", to: "task1" },
{ id: "e2", from: "task1", to: "gw1", label: "Go" },
{ id: "e3", from: "gw1", to: "end" },
],
};
}
describe("buildBpmnElkGraph", () => {
it("should build a root graph with pool hierarchy", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
expect(graph.id).toBe("root");
expect(graph.layoutOptions?.["elk.hierarchyHandling"]).toBe(
"INCLUDE_CHILDREN",
);
});
it("should create pool as a child of root", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
// root children: pool1 + free-floating note1
expect(graph.children?.length).toBe(2);
const poolChild = graph.children?.find((c) => c.id === "pool1");
expect(poolChild).toBeDefined();
});
it("should create lanes as children of pool", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
const poolChild = graph.children?.find((c) => c.id === "pool1");
expect(poolChild?.children?.length).toBe(2);
const lane1 = poolChild?.children?.find((c) => c.id === "lane1");
const lane2 = poolChild?.children?.find((c) => c.id === "lane2");
expect(lane1).toBeDefined();
expect(lane2).toBeDefined();
});
it("should place nodes in their assigned lanes", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
const poolChild = graph.children?.find((c) => c.id === "pool1");
const lane1 = poolChild?.children?.find((c) => c.id === "lane1");
const lane2 = poolChild?.children?.find((c) => c.id === "lane2");
const lane1NodeIds = lane1?.children?.map((c) => c.id);
expect(lane1NodeIds).toContain("start");
expect(lane1NodeIds).toContain("task1");
const lane2NodeIds = lane2?.children?.map((c) => c.id);
expect(lane2NodeIds).toContain("gw1");
expect(lane2NodeIds).toContain("end");
});
it("should place free-floating nodes at root level", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
const rootNodeIds = graph.children?.map((c) => c.id);
expect(rootNodeIds).toContain("note1");
});
it("should assign same-lane edges to lane container", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
// e1: start→task1, both in lane1 → belongs to lane1
const poolChild = graph.children?.find((c) => c.id === "pool1");
const lane1 = poolChild?.children?.find((c) => c.id === "lane1");
const lane1Edges = (lane1 as { edges?: Array<{ id: string }> }).edges;
const lane1EdgeIds = lane1Edges?.map((e) => e.id);
expect(lane1EdgeIds).toContain("e0"); // first edge
});
it("should assign cross-lane edges to pool container", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
// e2: task1(lane1)→gw1(lane2), cross-lane → belongs to pool1
const poolChild = graph.children?.find((c) => c.id === "pool1");
const poolEdges = (poolChild as { edges?: Array<{ id: string }> }).edges;
const poolEdgeIds = poolEdges?.map((e) => e.id);
expect(poolEdgeIds).toContain("e1"); // second edge
});
it("should use BPMN_SIZES dimensions for nodes", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data);
const poolChild = graph.children?.find((c) => c.id === "pool1");
const lane1 = poolChild?.children?.find((c) => c.id === "lane1");
const startNode = lane1?.children?.find((c) => c.id === "start");
expect(startNode?.width).toBe(36); // start-event width
expect(startNode?.height).toBe(36);
});
it("should handle data without pools", () => {
const data: GraphData = {
nodes: [
{ id: "a", type: "bpmn:activity", label: "A" },
{ id: "b", type: "bpmn:activity", label: "B" },
],
edges: [{ id: "e1", from: "a", to: "b" }],
};
const graph = buildBpmnElkGraph(data);
const childIds = graph.children?.map((c) => c.id);
expect(childIds).toContain("a");
expect(childIds).toContain("b");
});
it("should respect layout direction from options", () => {
const data = createTestBpmnData();
const graph = buildBpmnElkGraph(data, { direction: "DOWN" });
expect(graph.layoutOptions?.["elk.direction"]).toBe("DOWN");
});
});
describe("resolveBpmnPositions", () => {
it("should resolve absolute positions from nested structure", () => {
const mockElkResult = {
id: "root",
x: 0,
y: 0,
width: 800,
height: 600,
children: [
{
id: "pool1",
x: 10,
y: 10,
width: 700,
height: 500,
children: [
{
id: "lane1",
x: 5,
y: 5,
width: 690,
height: 240,
children: [
{ id: "start", x: 20, y: 30, width: 36, height: 36 },
],
},
],
},
],
};
const positions = resolveBpmnPositions(mockElkResult);
// root at (0,0)
expect(positions.get("root")).toEqual({
x: 0,
y: 0,
w: 800,
h: 600,
});
// pool1 at root offset (10,10)
expect(positions.get("pool1")).toEqual({
x: 10,
y: 10,
w: 700,
h: 500,
});
// lane1 at pool1 + (5,5) = (15, 15)
expect(positions.get("lane1")).toEqual({
x: 15,
y: 15,
w: 690,
h: 240,
});
// start at lane1 + (20,30) = (35, 45)
expect(positions.get("start")).toEqual({
x: 35,
y: 45,
w: 36,
h: 36,
});
});
it("should handle empty children", () => {
const mockElkResult = {
id: "root",
x: 0,
y: 0,
width: 100,
height: 100,
};
const positions = resolveBpmnPositions(mockElkResult);
expect(positions.size).toBe(1);
expect(positions.get("root")).toBeDefined();
});
});

View File

@@ -0,0 +1,408 @@
import type { ElkNode, ElkExtendedEdge } from "elkjs";
import type { Node, Edge } from "@xyflow/react";
import type { DiagramNode, GraphData } from "../types/graph";
import { getBpmnNodeSize, bareBpmnType, hasExternalLabel } from "../types/bpmn";
import type { ElkLayoutOptions } from "./elk-layout";
// ── BPMN ELK Node Builder ──────────────────────────────────────────────────
interface ElkNodeWithEdges extends ElkNode {
edges?: ElkExtendedEdge[];
labels?: Array<{
text: string;
width: number;
height: number;
layoutOptions?: Record<string, string>;
}>;
}
function buildBpmnElkNode(
node: DiagramNode,
): ElkNodeWithEdges {
const size = getBpmnNodeSize(node.type);
const elkNode: ElkNodeWithEdges = {
id: node.id,
width: node.w ?? size.w,
height: size.h,
};
if (hasExternalLabel(node.type) && node.label) {
const labelW = Math.min(node.label.length * 6.5 + 16, 160);
elkNode.labels = [
{
text: node.label,
width: labelW,
height: size.labelH || 20,
layoutOptions: {
"elk.nodeLabels.placement": "OUTSIDE V_BOTTOM H_CENTER",
},
},
];
}
return elkNode;
}
// ── Edge Container Resolution ──────────────────────────────────────────────
function resolveEdgeContainer(
fromId: string,
toId: string,
nodeToContainer: Map<string, { pool: string; lane: string }>,
): string {
const src = nodeToContainer.get(fromId);
const tgt = nodeToContainer.get(toId);
if (!src || !tgt) return "root";
if (src.pool === tgt.pool && src.lane === tgt.lane) return src.lane;
if (src.pool === tgt.pool) return src.pool;
return "root";
}
// ── Build Compound ELK Graph ───────────────────────────────────────────────
export function buildBpmnElkGraph(
graphData: GraphData,
options: Partial<ElkLayoutOptions> = {},
): ElkNodeWithEdges {
const direction = options.direction ?? graphData.meta?.layoutDirection ?? "RIGHT";
const edgeRouting = options.edgeRouting ?? graphData.meta?.edgeRouting ?? "ORTHOGONAL";
const nodeSpacing = options.nodeSpacing ?? 100;
const layerSpacing = options.layerSpacing ?? 250;
// Map nodes to their lane
const nodesByLane = new Map<string, DiagramNode[]>();
const freeNodes: DiagramNode[] = [];
for (const n of graphData.nodes) {
if (n.lane) {
const existing = nodesByLane.get(n.lane) ?? [];
existing.push(n);
nodesByLane.set(n.lane, existing);
} else {
freeNodes.push(n);
}
}
const elkChildren: ElkNodeWithEdges[] = [];
// Build pool > lane > node hierarchy
if (graphData.pools) {
for (const pool of graphData.pools) {
const laneChildren: ElkNodeWithEdges[] = [];
for (const lane of pool.lanes) {
const laneNodes = (nodesByLane.get(lane.id) ?? []).map((n) =>
buildBpmnElkNode(n),
);
laneChildren.push({
id: lane.id,
width: 0,
height: 0,
layoutOptions: {
"elk.padding": "[top=55,left=80,bottom=45,right=40]",
},
children: laneNodes.length
? laneNodes
: [{ id: `${lane.id}_placeholder`, width: 1, height: 1 }],
});
}
elkChildren.push({
id: pool.id,
width: 0,
height: 0,
layoutOptions: {
"elk.padding": "[top=30,left=40,bottom=30,right=30]",
},
children: laneChildren,
});
}
}
// Free-floating nodes (no lane assignment)
for (const n of freeNodes) {
elkChildren.push(buildBpmnElkNode(n));
}
// Build node-to-container map for edge resolution
const nodeToContainer = new Map<string, { pool: string; lane: string }>();
if (graphData.pools) {
for (const pool of graphData.pools) {
for (const lane of pool.lanes) {
for (const n of nodesByLane.get(lane.id) ?? []) {
nodeToContainer.set(n.id, { pool: pool.id, lane: lane.id });
}
}
}
}
// Build all edges with container assignment
const allEdges: (ElkExtendedEdge & { container: string })[] = [];
for (let i = 0; i < graphData.edges.length; i++) {
const e = graphData.edges[i];
const container = resolveEdgeContainer(e.from, e.to, nodeToContainer);
const elkEdge: ElkExtendedEdge & { container: string } = {
id: `e${i}`,
sources: [e.from],
targets: [e.to],
container,
};
if (e.label) {
(elkEdge as ElkNodeWithEdges).labels = [
{
text: e.label,
width: e.label.length * 7 + 12,
height: 18,
layoutOptions: {
"elk.edgeLabels.placement": "CENTER",
},
},
];
}
allEdges.push(elkEdge);
}
// Distribute edges to their containers
function attachEdges(node: ElkNodeWithEdges) {
const myEdges = allEdges.filter((e) => e.container === node.id);
if (myEdges.length) {
node.edges = myEdges;
}
if (node.children) {
for (const child of node.children as ElkNodeWithEdges[]) {
attachEdges(child);
}
}
}
const rootGraph: ElkNodeWithEdges = {
id: "root",
width: 0,
height: 0,
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": direction,
"elk.edgeRouting": edgeRouting,
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.spacing.nodeNode": String(nodeSpacing),
"elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
"elk.spacing.edgeNode": "70",
"elk.spacing.edgeEdge": "30",
"elk.spacing.componentComponent": "80",
"elk.spacing.portPort": "20",
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.layered.spacing.edgeNodeBetweenLayers": "70",
"elk.layered.spacing.edgeEdgeBetweenLayers": "30",
"elk.nodeLabels.placement": "OUTSIDE V_BOTTOM H_CENTER",
"elk.edgeLabels.placement": "CENTER",
"elk.spacing.labelLabel": "8",
"elk.spacing.edgeLabelSpacing": "6",
"elk.spacing.labelNode": "10",
"elk.spacing.labelPort": "6",
"elk.padding": "[top=30,left=30,bottom=30,right=30]",
},
children: elkChildren,
};
// Attach root-level edges
const rootEdges = allEdges.filter((e) => e.container === "root");
if (rootEdges.length) {
rootGraph.edges = rootEdges;
}
attachEdges(rootGraph);
return rootGraph;
}
// ── Recursive Position Resolution ──────────────────────────────────────────
export interface BpmnPosition {
x: number;
y: number;
w: number;
h: number;
}
export function resolveBpmnPositions(
node: ElkNode,
offsetX = 0,
offsetY = 0,
): Map<string, BpmnPosition> {
const positions = new Map<string, BpmnPosition>();
const ax = offsetX + (node.x ?? 0);
const ay = offsetY + (node.y ?? 0);
positions.set(node.id, {
x: ax,
y: ay,
w: node.width ?? 0,
h: node.height ?? 0,
});
for (const child of node.children ?? []) {
const childPositions = resolveBpmnPositions(child, ax, ay);
for (const [k, v] of childPositions) {
positions.set(k, v);
}
}
return positions;
}
// ── Convert ELK edge section to SVG path ───────────────────────────────────
interface ElkPoint {
x: number;
y: number;
}
interface ElkSection {
startPoint: ElkPoint;
endPoint: ElkPoint;
bendPoints?: ElkPoint[];
}
function elkSectionToPath(section: ElkSection, routing: string): string {
const pts: ElkPoint[] = [
section.startPoint,
...(section.bendPoints ?? []),
section.endPoint,
];
if (routing !== "SPLINES") {
return `M ${pts.map((p) => `${p.x} ${p.y}`).join(" L ")}`;
}
let d = `M ${pts[0].x} ${pts[0].y}`;
let i = 1;
while (i < pts.length) {
const rem = pts.length - i;
if (rem >= 3) {
d += ` C ${pts[i].x} ${pts[i].y}, ${pts[i + 1].x} ${pts[i + 1].y}, ${pts[i + 2].x} ${pts[i + 2].y}`;
i += 3;
} else if (rem === 2) {
d += ` Q ${pts[i].x} ${pts[i].y}, ${pts[i + 1].x} ${pts[i + 1].y}`;
i += 2;
} else {
d += ` L ${pts[i].x} ${pts[i].y}`;
i += 1;
}
}
return d;
}
// ── Resolve Edge Paths ─────────────────────────────────────────────────────
export interface ResolvedEdge {
id: string;
d: string;
midX: number;
midY: number;
}
export function resolveBpmnEdges(
node: ElkNode & { edges?: Array<ElkExtendedEdge & { sections?: ElkSection[] }> },
positions: Map<string, BpmnPosition>,
routing: string,
): ResolvedEdge[] {
const edges: ResolvedEdge[] = [];
const nodePos = positions.get(node.id);
const ox = nodePos?.x ?? 0;
const oy = nodePos?.y ?? 0;
if (node.edges) {
for (const e of node.edges) {
const sections = (e as unknown as { sections?: ElkSection[] }).sections;
if (!sections?.length) continue;
const pathParts = sections.map((sec) => {
const shifted: ElkSection = {
startPoint: { x: sec.startPoint.x + ox, y: sec.startPoint.y + oy },
endPoint: { x: sec.endPoint.x + ox, y: sec.endPoint.y + oy },
bendPoints: (sec.bendPoints ?? []).map((p) => ({
x: p.x + ox,
y: p.y + oy,
})),
};
return elkSectionToPath(shifted, routing);
});
const allPts: ElkPoint[] = [];
for (const sec of sections) {
allPts.push({ x: sec.startPoint.x + ox, y: sec.startPoint.y + oy });
for (const bp of sec.bendPoints ?? []) {
allPts.push({ x: bp.x + ox, y: bp.y + oy });
}
allPts.push({ x: sec.endPoint.x + ox, y: sec.endPoint.y + oy });
}
const mid = allPts[Math.floor(allPts.length / 2)];
// Use ELK-computed label position if available
let labelX = mid.x;
let labelY = mid.y;
const labels = (e as unknown as { labels?: Array<{ x?: number; y?: number; width?: number; height?: number }> }).labels;
if (labels?.length && labels[0].x != null) {
const lbl = labels[0];
labelX = (lbl.x ?? 0) + ox + (lbl.width ?? 0) / 2;
labelY = (lbl.y ?? 0) + oy + (lbl.height ?? 0) / 2;
}
edges.push({
id: e.id,
d: pathParts.join(" "),
midX: labelX,
midY: labelY,
});
}
}
if (node.children) {
for (const child of node.children) {
edges.push(
...resolveBpmnEdges(
child as ElkNode & { edges?: Array<ElkExtendedEdge & { sections?: ElkSection[] }> },
positions,
routing,
),
);
}
}
return edges;
}
// ── Apply BPMN Layout to @xyflow/react Nodes ──────────────────────────────
export function applyBpmnPositions(
positions: Map<string, BpmnPosition>,
originalNodes: Node[],
): Node[] {
return originalNodes.map((node) => {
const data = node.data as unknown as DiagramNode;
if (data.manuallyPositioned) return node;
const pos = positions.get(node.id);
if (!pos) return node;
// For pool/lane group nodes, also set the computed width/height
if (node.type === "bpmnPool" || node.type === "bpmnLane") {
return {
...node,
position: { x: pos.x, y: pos.y },
style: { ...node.style, width: pos.w, height: pos.h },
};
}
return {
...node,
position: { x: pos.x, y: pos.y },
};
});
}

View File

@@ -1,8 +1,13 @@
import type { Node, Edge } from "@xyflow/react";
import type { ElkNode, ElkExtendedEdge } from "elkjs";
import type { DiagramNode } from "../types/graph";
import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph";
import type { ElkWorkerResponse } from "./elk-worker";
import {
buildBpmnElkGraph,
resolveBpmnPositions,
applyBpmnPositions,
} from "./bpmn-layout";
// ── Layout Options ──────────────────────────────────────────────────────────
@@ -123,6 +128,55 @@ export function terminateWorker(): void {
}
}
// ── BPMN Compound Layout Detection ──────────────────────────────────────────
/** Reconstruct GraphData from @xyflow nodes for compound BPMN layout. */
function flowToBpmnGraphData(
nodes: Node[],
edges: Edge[],
): GraphData {
const poolNodes = nodes.filter((n) => n.type === "bpmnPool");
const laneNodes = nodes.filter((n) => n.type === "bpmnLane");
const contentNodes = nodes.filter(
(n) =>
n.type !== "bpmnPool" &&
n.type !== "bpmnLane" &&
n.type !== "bpmnGroup",
);
return {
pools: poolNodes.map((pool) => ({
id: pool.id,
label: String((pool.data as Record<string, unknown>).label ?? ""),
lanes: laneNodes
.filter((lane) => lane.parentId === pool.id)
.map((lane) => ({
id: lane.id,
label: String(
(lane.data as Record<string, unknown>).label ?? "",
),
})),
})),
nodes: contentNodes.map((n) => {
const d = n.data as unknown as DiagramNode;
return {
...d,
id: n.id,
lane: d.lane ?? n.parentId,
} as DiagramNode;
}),
edges: edges.map((e) => ({
id: e.id,
from: e.source,
to: e.target,
label: typeof e.label === "string" ? e.label : undefined,
type: (e.data as Record<string, unknown> | undefined)?.type as
| string
| undefined,
})),
};
}
// ── Main Layout Function ────────────────────────────────────────────────────
export function computeLayout(
@@ -142,7 +196,17 @@ export function computeLayout(
pendingReject = null;
}
const elkGraph = buildElkGraph(nodes, edges, options);
// Detect BPMN compound layout (pools present in @xyflow nodes)
const isCompound = nodes.some((n) => n.type === "bpmnPool");
let elkGraph: ElkNode;
if (isCompound) {
const graphData = flowToBpmnGraphData(nodes, edges);
elkGraph = buildBpmnElkGraph(graphData, options);
} else {
elkGraph = buildElkGraph(nodes, edges, options);
}
const w = getWorker();
let settled = false;
@@ -163,7 +227,12 @@ export function computeLayout(
w.removeEventListener("message", handler);
if (event.data.type === "result" && event.data.graph) {
resolve(resolvePositions(event.data.graph, nodes));
if (isCompound) {
const positions = resolveBpmnPositions(event.data.graph);
resolve(applyBpmnPositions(positions, nodes));
} else {
resolve(resolvePositions(event.data.graph, nodes));
}
} else {
reject(new Error(event.data.message ?? "ELK layout failed"));
}

View File

@@ -136,9 +136,125 @@ describe("graphToFlow", () => {
}));
const result = graphToFlow({ nodes, edges: [] });
expect(result.nodes).toHaveLength(6);
result.nodes.forEach((node) => {
expect(node.type).toBe("default");
});
// bpmn: prefix resolves to BPMN node type even without diagramType context
expect(result.nodes[0]!.type).toBe("bpmnActivity");
// Non-BPMN types without diagramType context stay default
expect(result.nodes[1]!.type).toBe("default");
expect(result.nodes[5]!.type).toBe("default");
});
it("should resolve BPMN node types when diagramType is bpmn", () => {
const data: GraphData = {
meta: {
version: "1.0",
title: "BPMN Test",
diagramType: "bpmn",
},
nodes: [
{ id: "n1", type: "activity", label: "Task" },
{ id: "n2", type: "bpmn:gateway-exclusive", label: "Decision?" },
{ id: "n3", type: "start-event", label: "Start" },
],
edges: [],
};
const result = graphToFlow(data);
expect(result.nodes[0]!.type).toBe("bpmnActivity");
expect(result.nodes[1]!.type).toBe("bpmnGateway");
expect(result.nodes[2]!.type).toBe("bpmnStartEvent");
});
it("should resolve BPMN edge types when diagramType is bpmn", () => {
const data: GraphData = {
meta: {
version: "1.0",
title: "BPMN Edge Test",
diagramType: "bpmn",
},
nodes: [
{ id: "a", type: "activity", label: "A" },
{ id: "b", type: "activity", label: "B" },
{ id: "c", type: "activity", label: "C" },
],
edges: [
{ id: "e1", from: "a", to: "b", type: "sequence" },
{ id: "e2", from: "a", to: "c", type: "message" },
{ id: "e3", from: "b", to: "c", type: "association" },
],
};
const result = graphToFlow(data);
expect(result.edges[0]!.type).toBe("bpmnSequence");
expect(result.edges[1]!.type).toBe("bpmnMessage");
expect(result.edges[2]!.type).toBe("bpmnAssociation");
});
it("should create pool and lane nodes for BPMN with pools", () => {
const data: GraphData = {
meta: {
version: "1.0",
title: "Pool Test",
diagramType: "bpmn",
},
pools: [
{
id: "pool1",
label: "Pool",
lanes: [{ id: "lane1", label: "Lane 1" }],
},
],
nodes: [{ id: "n1", type: "activity", label: "Task", lane: "lane1" }],
edges: [],
};
const result = graphToFlow(data);
// pool + lane + content node = 3
expect(result.nodes).toHaveLength(3);
const poolNode = result.nodes.find((n) => n.id === "pool1");
expect(poolNode?.type).toBe("bpmnPool");
const laneNode = result.nodes.find((n) => n.id === "lane1");
expect(laneNode?.type).toBe("bpmnLane");
expect(laneNode?.parentId).toBe("pool1");
const contentNode = result.nodes.find((n) => n.id === "n1");
expect(contentNode?.parentId).toBe("lane1");
});
it("should filter pool/lane nodes from flowToGraph output", () => {
const nodes = [
{ id: "pool1", type: "bpmnPool", position: { x: 0, y: 0 }, data: { label: "Pool" } },
{ id: "lane1", type: "bpmnLane", position: { x: 0, y: 0 }, data: { label: "Lane" } },
{ id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } },
];
const result = flowToGraph(nodes, []);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0]!.id).toBe("n1");
});
it("should preserve pools in flowToGraph roundtrip", () => {
const nodes = [
{ id: "pool1", type: "bpmnPool", position: { x: 0, y: 0 }, data: { label: "Main Pool" } },
{ id: "lane1", type: "bpmnLane", position: { x: 0, y: 0 }, parentId: "pool1", data: { label: "Lane A" } },
{ id: "lane2", type: "bpmnLane", position: { x: 0, y: 0 }, parentId: "pool1", data: { label: "Lane B" } },
{ id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } },
];
const result = flowToGraph(nodes, []);
expect(result.pools).toBeDefined();
expect(result.pools).toHaveLength(1);
expect(result.pools![0]!.id).toBe("pool1");
expect(result.pools![0]!.label).toBe("Main Pool");
expect(result.pools![0]!.lanes).toHaveLength(2);
expect(result.pools![0]!.lanes[0]!.id).toBe("lane1");
});
it("should preserve groups in flowToGraph roundtrip", () => {
const nodes = [
{ id: "g1", type: "bpmnGroup", position: { x: 0, y: 0 }, data: { label: "Group 1", color: "#ff0000" } },
{ id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } },
];
const result = flowToGraph(nodes, []);
expect(result.groups).toBeDefined();
expect(result.groups).toHaveLength(1);
expect(result.groups![0]!.id).toBe("g1");
expect(result.groups![0]!.label).toBe("Group 1");
expect(result.groups![0]!.color).toBe("#ff0000");
expect(result.nodes).toHaveLength(1);
});
});

View File

@@ -1,10 +1,47 @@
import type { Node, Edge } from "@xyflow/react";
import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph";
import type {
DiagramNode,
DiagramEdge,
DiagramType,
GraphData,
} from "../types/graph";
import {
resolveBpmnNodeType,
resolveBpmnEdgeType,
} from "../types/bpmn/constants";
export function graphNodeToFlowNode(node: DiagramNode): Node {
// ── Node Type Resolution ───────────────────────────────────────────────────
function resolveFlowNodeType(
diagramType: DiagramType | undefined,
nodeType: string,
): string {
if (diagramType === "bpmn" || nodeType.startsWith("bpmn:")) {
return resolveBpmnNodeType(nodeType);
}
// Future: er, orgchart, architecture, sequence, flowchart
return "default";
}
function resolveFlowEdgeType(
diagramType: DiagramType | undefined,
edgeType: string | undefined,
): string {
if (diagramType === "bpmn") {
return resolveBpmnEdgeType(edgeType);
}
return "default";
}
// ── Graph → Flow Conversion ────────────────────────────────────────────────
export function graphNodeToFlowNode(
node: DiagramNode,
diagramType?: DiagramType,
): Node {
return {
id: node.id,
type: "default",
type: resolveFlowNodeType(diagramType, node.type),
position: node.position ?? { x: 0, y: 0 },
data: {
...node,
@@ -13,24 +50,148 @@ export function graphNodeToFlowNode(node: DiagramNode): Node {
};
}
export function graphEdgeToFlowEdge(edge: DiagramEdge): Edge {
export function graphEdgeToFlowEdge(
edge: DiagramEdge,
diagramType?: DiagramType,
): Edge {
return {
id: edge.id,
source: edge.from,
target: edge.to,
label: edge.label,
type: "default",
type: resolveFlowEdgeType(diagramType, edge.type),
data: { ...edge },
};
}
/** Convert BPMN pools/lanes into @xyflow/react group nodes.
* Also sets parentId on child nodes for @xyflow/react grouping. */
function createPoolLaneNodes(
data: GraphData,
): { poolLaneNodes: Node[]; childParentMap: Map<string, string> } {
const poolLaneNodes: Node[] = [];
const childParentMap = new Map<string, string>();
if (!data.pools) return { poolLaneNodes, childParentMap };
// Build lane lookup from nodes
const laneIds = new Set<string>();
for (const pool of data.pools) {
for (const lane of pool.lanes) {
laneIds.add(lane.id);
}
}
// Map each node to its lane parent
for (const n of data.nodes) {
if (n.lane && laneIds.has(n.lane)) {
childParentMap.set(n.id, n.lane);
}
}
for (const pool of data.pools) {
poolLaneNodes.push({
id: pool.id,
type: "bpmnPool",
position: { x: 0, y: 0 },
data: { label: pool.label, type: "bpmn:pool" },
style: { width: 100, height: 100 },
});
for (const lane of pool.lanes) {
poolLaneNodes.push({
id: lane.id,
type: "bpmnLane",
position: { x: 0, y: 0 },
parentId: pool.id,
data: { label: lane.label, type: "bpmn:lane" },
style: { width: 100, height: 100 },
});
}
}
return { poolLaneNodes, childParentMap };
}
/** Convert BPMN groups into @xyflow/react group nodes. */
function createGroupNodes(
data: GraphData,
): { groupNodes: Node[]; groupChildMap: Map<string, string> } {
const groupNodes: Node[] = [];
const groupChildMap = new Map<string, string>();
if (!data.groups) return { groupNodes, groupChildMap };
for (const group of data.groups) {
groupNodes.push({
id: group.id,
type: "bpmnGroup",
position: { x: 0, y: 0 },
data: { label: group.label, type: "bpmn:group", color: group.color },
style: { width: 100, height: 100 },
});
}
// Map nodes to their group
for (const n of data.nodes) {
if (n.group && data.groups.some((g) => g.id === n.group)) {
groupChildMap.set(n.id, n.group);
}
}
return { groupNodes, groupChildMap };
}
export function graphToFlow(data: GraphData): { nodes: Node[]; edges: Edge[] } {
const diagramType = data.meta?.diagramType;
if (diagramType === "bpmn") {
const containerNodes: Node[] = [];
const childParentMap = new Map<string, string>();
// Create pool/lane group nodes if present
if (data.pools?.length) {
const { poolLaneNodes, childParentMap: plMap } =
createPoolLaneNodes(data);
containerNodes.push(...poolLaneNodes);
for (const [k, v] of plMap) childParentMap.set(k, v);
}
// Create group nodes if present
if (data.groups?.length) {
const { groupNodes, groupChildMap } = createGroupNodes(data);
containerNodes.push(...groupNodes);
for (const [k, v] of groupChildMap) {
// Lane parentId takes precedence over group parentId
if (!childParentMap.has(k)) {
childParentMap.set(k, v);
}
}
}
const contentNodes = (data.nodes ?? []).map((node) => {
const flowNode = graphNodeToFlowNode(node, diagramType);
const parentId = childParentMap.get(node.id);
if (parentId) {
flowNode.parentId = parentId;
}
return flowNode;
});
return {
nodes: [...containerNodes, ...contentNodes],
edges: (data.edges ?? []).map((e) => graphEdgeToFlowEdge(e, diagramType)),
};
}
return {
nodes: (data.nodes ?? []).map(graphNodeToFlowNode),
edges: (data.edges ?? []).map(graphEdgeToFlowEdge),
nodes: (data.nodes ?? []).map((n) => graphNodeToFlowNode(n, diagramType)),
edges: (data.edges ?? []).map((e) => graphEdgeToFlowEdge(e, diagramType)),
};
}
// ── Flow → Graph Conversion ────────────────────────────────────────────────
export function flowNodeToGraphNode(node: Node): DiagramNode {
const data = node.data as unknown as DiagramNode & { label: string };
return {
@@ -68,9 +229,54 @@ export function flowToGraph(
edges: Edge[],
meta?: GraphData["meta"],
): GraphData {
const containerTypes = new Set([
"bpmnPool",
"bpmnLane",
"bpmnGroup",
]);
// Reconstruct pools from pool/lane group nodes
const poolNodes = nodes.filter((n) => n.type === "bpmnPool");
const laneNodes = nodes.filter((n) => n.type === "bpmnLane");
const groupNodes = nodes.filter((n) => n.type === "bpmnGroup");
const pools =
poolNodes.length > 0
? poolNodes.map((pool) => ({
id: pool.id,
label: String(
(pool.data as Record<string, unknown>).label ?? "",
),
lanes: laneNodes
.filter((lane) => lane.parentId === pool.id)
.map((lane) => ({
id: lane.id,
label: String(
(lane.data as Record<string, unknown>).label ?? "",
),
})),
}))
: undefined;
const groups =
groupNodes.length > 0
? groupNodes.map((g) => {
const d = g.data as Record<string, unknown>;
return {
id: g.id,
label: String(d.label ?? ""),
...(d.color ? { color: String(d.color) } : {}),
};
})
: undefined;
return {
meta,
nodes: nodes.map(flowNodeToGraphNode),
nodes: nodes
.filter((n) => !containerTypes.has(n.type ?? ""))
.map(flowNodeToGraphNode),
edges: edges.map(flowEdgeToGraphEdge),
...(pools && { pools }),
...(groups && { groups }),
};
}

View File

@@ -156,4 +156,27 @@ describe("useGraphStore", () => {
expect(useGraphStore.getState().isLayouting).toBe(false);
});
});
describe("highlightedNodeId", () => {
it("should default to null", () => {
expect(useGraphStore.getState().highlightedNodeId).toBeNull();
});
it("should set highlighted node id", () => {
useGraphStore.getState().setHighlightedNodeId("n1");
expect(useGraphStore.getState().highlightedNodeId).toBe("n1");
});
it("should clear highlighted node id", () => {
useGraphStore.getState().setHighlightedNodeId("n1");
useGraphStore.getState().setHighlightedNodeId(null);
expect(useGraphStore.getState().highlightedNodeId).toBeNull();
});
it("should reset highlighted node id on reset()", () => {
useGraphStore.getState().setHighlightedNodeId("n1");
useGraphStore.getState().reset();
expect(useGraphStore.getState().highlightedNodeId).toBeNull();
});
});
});

View File

@@ -22,6 +22,7 @@ interface GraphState {
layoutDirection: LayoutDirection;
edgeRouting: EdgeRouting;
isLayouting: boolean;
highlightedNodeId: string | null;
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
onNodesChange: OnNodesChange;
@@ -30,6 +31,7 @@ interface GraphState {
setLayoutDirection: (direction: LayoutDirection) => void;
setEdgeRouting: (routing: EdgeRouting) => void;
setIsLayouting: (isLayouting: boolean) => void;
setHighlightedNodeId: (id: string | null) => void;
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
reset: () => void;
}
@@ -43,6 +45,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
layoutDirection: "DOWN",
edgeRouting: "ORTHOGONAL",
isLayouting: false,
highlightedNodeId: null,
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
setEdges: (edges) => set({ edges }),
@@ -63,6 +66,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
setLayoutDirection: (layoutDirection) => set({ layoutDirection }),
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
setIsLayouting: (isLayouting) => set({ isLayouting }),
setHighlightedNodeId: (highlightedNodeId) => set({ highlightedNodeId }),
initializeFromGraphData: (nodes, edges) => {
set({ nodes, edges, nodeCount: nodes.length });
@@ -78,6 +82,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
layoutDirection: "DOWN",
edgeRouting: "ORTHOGONAL",
isLayouting: false,
highlightedNodeId: null,
});
},
}));

View File

@@ -0,0 +1,29 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnActivityNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-activity">
{d.tag && <div className="bpmn-activity-tag">{d.tag}</div>}
<div className="bpmn-activity-label">{d.label}</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnAnnotationNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-annotation">
<div className="bpmn-annotation-text">{d.label}</div>
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { BaseEdge, getSmoothStepPath } from "@xyflow/react";
import type { EdgeProps } from "@xyflow/react";
export function BpmnAssociationEdge(props: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
});
return (
<BaseEdge
id={props.id}
path={edgePath}
style={{
stroke: "var(--edge-default)",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
label={props.label}
labelStyle={{ fill: "var(--foreground)", fontSize: 11 }}
labelBgStyle={{
fill: "var(--node-bg)",
fillOpacity: 0.8,
}}
labelShowBg
/>
);
}

View File

@@ -0,0 +1,46 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnDataObjectNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
const w = 40;
const h = 50;
const fold = 10;
const bodyPath = `M0,0 L${w - fold},0 L${w},${fold} L${w},${h} L0,${h} Z`;
const foldPath = `M${w - fold},0 L${w - fold},${fold} L${w},${fold}`;
return (
<div className="bpmn-data-object-node">
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<path
d={bodyPath}
fill="var(--node-bg)"
style={{ stroke: "var(--bpmn-data-object)", strokeWidth: 1.5 }}
/>
<path
d={foldPath}
fill="none"
style={{ stroke: "var(--bpmn-data-object)", strokeWidth: 1.5 }}
/>
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnEndEventNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-event-node">
<svg width={36} height={36} viewBox="0 0 36 36">
<circle
cx={18}
cy={18}
r={17}
fill="none"
style={{ stroke: "var(--bpmn-end-event)", strokeWidth: 3.5 }}
/>
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
function GatewayMarker({ type }: { type: string }) {
const bare = type.startsWith("bpmn:") ? type.slice(5) : type;
if (bare === "gateway-exclusive") {
return (
<g>
<line
x1={17}
y1={17}
x2={33}
y2={33}
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 3 }}
/>
<line
x1={33}
y1={17}
x2={17}
y2={33}
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 3 }}
/>
</g>
);
}
if (bare === "gateway-parallel") {
return (
<g>
<line
x1={25}
y1={16}
x2={25}
y2={34}
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 3 }}
/>
<line
x1={16}
y1={25}
x2={34}
y2={25}
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 3 }}
/>
</g>
);
}
// gateway-inclusive
return (
<circle
cx={25}
cy={25}
r={9}
fill="none"
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 2.5 }}
/>
);
}
export function BpmnGatewayNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-gateway-node">
<svg width={50} height={50} viewBox="0 0 50 50">
<polygon
points="25,1 49,25 25,49 1,25"
fill="var(--node-bg)"
style={{ stroke: "var(--bpmn-gateway)", strokeWidth: 2 }}
/>
<GatewayMarker type={d.type} />
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Bottom}
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnGroupNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div
className="bpmn-group"
style={{
width: "100%",
height: "100%",
...(d.color ? { borderColor: d.color } : {}),
}}
>
{d.label && <div className="bpmn-group-label">{d.label}</div>}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnLaneNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-lane" style={{ width: "100%", height: "100%" }}>
<div className="bpmn-lane-label">{d.label}</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { BaseEdge, getSmoothStepPath } from "@xyflow/react";
import type { EdgeProps } from "@xyflow/react";
export function BpmnMessageEdge(props: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
});
return (
<BaseEdge
id={props.id}
path={edgePath}
markerEnd="url(#bpmn-arrow-open)"
style={{
stroke: "var(--edge-default)",
strokeWidth: 1.5,
strokeDasharray: "8 4",
}}
label={props.label}
labelStyle={{ fill: "var(--foreground)", fontSize: 11 }}
labelBgStyle={{
fill: "var(--node-bg)",
fillOpacity: 0.8,
}}
labelShowBg
/>
);
}

View File

@@ -0,0 +1,58 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnMessageEventNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-event-node">
<svg width={36} height={36} viewBox="0 0 36 36">
<circle
cx={18}
cy={18}
r={17}
fill="none"
style={{ stroke: "var(--bpmn-message-event)", strokeWidth: 2 }}
/>
<circle
cx={18}
cy={18}
r={13}
fill="none"
style={{ stroke: "var(--bpmn-message-event)", strokeWidth: 1 }}
/>
<rect
x={10}
y={13}
width={16}
height={11}
fill="none"
rx={1}
style={{ stroke: "var(--bpmn-message-event)", strokeWidth: 1.2 }}
/>
<polyline
points="10,13 18,19 26,13"
fill="none"
style={{ stroke: "var(--bpmn-message-event)", strokeWidth: 1.2 }}
/>
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnPoolNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-pool" style={{ width: "100%", height: "100%" }}>
<div className="bpmn-pool-label">{d.label}</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { BaseEdge, getSmoothStepPath } from "@xyflow/react";
import type { EdgeProps } from "@xyflow/react";
export function BpmnSequenceEdge(props: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
});
return (
<BaseEdge
id={props.id}
path={edgePath}
markerEnd="url(#bpmn-arrow-filled)"
style={{ stroke: "var(--edge-default)", strokeWidth: 1.5 }}
label={props.label}
labelStyle={{ fill: "var(--foreground)", fontSize: 11 }}
labelBgStyle={{
fill: "var(--node-bg)",
fillOpacity: 0.8,
}}
labelShowBg
/>
);
}

View File

@@ -0,0 +1,37 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnStartEventNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-event-node">
<svg width={36} height={36} viewBox="0 0 36 36">
<circle
cx={18}
cy={18}
r={17}
fill="none"
style={{ stroke: "var(--bpmn-start-event)", strokeWidth: 2 }}
/>
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnSubprocessNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-subprocess">
{d.tag && <div className="bpmn-activity-tag">{d.tag}</div>}
<div className="bpmn-activity-label">{d.label}</div>
<div className="bpmn-subprocess-marker">+</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
export function BpmnTimerEventNode({ data }: NodeProps) {
const d = data as unknown as DiagramNode & { label: string };
return (
<div className="bpmn-event-node">
<svg width={36} height={36} viewBox="0 0 36 36">
<circle
cx={18}
cy={18}
r={17}
fill="none"
style={{ stroke: "var(--bpmn-timer-event)", strokeWidth: 2 }}
/>
<circle
cx={18}
cy={18}
r={13}
fill="none"
style={{ stroke: "var(--bpmn-timer-event)", strokeWidth: 1 }}
/>
<line
x1={18}
y1={18}
x2={18}
y2={11}
style={{ stroke: "var(--bpmn-timer-event)", strokeWidth: 1.5 }}
/>
<line
x1={18}
y1={18}
x2={23}
y2={20}
style={{ stroke: "var(--bpmn-timer-event)", strokeWidth: 1.5 }}
/>
</svg>
{d.label && <div className="bpmn-event-label">{d.label}</div>}
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,90 @@
/** BPMN node dimensions for ELK layout spacing.
* Ported from Flexicar BPMN_SIZES.
* - w/h: visual shape dimensions
* - labelH: space reserved for labels below the shape (gateways, events, data-objects)
*/
export const BPMN_SIZES: Record<
string,
{ w: number; h: number; labelH: number }
> = {
"start-event": { w: 36, h: 36, labelH: 32 },
"end-event": { w: 36, h: 36, labelH: 32 },
"event-timer": { w: 36, h: 36, labelH: 32 },
"event-message": { w: 36, h: 36, labelH: 32 },
"gateway-exclusive": { w: 50, h: 50, labelH: 40 },
"gateway-parallel": { w: 50, h: 50, labelH: 40 },
"gateway-inclusive": { w: 50, h: 50, labelH: 40 },
"data-object": { w: 40, h: 50, labelH: 40 },
annotation: { w: 220, h: 50, labelH: 0 },
activity: { w: 240, h: 76, labelH: 0 },
subprocess: { w: 240, h: 86, labelH: 0 },
};
/** Map DiagramNode.type (with or without bpmn: prefix) to @xyflow/react node type string */
export function resolveBpmnNodeType(type: string): string {
const bare = type.startsWith("bpmn:") ? type.slice(5) : type;
switch (bare) {
case "activity":
return "bpmnActivity";
case "subprocess":
return "bpmnSubprocess";
case "start-event":
return "bpmnStartEvent";
case "end-event":
return "bpmnEndEvent";
case "event-timer":
return "bpmnTimerEvent";
case "event-message":
return "bpmnMessageEvent";
case "gateway-exclusive":
case "gateway-parallel":
case "gateway-inclusive":
return "bpmnGateway";
case "data-object":
return "bpmnDataObject";
case "annotation":
return "bpmnAnnotation";
default:
return "bpmnActivity";
}
}
/** Map DiagramEdge.type to @xyflow/react edge type string */
export function resolveBpmnEdgeType(type: string | undefined): string {
switch (type) {
case "message":
return "bpmnMessage";
case "association":
return "bpmnAssociation";
case "sequence":
default:
return "bpmnSequence";
}
}
/** Strip the bpmn: prefix to get the bare BPMN type for size lookup */
export function bareBpmnType(type: string): string {
return type.startsWith("bpmn:") ? type.slice(5) : type;
}
/** Get BPMN node size, falling back to activity dimensions */
export function getBpmnNodeSize(type: string): {
w: number;
h: number;
labelH: number;
} {
const bare = bareBpmnType(type);
return BPMN_SIZES[bare] ?? BPMN_SIZES["activity"];
}
/** Whether a BPMN type has its label rendered externally (below the shape) */
export function hasExternalLabel(type: string): boolean {
const bare = bareBpmnType(type);
return (
bare.startsWith("gateway") ||
bare.startsWith("event") ||
bare === "start-event" ||
bare === "end-event" ||
bare === "data-object"
);
}

View File

@@ -0,0 +1,28 @@
// BPMN constants and type helpers
export {
BPMN_SIZES,
resolveBpmnNodeType,
resolveBpmnEdgeType,
bareBpmnType,
getBpmnNodeSize,
hasExternalLabel,
} from "./constants";
// BPMN node components
export { BpmnActivityNode } from "./BpmnActivityNode";
export { BpmnSubprocessNode } from "./BpmnSubprocessNode";
export { BpmnStartEventNode } from "./BpmnStartEventNode";
export { BpmnEndEventNode } from "./BpmnEndEventNode";
export { BpmnTimerEventNode } from "./BpmnTimerEventNode";
export { BpmnMessageEventNode } from "./BpmnMessageEventNode";
export { BpmnGatewayNode } from "./BpmnGatewayNode";
export { BpmnDataObjectNode } from "./BpmnDataObjectNode";
export { BpmnAnnotationNode } from "./BpmnAnnotationNode";
export { BpmnPoolNode } from "./BpmnPoolNode";
export { BpmnLaneNode } from "./BpmnLaneNode";
export { BpmnGroupNode } from "./BpmnGroupNode";
// BPMN edge components
export { BpmnSequenceEdge } from "./BpmnSequenceEdge";
export { BpmnMessageEdge } from "./BpmnMessageEdge";
export { BpmnAssociationEdge } from "./BpmnAssociationEdge";