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:
@@ -0,0 +1,790 @@
|
|||||||
|
# Story 2.3: BPMN Diagram Type Renderer
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
|
||||||
|
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
As a user,
|
||||||
|
I want to create and view BPMN process diagrams with standard notation,
|
||||||
|
so that I can model business processes with pools, lanes, gateways, events, and flows.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. **Given** I open or create a BPMN diagram, **When** the canvas renders, **Then** I see BPMN-standard visual elements: start events (green circle), end events (red bold circle), timer events, message events, exclusive gateways (X diamond), parallel gateways (+ diamond), inclusive gateways (O diamond), activities (rounded rectangles), subprocesses (rounded rectangles with + marker), **And** pools render as large labeled containers with lane subdivisions.
|
||||||
|
|
||||||
|
2. **Given** a BPMN diagram has pools and lanes, **When** auto-layout runs, **Then** ELK.js uses compound/hierarchical layout placing nodes within their assigned lanes, **And** edges route correctly between lanes and pools with proper crossing minimization.
|
||||||
|
|
||||||
|
3. **Given** a BPMN diagram has different edge types, **When** rendered, **Then** sequence flows show solid arrows, message flows show dashed open arrows, associations show dotted lines.
|
||||||
|
|
||||||
|
4. **Given** I click on a BPMN node, **When** the path highlighting activates, **Then** connected nodes and edges in the BFS path are highlighted, **And** unconnected elements are dimmed (opacity reduction).
|
||||||
|
|
||||||
|
## Tasks / Subtasks
|
||||||
|
|
||||||
|
- [x] Task 1: Create BPMN node size constants and type registry (AC: #1)
|
||||||
|
- [x] 1.1: Create `apps/web/src/modules/diagram/types/bpmn/constants.ts` — `BPMN_SIZES` map with dimensions per BPMN node type (start-event, end-event, event-timer, event-message, gateway-exclusive, gateway-parallel, gateway-inclusive, data-object, annotation, activity, subprocess)
|
||||||
|
- [x] 1.2: Create `apps/web/src/modules/diagram/types/bpmn/index.ts` — export all BPMN node components, constants, and type helpers
|
||||||
|
|
||||||
|
- [x] Task 2: Create custom @xyflow/react BPMN node components (AC: #1)
|
||||||
|
- [x] 2.1: Create `BpmnActivityNode.tsx` — rounded rectangle with tag header and label body, handle left/right/top/bottom
|
||||||
|
- [x] 2.2: Create `BpmnSubprocessNode.tsx` — same as activity but with a `+` marker at the bottom center
|
||||||
|
- [x] 2.3: Create `BpmnStartEventNode.tsx` — green circle (stroke #2ecc71, strokeWidth 2) with label below
|
||||||
|
- [x] 2.4: Create `BpmnEndEventNode.tsx` — red bold circle (stroke #e74c3c, strokeWidth 3.5) with label below
|
||||||
|
- [x] 2.5: Create `BpmnTimerEventNode.tsx` — blue double circle with clock hands inside (stroke #3498db) and label below
|
||||||
|
- [x] 2.6: Create `BpmnMessageEventNode.tsx` — orange double circle with envelope inside (stroke #f39c12) and label below
|
||||||
|
- [x] 2.7: Create `BpmnGatewayNode.tsx` — diamond shape with inner marker: X for exclusive, + for parallel, O for inclusive (stroke #3498db)
|
||||||
|
- [x] 2.8: Create `BpmnDataObjectNode.tsx` — document shape with folded corner (stroke #f39c12)
|
||||||
|
- [x] 2.9: Create `BpmnAnnotationNode.tsx` — text annotation with left bracket border
|
||||||
|
|
||||||
|
- [x] Task 3: Create custom BPMN edge components (AC: #3)
|
||||||
|
- [x] 3.1: Create `BpmnSequenceEdge.tsx` — solid arrow (default BPMN edge)
|
||||||
|
- [x] 3.2: Create `BpmnMessageEdge.tsx` — dashed line with open arrowhead
|
||||||
|
- [x] 3.3: Create `BpmnAssociationEdge.tsx` — dotted line (no arrowhead)
|
||||||
|
|
||||||
|
- [x] Task 4: Create BPMN compound ELK layout builder (AC: #2)
|
||||||
|
- [x] 4.1: Create `apps/web/src/modules/diagram/lib/bpmn-layout.ts` — `buildBpmnElkGraph()` that builds pool > lane > node hierarchy with `elk.hierarchyHandling: INCLUDE_CHILDREN`
|
||||||
|
- [x] 4.2: Implement `buildBpmnElkNode()` — maps BPMN node types to ELK nodes with correct dimensions from `BPMN_SIZES`, external labels for gateways/events via ELK label system
|
||||||
|
- [x] 4.3: Implement edge container resolution — edges placed at lane level (same lane), pool level (cross-lane same pool), or root level (cross-pool)
|
||||||
|
- [x] 4.4: Implement `resolveBpmnPositions()` — recursive absolute position resolution from ELK compound output (parent offsets cascade to children)
|
||||||
|
- [x] 4.5: Implement `resolveBpmnEdges()` — resolve edge paths from ELK sections with absolute coordinate shifting, support for SPLINES/ORTHOGONAL/POLYLINE
|
||||||
|
|
||||||
|
- [x] Task 5: Create pool and lane container rendering (AC: #1, #2)
|
||||||
|
- [x] 5.1: Implement pool rendering as a large labeled container div overlaid on the canvas (or via @xyflow/react group node)
|
||||||
|
- [x] 5.2: Implement lane rendering as subdivisions within pools with horizontal labels
|
||||||
|
- [x] 5.3: Ensure pool/lane dimensions are computed from ELK layout result
|
||||||
|
|
||||||
|
- [x] Task 6: Create BFS path highlighting (AC: #4)
|
||||||
|
- [x] 6.1: Create `apps/web/src/modules/diagram/lib/bfs-path.ts` — `bfsPath(startId, edges)` returning `{ nodeSet, edgeSet }` with bidirectional BFS
|
||||||
|
- [x] 6.2: Integrate path highlighting into node click handlers — toggle highlight state, dim non-connected nodes/edges via opacity
|
||||||
|
- [x] 6.3: Store highlighted node ID in `useGraphStore` — add `highlightedNodeId` and `setHighlightedNodeId` state
|
||||||
|
|
||||||
|
- [x] Task 7: Integrate BPMN into graph converter and canvas (AC: #1, #2, #3)
|
||||||
|
- [x] 7.1: Update `graph-converter.ts` — map BPMN node types to custom @xyflow/react node types (e.g., `bpmn:activity` → `bpmnActivity`)
|
||||||
|
- [x] 7.2: Update `graph-converter.ts` — map BPMN edge types to custom edge types (`sequence` → `bpmnSequence`, `message` → `bpmnMessage`, `association` → `bpmnAssociation`)
|
||||||
|
- [x] 7.3: Register all BPMN node types in `DiagramCanvas.tsx` `nodeTypes` object
|
||||||
|
- [x] 7.4: Register all BPMN edge types in `DiagramCanvas.tsx` `edgeTypes` object
|
||||||
|
- [x] 7.5: Update `useAutoLayout` or `computeLayout` to detect BPMN diagrams and use `buildBpmnElkGraph` for compound layout instead of flat `buildElkGraph`
|
||||||
|
|
||||||
|
- [x] Task 8: Group rendering overlay (AC: #1)
|
||||||
|
- [x] 8.1: Implement visual group boundaries — compute bounding box from member node positions, render as a dashed border with label
|
||||||
|
- [x] 8.2: Groups should dim when path highlighting is active and no group members are in the highlighted path
|
||||||
|
|
||||||
|
- [x] Task 9: Tests (AC: all)
|
||||||
|
- [x] 9.1: Unit tests for `BPMN_SIZES` — all 11 node types have valid dimensions
|
||||||
|
- [x] 9.2: Unit tests for `buildBpmnElkGraph()` — correct pool/lane/node hierarchy, edge container resolution
|
||||||
|
- [x] 9.3: Unit tests for `resolveBpmnPositions()` — recursive absolute position computation
|
||||||
|
- [x] 9.4: Unit tests for `bfsPath()` — forward and backward BFS, edge set correctness
|
||||||
|
- [x] 9.5: Unit tests for graph converter BPMN type mapping — node types and edge types correctly resolved
|
||||||
|
- [x] 9.6: All existing tests (50) still pass — now 81 web tests (31 new), 337 total across all packages
|
||||||
|
|
||||||
|
## Dev Notes
|
||||||
|
|
||||||
|
### Overview — What This Story Builds
|
||||||
|
|
||||||
|
This story adds the first diagram type renderer: BPMN (Business Process Model and Notation). It ports the proven Flexicar BPMN rendering patterns to @xyflow/react custom nodes and edges, adds compound ELK layout for pool/lane-aware positioning, and implements BFS path highlighting for interactive node selection.
|
||||||
|
|
||||||
|
**This story builds:**
|
||||||
|
- 9 custom BPMN node components (activity, subprocess, start-event, end-event, timer-event, message-event, gateway, data-object, annotation)
|
||||||
|
- 3 custom BPMN edge components (sequence, message, association)
|
||||||
|
- Compound ELK layout builder for pool/lane hierarchy
|
||||||
|
- BFS path highlighting system
|
||||||
|
- Pool/lane/group visual containers
|
||||||
|
|
||||||
|
**This story does NOT implement:**
|
||||||
|
- Other diagram types (Stories 2.4-2.8)
|
||||||
|
- Manual node repositioning (Story 2.9)
|
||||||
|
- Liveblocks/CRDT integration (Epic 4)
|
||||||
|
- AI-triggered mutations (Epic 3)
|
||||||
|
|
||||||
|
### Architecture Compliance
|
||||||
|
|
||||||
|
**MANDATORY patterns from Architecture Decision Document:**
|
||||||
|
|
||||||
|
1. **Unified Graph Data Model (Decision 1):** BPMN nodes use type-prefixed `type` field (`bpmn:activity`, `bpmn:gateway-exclusive`, `bpmn:start-event`, etc.). The `lane` and `group` fields on DiagramNode are the BPMN-specific fields. The `pools` and `groups` arrays on GraphData are the BPMN extensions.
|
||||||
|
|
||||||
|
2. **Component Structure:** Feature code in `~/modules/diagram/types/bpmn/` — BPMN-specific node components and constants. Shared layout utilities in `~/modules/diagram/lib/`. NOT co-located in route directories.
|
||||||
|
|
||||||
|
3. **@xyflow/react Custom Nodes:** All custom node components must use `NodeProps` typing from @xyflow/react. The `nodeTypes` object MUST be defined OUTSIDE the component (performance critical — Story 2.1 pattern).
|
||||||
|
|
||||||
|
4. **ELK.js in Web Worker:** The compound layout builder creates the ELK graph structure, but the actual `elk.layout()` call still happens in the existing Web Worker via `computeLayout`. The builder just produces a different ELK graph shape (hierarchical vs flat).
|
||||||
|
|
||||||
|
5. **Lean JSON Data Model:** No x/y positions stored for BPMN nodes. All positioning is computed by ELK at render time. Only `lane` and `group` assignments are stored.
|
||||||
|
|
||||||
|
### BPMN Node Types — Size Constants
|
||||||
|
|
||||||
|
Port from Flexicar's `BPMN_SIZES`. These define the dimensions ELK uses for layout spacing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**`labelH`:** Height reserved for labels below the shape (gateways, events, data-objects). Activities/subprocesses have labels inside the shape body, so `labelH: 0`.
|
||||||
|
|
||||||
|
### BPMN Custom Node Components — Implementation Approach
|
||||||
|
|
||||||
|
Each BPMN node type gets a custom @xyflow/react node component. All go in `apps/web/src/modules/diagram/types/bpmn/`.
|
||||||
|
|
||||||
|
**Key pattern — @xyflow/react custom node:**
|
||||||
|
```typescript
|
||||||
|
import { Handle, Position } from "@xyflow/react";
|
||||||
|
import type { Node, NodeProps } from "@xyflow/react";
|
||||||
|
import type { DiagramNode } from "../../types/graph";
|
||||||
|
|
||||||
|
type BpmnActivityData = DiagramNode & { label: string };
|
||||||
|
type BpmnActivityNodeType = Node<BpmnActivityData, "bpmnActivity">;
|
||||||
|
|
||||||
|
export function BpmnActivityNode({ data }: NodeProps<BpmnActivityNodeType>) {
|
||||||
|
return (
|
||||||
|
<div className="bpmn-activity">
|
||||||
|
{data.tag && <div className="bpmn-activity-tag">{data.tag}</div>}
|
||||||
|
<div className="bpmn-activity-label">{data.label}</div>
|
||||||
|
<Handle type="target" position={Position.Left} />
|
||||||
|
<Handle type="source" position={Position.Right} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SVG-based nodes (events, gateways, data-objects):** These use inline SVG within the @xyflow/react node wrapper. The node's outer div has explicit width/height matching `BPMN_SIZES`. The SVG shape renders inside.
|
||||||
|
|
||||||
|
**Label below:** For event/gateway/data-object nodes, the label renders below the SVG shape as a `<div>` with `text-center`. The node's total height for @xyflow = shape height + labelH from BPMN_SIZES.
|
||||||
|
|
||||||
|
**Handles:** @xyflow/react requires `<Handle>` components for edge connection points. For BPMN nodes, add handles at all 4 positions (top/right/bottom/left) with `style={{ opacity: 0 }}` to make them invisible but functional.
|
||||||
|
|
||||||
|
### BPMN Node Type → @xyflow/react Type Mapping
|
||||||
|
|
||||||
|
| DiagramNode.type | @xyflow Node type | Component |
|
||||||
|
|---|---|---|
|
||||||
|
| `bpmn:activity` | `bpmnActivity` | `BpmnActivityNode` |
|
||||||
|
| `bpmn:subprocess` | `bpmnSubprocess` | `BpmnSubprocessNode` |
|
||||||
|
| `bpmn:start-event` | `bpmnStartEvent` | `BpmnStartEventNode` |
|
||||||
|
| `bpmn:end-event` | `bpmnEndEvent` | `BpmnEndEventNode` |
|
||||||
|
| `bpmn:event-timer` | `bpmnTimerEvent` | `BpmnTimerEventNode` |
|
||||||
|
| `bpmn:event-message` | `bpmnMessageEvent` | `BpmnMessageEventNode` |
|
||||||
|
| `bpmn:gateway-exclusive` | `bpmnGateway` | `BpmnGatewayNode` |
|
||||||
|
| `bpmn:gateway-parallel` | `bpmnGateway` | `BpmnGatewayNode` |
|
||||||
|
| `bpmn:gateway-inclusive` | `bpmnGateway` | `BpmnGatewayNode` |
|
||||||
|
| `bpmn:data-object` | `bpmnDataObject` | `BpmnDataObjectNode` |
|
||||||
|
| `bpmn:annotation` | `bpmnAnnotation` | `BpmnAnnotationNode` |
|
||||||
|
|
||||||
|
**Note on gateways:** All three gateway subtypes use the same `BpmnGatewayNode` component. The inner marker (X, +, O) is determined by `data.type` within the component.
|
||||||
|
|
||||||
|
**Note on type prefixing:** The stored data uses `bpmn:` prefix (e.g., `bpmn:activity`). The graph converter strips the prefix and maps to @xyflow node type string (e.g., `bpmnActivity`). However, some Flexicar reference data uses unprefixed types (e.g., just `activity`, `start-event`). The converter should handle BOTH formats for backward compatibility:
|
||||||
|
- `bpmn:activity` → `bpmnActivity`
|
||||||
|
- `activity` → `bpmnActivity` (when diagram meta.diagramType === "bpmn")
|
||||||
|
|
||||||
|
### BPMN Custom Edge Types
|
||||||
|
|
||||||
|
| DiagramEdge.type | @xyflow Edge type | Visual |
|
||||||
|
|---|---|---|
|
||||||
|
| `sequence` (or default) | `bpmnSequence` | Solid line, filled arrowhead |
|
||||||
|
| `message` | `bpmnMessage` | Dashed line, open arrowhead |
|
||||||
|
| `association` | `bpmnAssociation` | Dotted line, no arrowhead |
|
||||||
|
|
||||||
|
**Edge components** use @xyflow/react's `BaseEdge` + `getSmoothStepPath` (for orthogonal routing) or `getBezierPath` (for spline routing). Use `markerEnd` prop for arrowheads.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
path={edgePath}
|
||||||
|
markerEnd="url(#bpmn-arrow)"
|
||||||
|
style={{ stroke: "var(--edge-default)" }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SVG marker defs:** Add arrowhead marker definitions to the canvas (filled arrow for sequence, open arrow for message). Use an SVG `<defs>` element rendered inside the ReactFlow component.
|
||||||
|
|
||||||
|
### Compound ELK Layout — `buildBpmnElkGraph()`
|
||||||
|
|
||||||
|
The critical difference from flat layout: BPMN uses **hierarchical** ELK layout with pool/lane containers.
|
||||||
|
|
||||||
|
**Key ELK options for compound layout:**
|
||||||
|
```typescript
|
||||||
|
'elk.hierarchyHandling': 'INCLUDE_CHILDREN' // Process children within parent boundaries
|
||||||
|
'elk.padding': '[top=55,left=80,bottom=45,right=40]' // Lane padding for labels
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hierarchy:**
|
||||||
|
```
|
||||||
|
root
|
||||||
|
├── pool (container)
|
||||||
|
│ ├── lane (container, with padding for lane label)
|
||||||
|
│ │ ├── node (with BPMN_SIZES dimensions)
|
||||||
|
│ │ └── node
|
||||||
|
│ └── lane
|
||||||
|
│ └── node
|
||||||
|
└── free-floating node (annotation, data-object without lane)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge container resolution (critical for correct routing):**
|
||||||
|
- Same lane → edge belongs to lane
|
||||||
|
- Cross-lane, same pool → edge belongs to pool
|
||||||
|
- Cross-pool or free → edge belongs to root
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ELK node labels for gateways/events:** Shapes with labels below (gateways, events, data-objects) use ELK's label system so it reserves space for the label text while routing edges to the shape body:
|
||||||
|
```typescript
|
||||||
|
elkNode.labels = [{ text: node.label, width: labelW, height: BPMN_SIZES[type].labelH }];
|
||||||
|
elkNode.layoutOptions = { 'elk.nodeLabels.placement': 'OUTSIDE V_BOTTOM H_CENTER' };
|
||||||
|
```
|
||||||
|
|
||||||
|
### Position Resolution — Compound Graphs
|
||||||
|
|
||||||
|
For compound (hierarchical) graphs, ELK returns positions relative to parent containers. Must recursively add parent offsets to get absolute positions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function resolveBpmnPositions(
|
||||||
|
node: ElkNode,
|
||||||
|
offsetX = 0,
|
||||||
|
offsetY = 0,
|
||||||
|
): Map<string, { x: number; y: number; w: number; h: number }> {
|
||||||
|
const positions = new Map();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### BFS Path Highlighting
|
||||||
|
|
||||||
|
Port from Flexicar's `bfsPath()`. Bidirectional BFS from clicked node:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function bfsPath(
|
||||||
|
startId: string,
|
||||||
|
edges: Array<{ from: string; to: string }>,
|
||||||
|
): { nodeSet: Set<string>; edgeSet: Set<string> } {
|
||||||
|
const forward: Record<string, typeof edges> = {};
|
||||||
|
const backward: Record<string, typeof edges> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration:** Add `highlightedNodeId: string | null` and `setHighlightedNodeId` to `useGraphStore`. Node click handler toggles this state. Node components read it to apply dimming (opacity: 0.25) or highlighting (border glow) CSS classes. Edge components use it to dim non-path edges.
|
||||||
|
|
||||||
|
### Graph Converter Updates
|
||||||
|
|
||||||
|
The current `graphNodeToFlowNode` maps ALL nodes to `type: "default"`. For BPMN, it must map to custom node types.
|
||||||
|
|
||||||
|
**Updated logic in `graphNodeToFlowNode`:**
|
||||||
|
```typescript
|
||||||
|
function resolveBpmnNodeType(type: string): string {
|
||||||
|
// Strip prefix if present
|
||||||
|
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"; // fallback for unknown BPMN types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge type mapping:**
|
||||||
|
```typescript
|
||||||
|
function resolveBpmnEdgeType(type: string | undefined): string {
|
||||||
|
switch (type) {
|
||||||
|
case "message": return "bpmnMessage";
|
||||||
|
case "association": return "bpmnAssociation";
|
||||||
|
case "sequence":
|
||||||
|
default: return "bpmnSequence";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** The `graphToFlow` function must receive `diagramType` context to know which renderer to use. Either pass it as a parameter or read it from the graph's `meta.diagramType`.
|
||||||
|
|
||||||
|
### Layout Integration — Detecting BPMN for Compound Layout
|
||||||
|
|
||||||
|
The `computeLayout` function currently calls `buildElkGraph()` for flat layout. For BPMN diagrams with pools, it must use `buildBpmnElkGraph()` instead.
|
||||||
|
|
||||||
|
**Approach:** Add a `diagramType` parameter (or full `GraphData`) to `computeLayout`. When `diagramType === "bpmn"` and pools are present, build the compound ELK graph. Otherwise, use the existing flat builder.
|
||||||
|
|
||||||
|
The compound graph builder produces a different ELK graph shape but the worker still runs `elk.layout()` the same way. The key difference is the `resolvePositions` step — compound graphs need recursive position resolution instead of flat mapping.
|
||||||
|
|
||||||
|
**Worker change:** None needed. The worker receives any ELK graph and returns the layout result. The client-side code handles the difference.
|
||||||
|
|
||||||
|
### Pool and Lane Rendering in @xyflow/react
|
||||||
|
|
||||||
|
**Option A (recommended): Use @xyflow/react group nodes.** Register pool/lane as custom node types with `type: "group"`. Set `style: { width, height }` from ELK layout result. Child nodes use `parentId` to be positioned relative to the group.
|
||||||
|
|
||||||
|
**Option B: Overlay divs.** Render pool/lane as absolute-positioned divs on a Panel layer behind the nodes. Simpler but less integrated with @xyflow's node system.
|
||||||
|
|
||||||
|
**Recommended: Option A** — aligns with @xyflow/react's built-in grouping support. Each pool is a group node, each lane is a child group node within the pool. BPMN nodes have `parentId` set to their lane ID.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pool node type for @xyflow/react
|
||||||
|
function BpmnPoolNode({ data }: NodeProps) {
|
||||||
|
return (
|
||||||
|
<div className="bpmn-pool" style={{ width: "100%", height: "100%" }}>
|
||||||
|
<div className="bpmn-pool-label">{data.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lane node type for @xyflow/react
|
||||||
|
function BpmnLaneNode({ data }: NodeProps) {
|
||||||
|
return (
|
||||||
|
<div className="bpmn-lane" style={{ width: "100%", height: "100%" }}>
|
||||||
|
<div className="bpmn-lane-label">{data.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Graph converter must inject pool/lane nodes:** When converting BPMN GraphData, create synthetic @xyflow nodes for pools and lanes (from `graphData.pools`) with `type: "bpmnPool"` and `type: "bpmnLane"`. Set child nodes' `parentId` to their lane ID.
|
||||||
|
|
||||||
|
### CSS Styles for BPMN Nodes
|
||||||
|
|
||||||
|
Add to `globals.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 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;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-pool {
|
||||||
|
border: 2px solid var(--node-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpmn-pool-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpmn-lane {
|
||||||
|
border-top: 1px solid var(--node-border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpmn-lane-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Code to Reuse / Modify
|
||||||
|
|
||||||
|
| File | Action | What |
|
||||||
|
|------|--------|------|
|
||||||
|
| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Register BPMN node types and edge types in `nodeTypes`/`edgeTypes` objects |
|
||||||
|
| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add BPMN type resolution for nodes and edges, pass diagramType context |
|
||||||
|
| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Add BPMN compound layout path (or import from new bpmn-layout.ts), update `computeLayout` to accept diagramType |
|
||||||
|
| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **MODIFY** | Add `highlightedNodeId` state for path highlighting |
|
||||||
|
| `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` | **MODIFY** | Pass diagramType and GraphData (pools) to `computeLayout` for compound layout detection |
|
||||||
|
| `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` | **READ** | Understand how graphData is passed to canvas and store |
|
||||||
|
| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode (lane, group), GraphData (pools, groups) — already defined |
|
||||||
|
| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add BPMN node CSS styles and path highlighting styles |
|
||||||
|
| `/Users/agutierrez/Desktop/flexicar-context/.diagrams/app/shared.js` | **REFERENCE** | Port BPMN_SIZES, buildElkGraph compound builder, SVG renderers, bfsPath, resolvePositions, resolveEdges |
|
||||||
|
| `/Users/agutierrez/Desktop/flexicar-context/.diagrams/app/process-v4-bpmn.json` | **REFERENCE** | Sample BPMN data structure with pools/lanes/groups for testing |
|
||||||
|
|
||||||
|
### Library & Framework Requirements
|
||||||
|
|
||||||
|
**No new packages required.** Everything is built with existing dependencies:
|
||||||
|
- `@xyflow/react` 12.10.1 — custom nodes, edges, handles, group nodes
|
||||||
|
- `elkjs` 0.11.0 — compound layout via `elk.hierarchyHandling: INCLUDE_CHILDREN`
|
||||||
|
- `zustand` 5.0.8 — highlight state
|
||||||
|
|
||||||
|
### @xyflow/react Custom Nodes — Key Implementation Details
|
||||||
|
|
||||||
|
**Custom node registration (MUST be outside component):**
|
||||||
|
```typescript
|
||||||
|
import { BpmnActivityNode } from "../../types/bpmn/BpmnActivityNode";
|
||||||
|
// ... all other BPMN node imports
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
bpmnActivity: BpmnActivityNode,
|
||||||
|
bpmnSubprocess: BpmnSubprocessNode,
|
||||||
|
bpmnStartEvent: BpmnStartEventNode,
|
||||||
|
bpmnEndEvent: BpmnEndEventNode,
|
||||||
|
bpmnTimerEvent: BpmnTimerEventNode,
|
||||||
|
bpmnMessageEvent: BpmnMessageEventNode,
|
||||||
|
bpmnGateway: BpmnGatewayNode,
|
||||||
|
bpmnDataObject: BpmnDataObjectNode,
|
||||||
|
bpmnAnnotation: BpmnAnnotationNode,
|
||||||
|
bpmnPool: BpmnPoolNode,
|
||||||
|
bpmnLane: BpmnLaneNode,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom node props:** `data` prop contains the full `DiagramNode` object (spread by `graphNodeToFlowNode`). Access `data.type`, `data.tag`, `data.label`, `data.lane`, `data.group`.
|
||||||
|
|
||||||
|
**Custom node dimensions:** @xyflow/react measures nodes after DOM render via `node.measured.width/height`. For ELK layout, set initial width/height from `BPMN_SIZES` using the node's `data.w` or computed size.
|
||||||
|
|
||||||
|
**Group nodes (pools/lanes):** @xyflow/react v12 supports group nodes natively. Set `type: "group"` on parent nodes AND set `style: { width, height }` explicitly. Child nodes must have `parentId` pointing to their group. Child positions are relative to parent.
|
||||||
|
|
||||||
|
### Flexicar BPMN Reference — SVG Shapes to Port
|
||||||
|
|
||||||
|
All SVG shapes from Flexicar are rendered via `React.createElement` (vanilla). Port to JSX:
|
||||||
|
|
||||||
|
- **Start Event:** `<circle cx={18} cy={18} r={17} fill="none" stroke="#2ecc71" strokeWidth={2} />`
|
||||||
|
- **End Event:** `<circle cx={18} cy={18} r={17} fill="none" stroke="#e74c3c" strokeWidth={3.5} />`
|
||||||
|
- **Timer Event:** Double circle + clock hands (blue #3498db)
|
||||||
|
- **Message Event:** Double circle + envelope shape (orange #f39c12)
|
||||||
|
- **Gateway:** Diamond polygon with inner marker. Colors: all blue #3498db
|
||||||
|
- Exclusive: X cross lines (strokeWidth 3)
|
||||||
|
- Parallel: + cross lines (strokeWidth 3)
|
||||||
|
- Inclusive: circle (r=9, strokeWidth 2.5)
|
||||||
|
- **Data Object:** Document path with folded corner (stroke #f39c12)
|
||||||
|
- **Activity:** Rounded rectangle (CSS, not SVG)
|
||||||
|
- **Subprocess:** Same as activity + centered `+` marker at bottom
|
||||||
|
|
||||||
|
**Color note:** In Flexicar, these are dark-theme colors (dark background). For domaingraph, use CSS custom properties so they work in both light and dark modes. The shape strokes should use the `--diagram-bpmn` accent color with type-specific overrides for events/gateways.
|
||||||
|
|
||||||
|
### File Structure for This Story
|
||||||
|
|
||||||
|
New files:
|
||||||
|
```
|
||||||
|
apps/web/src/modules/diagram/
|
||||||
|
├── types/bpmn/
|
||||||
|
│ ├── index.ts # Exports all BPMN components + constants
|
||||||
|
│ ├── constants.ts # BPMN_SIZES, type mappings
|
||||||
|
│ ├── BpmnActivityNode.tsx # Activity custom node
|
||||||
|
│ ├── BpmnSubprocessNode.tsx # Subprocess custom node
|
||||||
|
│ ├── BpmnStartEventNode.tsx # Start event custom node
|
||||||
|
│ ├── BpmnEndEventNode.tsx # End event custom node
|
||||||
|
│ ├── BpmnTimerEventNode.tsx # Timer event custom node
|
||||||
|
│ ├── BpmnMessageEventNode.tsx # Message event custom node
|
||||||
|
│ ├── BpmnGatewayNode.tsx # Gateway custom node (all 3 subtypes)
|
||||||
|
│ ├── BpmnDataObjectNode.tsx # Data object custom node
|
||||||
|
│ ├── BpmnAnnotationNode.tsx # Annotation custom node
|
||||||
|
│ ├── BpmnPoolNode.tsx # Pool group container
|
||||||
|
│ ├── BpmnLaneNode.tsx # Lane group container
|
||||||
|
│ ├── BpmnSequenceEdge.tsx # Sequence flow edge
|
||||||
|
│ ├── BpmnMessageEdge.tsx # Message flow edge
|
||||||
|
│ └── BpmnAssociationEdge.tsx # Association edge
|
||||||
|
├── lib/
|
||||||
|
│ ├── bpmn-layout.ts # Compound ELK graph builder for BPMN
|
||||||
|
│ ├── bpmn-layout.test.ts # Tests for BPMN layout builder
|
||||||
|
│ └── bfs-path.ts # BFS path highlighting utility
|
||||||
|
│ └── bfs-path.test.ts # Tests for BFS path
|
||||||
|
└── (existing files modified)
|
||||||
|
```
|
||||||
|
|
||||||
|
Modified files:
|
||||||
|
```
|
||||||
|
apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register BPMN node/edge types
|
||||||
|
apps/web/src/modules/diagram/lib/graph-converter.ts # BPMN type mapping
|
||||||
|
apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add BPMN converter tests
|
||||||
|
apps/web/src/modules/diagram/lib/elk-layout.ts # BPMN compound layout path
|
||||||
|
apps/web/src/modules/diagram/stores/useGraphStore.ts # Add highlightedNodeId
|
||||||
|
apps/web/src/modules/diagram/stores/useGraphStore.test.ts # Add highlight state tests
|
||||||
|
apps/web/src/modules/diagram/hooks/useAutoLayout.ts # Pass diagramType to layout
|
||||||
|
apps/web/src/assets/styles/globals.css # BPMN CSS styles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure Notes
|
||||||
|
|
||||||
|
- BPMN node components go in `~/modules/diagram/types/bpmn/` — this follows the epics file convention (`apps/web/src/modules/diagram/types/bpmn/`)
|
||||||
|
- Layout utilities go in `~/modules/diagram/lib/` — shared across diagram types
|
||||||
|
- `bfs-path.ts` is NOT BPMN-specific — it works with any graph and will be reused by other diagram types
|
||||||
|
- Tests co-located next to source files
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **NEVER put `nodeTypes` or `edgeTypes` inside the component** — causes re-renders on every state change, killing canvas performance (Story 2.1 pattern)
|
||||||
|
- **NEVER hardcode positions for BPMN nodes** — all positioning comes from ELK compound layout
|
||||||
|
- **NEVER import from `reactflow`** — use `@xyflow/react` (v12+)
|
||||||
|
- **NEVER use `require()`** — ESM-only project
|
||||||
|
- **NEVER co-locate feature code in route directories** — use `~/modules/diagram/`
|
||||||
|
- **NEVER store ELK-computed positions in the persisted graph data** — positions are ephemeral (Story 2.2 pattern)
|
||||||
|
- **NEVER run ELK on the main thread** — always via Web Worker
|
||||||
|
- **DO NOT implement other diagram type renderers** — those are Stories 2.4-2.8
|
||||||
|
- **DO NOT implement manual node repositioning** — that's Story 2.9
|
||||||
|
- **DO NOT implement Liveblocks/CRDT** — that's Story 4.1
|
||||||
|
- **DO NOT break existing tests** — 50 tests must continue passing
|
||||||
|
- **DO NOT use inline styles for BPMN colors** — use CSS custom properties for light/dark mode support
|
||||||
|
|
||||||
|
### Previous Story Intelligence (Story 2.2)
|
||||||
|
|
||||||
|
**Key learnings to carry forward:**
|
||||||
|
- `buildElkGraph()` is the flat layout builder — BPMN needs a compound variant (`buildBpmnElkGraph`) that builds pool/lane hierarchy
|
||||||
|
- `resolvePositions()` currently handles flat graphs (direct children of root) — BPMN needs recursive resolution that cascades parent offsets
|
||||||
|
- `computeLayout()` sends the ELK graph to the Web Worker and resolves positions on return — extend this to accept diagramType/GraphData for compound layout detection
|
||||||
|
- Worker singleton pattern with `getWorker()` / `terminateWorker()` — no worker changes needed
|
||||||
|
- `useAutoLayout` debounces at 300ms and uses single-flight pattern — these apply to BPMN layout too
|
||||||
|
- Layout animation uses `.layouting` CSS class toggling — apply same pattern to BPMN nodes
|
||||||
|
- `DiagramMeta` already has `layoutDirection` and `edgeRouting` fields — BPMN uses these same fields
|
||||||
|
- `DiagramNode` already has `lane?: string` and `group?: string` — no type changes needed
|
||||||
|
- `GraphData` already has `pools` and `groups` arrays — no type changes needed
|
||||||
|
- All node data is spread into `data` prop via `graphNodeToFlowNode()` — custom nodes access full DiagramNode via `data`
|
||||||
|
- 50 tests currently pass (18 elk-layout + 15 graph-converter + 17 store) — don't break them
|
||||||
|
|
||||||
|
### Previous Story Intelligence (Story 2.1)
|
||||||
|
|
||||||
|
**Key learnings:**
|
||||||
|
- `nodeTypes` defined OUTSIDE component — critical for performance
|
||||||
|
- `ReactFlowProvider` wraps `ReactFlow` — hooks like `useReactFlow()` work in children
|
||||||
|
- `colorMode="system"` — dark mode automatic
|
||||||
|
- `diagramTypeConfig` in DiagramCard.tsx — has icons and accent colors for all 6 types
|
||||||
|
- Hono RPC pattern for data fetching
|
||||||
|
- `DropdownMenu` for selection controls
|
||||||
|
- `sonner` toast for user feedback
|
||||||
|
|
||||||
|
### Git Intelligence
|
||||||
|
|
||||||
|
Recent commits:
|
||||||
|
- `7dd5af1 feat: implement Story 2.2 — ELK.js auto-layout engine in Web Worker`
|
||||||
|
- `5033109 feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model`
|
||||||
|
|
||||||
|
Established patterns:
|
||||||
|
- Commit message: `feat: implement Story X.Y — description`
|
||||||
|
- Feature code in `apps/web/src/modules/diagram/`
|
||||||
|
- Co-located tests next to source files
|
||||||
|
- `DropdownMenu` from shadcn/ui for selection controls
|
||||||
|
- `diagramTypeConfig` object for diagram type metadata
|
||||||
|
- sherif workspace lint requires alphabetically sorted devDependencies
|
||||||
|
|
||||||
|
### Latest Tech Information
|
||||||
|
|
||||||
|
**@xyflow/react 12.10.1 — Custom Node API:**
|
||||||
|
- Custom nodes receive `NodeProps<T>` with `data`, `id`, `selected`, `dragging` props
|
||||||
|
- `Handle` component required for edge connections — `type="source"` or `type="target"`, `position` from `Position` enum
|
||||||
|
- `node.measured.width/height` available after DOM render — use for accurate ELK sizing on re-layout
|
||||||
|
- Group nodes: set `type` on parent, `parentId` on children, explicit `style: { width, height }` on parent node
|
||||||
|
- Custom edges: receive `EdgeProps` with `sourceX/Y`, `targetX/Y`, `sourcePosition`, `targetPosition`
|
||||||
|
- `BaseEdge` + path utilities (`getSmoothStepPath`, `getBezierPath`, `getStraightPath`) for edge rendering
|
||||||
|
- `markerEnd` prop accepts SVG marker URL reference
|
||||||
|
|
||||||
|
**ELK.js 0.11.0 — Compound Layout:**
|
||||||
|
- `elk.hierarchyHandling: 'INCLUDE_CHILDREN'` enables compound layout where child nodes are laid out within parent boundaries
|
||||||
|
- Each container (pool/lane) can have its own `layoutOptions` (padding, spacing)
|
||||||
|
- Edges assigned to specific containers via the `edges` property on that container node
|
||||||
|
- ELK returns positions relative to parent container — must recursively resolve to absolute
|
||||||
|
- Label system: `labels` array on nodes with `text`, `width`, `height` + `layoutOptions` placement
|
||||||
|
|
||||||
|
**@xyflow/react v12 grouping:**
|
||||||
|
- No need for `node.extent: "parent"` (removed in v12)
|
||||||
|
- Child nodes use `parentId` (not `parentNode` which was v11)
|
||||||
|
- Group nodes should use `style` prop with explicit `width`/`height`
|
||||||
|
- Group CSS: `pointer-events: none` on the group node to allow clicking through to children
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.3] — Full AC and technical notes
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model (hybrid schema, BPMN extensions)
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Flexicar Prototype] — Lean JSON data model, BPMNDiagram patterns
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules
|
||||||
|
- [Source: _bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md] — ELK layout module, Web Worker, resolvePositions, BPMN extension notes
|
||||||
|
- [Source: _bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md] — Graph converter, Zustand store, DiagramCanvas, nodeTypes pattern
|
||||||
|
- [Source: _bmad-output/project-context.md] — 62 critical implementation rules
|
||||||
|
- [Source: /Users/agutierrez/Desktop/flexicar-context/.diagrams/app/shared.js] — BPMN_SIZES, buildElkGraph compound, SVG renderers, bfsPath, resolvePositions, resolveEdges
|
||||||
|
- [Source: /Users/agutierrez/Desktop/flexicar-context/.diagrams/app/process-v4-bpmn.json] — Sample BPMN data with pools/lanes/groups
|
||||||
|
- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (lane, group), GraphData (pools, groups)
|
||||||
|
- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — Current flat layout builder to extend
|
||||||
|
- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Current converter to update for BPMN types
|
||||||
|
- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register BPMN types
|
||||||
|
|
||||||
|
## Dev Agent Record
|
||||||
|
|
||||||
|
### Agent Model Used
|
||||||
|
|
||||||
|
Claude Opus 4.6
|
||||||
|
|
||||||
|
### Debug Log References
|
||||||
|
|
||||||
|
No issues encountered during implementation.
|
||||||
|
|
||||||
|
### Completion Notes List
|
||||||
|
|
||||||
|
- Created 11 BPMN node types (constants.ts) with exact Flexicar BPMN_SIZES dimensions
|
||||||
|
- Created 9 custom @xyflow/react BPMN node components: activity, subprocess, start-event, end-event, timer-event, message-event, gateway (all 3 subtypes), data-object, annotation
|
||||||
|
- Created 3 container node types: BpmnPoolNode, BpmnLaneNode, BpmnGroupNode for pool/lane/group rendering
|
||||||
|
- Created 3 custom BPMN edge components: sequence (solid arrow), message (dashed open arrow), association (dotted)
|
||||||
|
- Created compound ELK layout builder (`bpmn-layout.ts`) with pool > lane > node hierarchy, edge container resolution, recursive position resolution, and edge path resolution
|
||||||
|
- Created BFS path highlighting utility (`bfs-path.ts`) with bidirectional BFS
|
||||||
|
- Updated graph converter to resolve BPMN node/edge types based on `diagramType` context, and to create synthetic pool/lane/group @xyflow/react group nodes
|
||||||
|
- Updated `flowToGraph` to reconstruct pools/groups from @xyflow group nodes (roundtrip preservation)
|
||||||
|
- Registered all BPMN node/edge types in DiagramCanvas.tsx with SVG marker defs for arrowheads
|
||||||
|
- Integrated compound BPMN layout into `computeLayout` in elk-layout.ts — auto-detects pools and uses hierarchical ELK layout
|
||||||
|
- Integrated BFS path highlighting via `onNodeClick` handler in DiagramCanvas — toggles dimmed/highlighted CSS classes on nodes/edges
|
||||||
|
- Added `highlightedNodeId` and `setHighlightedNodeId` to useGraphStore
|
||||||
|
- All event/data-object nodes now have 4 handles (top/right/bottom/left) for edge connections in any layout direction
|
||||||
|
- All BPMN SVG colors use CSS custom properties for light/dark mode support
|
||||||
|
- Added comprehensive BPMN CSS styles, group styles, and path highlighting (dimmed/highlighted) styles to globals.css
|
||||||
|
- All 81 web tests pass (31 new: 12 bpmn-layout, 9 bfs-path, 6 graph-converter BPMN tests, 4 store highlight tests), 337 total across all packages
|
||||||
|
- No regressions — all 50 pre-existing web tests continue to pass
|
||||||
|
|
||||||
|
### Change Log
|
||||||
|
|
||||||
|
- 2026-02-24: Story 2.3 implementation complete — BPMN diagram type renderer with 9 node types, 3 edge types, compound ELK layout, BFS highlighting, pool/lane containers
|
||||||
|
- 2026-02-24: Code review fixes — integrated compound layout into computeLayout (C1), wired BFS highlighting via onNodeClick (C2), added BpmnGroupNode (C3), fixed hardcoded SVG colors to CSS variables (M1), added 4 handles to all event nodes (M2), fixed flowToGraph pool/group preservation (M3), added 6 new tests (L3)
|
||||||
|
|
||||||
|
### File List
|
||||||
|
|
||||||
|
New files:
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/constants.ts`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/index.ts`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnSubprocessNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnStartEventNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnTimerEventNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnMessageEventNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnDataObjectNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnAnnotationNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/types/bpmn/BpmnAssociationEdge.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/lib/bpmn-layout.ts`
|
||||||
|
- `apps/web/src/modules/diagram/lib/bpmn-layout.test.ts`
|
||||||
|
- `apps/web/src/modules/diagram/lib/bfs-path.ts`
|
||||||
|
- `apps/web/src/modules/diagram/lib/bfs-path.test.ts`
|
||||||
|
|
||||||
|
Modified files:
|
||||||
|
- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx`
|
||||||
|
- `apps/web/src/modules/diagram/lib/graph-converter.ts`
|
||||||
|
- `apps/web/src/modules/diagram/lib/graph-converter.test.ts`
|
||||||
|
- `apps/web/src/modules/diagram/lib/elk-layout.ts`
|
||||||
|
- `apps/web/src/modules/diagram/stores/useGraphStore.ts`
|
||||||
|
- `apps/web/src/modules/diagram/stores/useGraphStore.test.ts`
|
||||||
|
- `apps/web/src/assets/styles/globals.css`
|
||||||
|
- `_bmad-output/implementation-artifacts/sprint-status.yaml`
|
||||||
@@ -52,7 +52,7 @@ development_status:
|
|||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done
|
2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done
|
||||||
2-2-elk-js-auto-layout-engine-in-web-worker: done
|
2-2-elk-js-auto-layout-engine-in-web-worker: done
|
||||||
2-3-bpmn-diagram-type-renderer: backlog
|
2-3-bpmn-diagram-type-renderer: done
|
||||||
2-4-entity-relationship-diagram-type-renderer: backlog
|
2-4-entity-relationship-diagram-type-renderer: backlog
|
||||||
2-5-org-chart-diagram-type-renderer: backlog
|
2-5-org-chart-diagram-type-renderer: backlog
|
||||||
2-6-architecture-diagram-type-renderer: backlog
|
2-6-architecture-diagram-type-renderer: backlog
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
--diagram-architecture: oklch(0.552 0.016 286);
|
--diagram-architecture: oklch(0.552 0.016 286);
|
||||||
--diagram-sequence: oklch(0.795 0.184 86);
|
--diagram-sequence: oklch(0.795 0.184 86);
|
||||||
--diagram-flowchart: oklch(0.645 0.246 16);
|
--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 {
|
.dark {
|
||||||
@@ -38,10 +45,204 @@
|
|||||||
--node-hover: oklch(0.623 0.214 260 / 12%);
|
--node-hover: oklch(0.623 0.214 260 / 12%);
|
||||||
--edge-default: oklch(0.55 0.01 286);
|
--edge-default: oklch(0.55 0.01 286);
|
||||||
--edge-selected: oklch(0.623 0.214 260);
|
--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 */
|
/* ELK layout animation — only active during auto-layout transitions */
|
||||||
.react-flow__node.layouting {
|
.react-flow__node.layouting {
|
||||||
transition: transform 200ms ease-out;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
@@ -9,11 +10,91 @@ import {
|
|||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
Panel,
|
Panel,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
|
||||||
import { useGraphStore } from "../../stores/useGraphStore";
|
import { useGraphStore } from "../../stores/useGraphStore";
|
||||||
import { useAutoLayout } from "../../hooks/useAutoLayout";
|
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() {
|
function CanvasInner() {
|
||||||
const nodes = useGraphStore((s) => s.nodes);
|
const nodes = useGraphStore((s) => s.nodes);
|
||||||
@@ -21,18 +102,83 @@ function CanvasInner() {
|
|||||||
const onNodesChange = useGraphStore((s) => s.onNodesChange);
|
const onNodesChange = useGraphStore((s) => s.onNodesChange);
|
||||||
const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
|
const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
|
||||||
const onViewportChange = useGraphStore((s) => s.onViewportChange);
|
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 { 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 (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
<BpmnMarkerDefs />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onViewportChange={onViewportChange}
|
onViewportChange={onViewportChange}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onPaneClick={clearHighlight}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
fitView
|
fitView
|
||||||
colorMode="system"
|
colorMode="system"
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
|
|||||||
76
apps/web/src/modules/diagram/lib/bfs-path.test.ts
Normal file
76
apps/web/src/modules/diagram/lib/bfs-path.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/web/src/modules/diagram/lib/bfs-path.ts
Normal file
56
apps/web/src/modules/diagram/lib/bfs-path.ts
Normal 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 };
|
||||||
|
}
|
||||||
248
apps/web/src/modules/diagram/lib/bpmn-layout.test.ts
Normal file
248
apps/web/src/modules/diagram/lib/bpmn-layout.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
408
apps/web/src/modules/diagram/lib/bpmn-layout.ts
Normal file
408
apps/web/src/modules/diagram/lib/bpmn-layout.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { Node, Edge } from "@xyflow/react";
|
import type { Node, Edge } from "@xyflow/react";
|
||||||
import type { ElkNode, ElkExtendedEdge } from "elkjs";
|
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 type { ElkWorkerResponse } from "./elk-worker";
|
||||||
|
import {
|
||||||
|
buildBpmnElkGraph,
|
||||||
|
resolveBpmnPositions,
|
||||||
|
applyBpmnPositions,
|
||||||
|
} from "./bpmn-layout";
|
||||||
|
|
||||||
// ── Layout Options ──────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────
|
// ── Main Layout Function ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function computeLayout(
|
export function computeLayout(
|
||||||
@@ -142,7 +196,17 @@ export function computeLayout(
|
|||||||
pendingReject = null;
|
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();
|
const w = getWorker();
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -163,7 +227,12 @@ export function computeLayout(
|
|||||||
w.removeEventListener("message", handler);
|
w.removeEventListener("message", handler);
|
||||||
|
|
||||||
if (event.data.type === "result" && event.data.graph) {
|
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 {
|
} else {
|
||||||
reject(new Error(event.data.message ?? "ELK layout failed"));
|
reject(new Error(event.data.message ?? "ELK layout failed"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,125 @@ describe("graphToFlow", () => {
|
|||||||
}));
|
}));
|
||||||
const result = graphToFlow({ nodes, edges: [] });
|
const result = graphToFlow({ nodes, edges: [] });
|
||||||
expect(result.nodes).toHaveLength(6);
|
expect(result.nodes).toHaveLength(6);
|
||||||
result.nodes.forEach((node) => {
|
// bpmn: prefix resolves to BPMN node type even without diagramType context
|
||||||
expect(node.type).toBe("default");
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
import type { Node, Edge } from "@xyflow/react";
|
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 {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: "default",
|
type: resolveFlowNodeType(diagramType, node.type),
|
||||||
position: node.position ?? { x: 0, y: 0 },
|
position: node.position ?? { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
...node,
|
...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 {
|
return {
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.from,
|
source: edge.from,
|
||||||
target: edge.to,
|
target: edge.to,
|
||||||
label: edge.label,
|
label: edge.label,
|
||||||
type: "default",
|
type: resolveFlowEdgeType(diagramType, edge.type),
|
||||||
data: { ...edge },
|
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[] } {
|
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 {
|
return {
|
||||||
nodes: (data.nodes ?? []).map(graphNodeToFlowNode),
|
nodes: (data.nodes ?? []).map((n) => graphNodeToFlowNode(n, diagramType)),
|
||||||
edges: (data.edges ?? []).map(graphEdgeToFlowEdge),
|
edges: (data.edges ?? []).map((e) => graphEdgeToFlowEdge(e, diagramType)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Flow → Graph Conversion ────────────────────────────────────────────────
|
||||||
|
|
||||||
export function flowNodeToGraphNode(node: Node): DiagramNode {
|
export function flowNodeToGraphNode(node: Node): DiagramNode {
|
||||||
const data = node.data as unknown as DiagramNode & { label: string };
|
const data = node.data as unknown as DiagramNode & { label: string };
|
||||||
return {
|
return {
|
||||||
@@ -68,9 +229,54 @@ export function flowToGraph(
|
|||||||
edges: Edge[],
|
edges: Edge[],
|
||||||
meta?: GraphData["meta"],
|
meta?: GraphData["meta"],
|
||||||
): GraphData {
|
): 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 {
|
return {
|
||||||
meta,
|
meta,
|
||||||
nodes: nodes.map(flowNodeToGraphNode),
|
nodes: nodes
|
||||||
|
.filter((n) => !containerTypes.has(n.type ?? ""))
|
||||||
|
.map(flowNodeToGraphNode),
|
||||||
edges: edges.map(flowEdgeToGraphEdge),
|
edges: edges.map(flowEdgeToGraphEdge),
|
||||||
|
...(pools && { pools }),
|
||||||
|
...(groups && { groups }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,4 +156,27 @@ describe("useGraphStore", () => {
|
|||||||
expect(useGraphStore.getState().isLayouting).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface GraphState {
|
|||||||
layoutDirection: LayoutDirection;
|
layoutDirection: LayoutDirection;
|
||||||
edgeRouting: EdgeRouting;
|
edgeRouting: EdgeRouting;
|
||||||
isLayouting: boolean;
|
isLayouting: boolean;
|
||||||
|
highlightedNodeId: string | null;
|
||||||
setNodes: (nodes: Node[]) => void;
|
setNodes: (nodes: Node[]) => void;
|
||||||
setEdges: (edges: Edge[]) => void;
|
setEdges: (edges: Edge[]) => void;
|
||||||
onNodesChange: OnNodesChange;
|
onNodesChange: OnNodesChange;
|
||||||
@@ -30,6 +31,7 @@ interface GraphState {
|
|||||||
setLayoutDirection: (direction: LayoutDirection) => void;
|
setLayoutDirection: (direction: LayoutDirection) => void;
|
||||||
setEdgeRouting: (routing: EdgeRouting) => void;
|
setEdgeRouting: (routing: EdgeRouting) => void;
|
||||||
setIsLayouting: (isLayouting: boolean) => void;
|
setIsLayouting: (isLayouting: boolean) => void;
|
||||||
|
setHighlightedNodeId: (id: string | null) => void;
|
||||||
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
|
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
layoutDirection: "DOWN",
|
layoutDirection: "DOWN",
|
||||||
edgeRouting: "ORTHOGONAL",
|
edgeRouting: "ORTHOGONAL",
|
||||||
isLayouting: false,
|
isLayouting: false,
|
||||||
|
highlightedNodeId: null,
|
||||||
|
|
||||||
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
|
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
|
||||||
setEdges: (edges) => set({ edges }),
|
setEdges: (edges) => set({ edges }),
|
||||||
@@ -63,6 +66,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
setLayoutDirection: (layoutDirection) => set({ layoutDirection }),
|
setLayoutDirection: (layoutDirection) => set({ layoutDirection }),
|
||||||
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
|
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
|
||||||
setIsLayouting: (isLayouting) => set({ isLayouting }),
|
setIsLayouting: (isLayouting) => set({ isLayouting }),
|
||||||
|
setHighlightedNodeId: (highlightedNodeId) => set({ highlightedNodeId }),
|
||||||
|
|
||||||
initializeFromGraphData: (nodes, edges) => {
|
initializeFromGraphData: (nodes, edges) => {
|
||||||
set({ nodes, edges, nodeCount: nodes.length });
|
set({ nodes, edges, nodeCount: nodes.length });
|
||||||
@@ -78,6 +82,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
layoutDirection: "DOWN",
|
layoutDirection: "DOWN",
|
||||||
edgeRouting: "ORTHOGONAL",
|
edgeRouting: "ORTHOGONAL",
|
||||||
isLayouting: false,
|
isLayouting: false,
|
||||||
|
highlightedNodeId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
29
apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx
Normal file
29
apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx
Normal file
37
apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx
Normal file
95
apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx
Normal file
20
apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx
Normal file
13
apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx
Normal file
33
apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx
Normal file
13
apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx
Normal file
29
apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/web/src/modules/diagram/types/bpmn/constants.ts
Normal file
90
apps/web/src/modules/diagram/types/bpmn/constants.ts
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/web/src/modules/diagram/types/bpmn/index.ts
Normal file
28
apps/web/src/modules/diagram/types/bpmn/index.ts
Normal 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";
|
||||||
Reference in New Issue
Block a user