From 0a7838aa60b805026136f1a6a80af35d3e04487c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:06:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=202.3=20=E2=80=94=20B?= =?UTF-8?q?PMN=20diagram=20type=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2-3-bpmn-diagram-type-renderer.md | 790 ++++++++++++++++++ .../sprint-status.yaml | 2 +- apps/web/src/assets/styles/globals.css | 201 +++++ .../components/editor/DiagramCanvas.tsx | 148 +++- .../src/modules/diagram/lib/bfs-path.test.ts | 76 ++ apps/web/src/modules/diagram/lib/bfs-path.ts | 56 ++ .../modules/diagram/lib/bpmn-layout.test.ts | 248 ++++++ .../src/modules/diagram/lib/bpmn-layout.ts | 408 +++++++++ .../web/src/modules/diagram/lib/elk-layout.ts | 75 +- .../diagram/lib/graph-converter.test.ts | 122 ++- .../modules/diagram/lib/graph-converter.ts | 222 ++++- .../diagram/stores/useGraphStore.test.ts | 23 + .../modules/diagram/stores/useGraphStore.ts | 5 + .../diagram/types/bpmn/BpmnActivityNode.tsx | 29 + .../diagram/types/bpmn/BpmnAnnotationNode.tsx | 16 + .../types/bpmn/BpmnAssociationEdge.tsx | 32 + .../diagram/types/bpmn/BpmnDataObjectNode.tsx | 46 + .../diagram/types/bpmn/BpmnEndEventNode.tsx | 37 + .../diagram/types/bpmn/BpmnGatewayNode.tsx | 95 +++ .../diagram/types/bpmn/BpmnGroupNode.tsx | 20 + .../diagram/types/bpmn/BpmnLaneNode.tsx | 13 + .../diagram/types/bpmn/BpmnMessageEdge.tsx | 33 + .../types/bpmn/BpmnMessageEventNode.tsx | 58 ++ .../diagram/types/bpmn/BpmnPoolNode.tsx | 13 + .../diagram/types/bpmn/BpmnSequenceEdge.tsx | 29 + .../diagram/types/bpmn/BpmnStartEventNode.tsx | 37 + .../diagram/types/bpmn/BpmnSubprocessNode.tsx | 30 + .../diagram/types/bpmn/BpmnTimerEventNode.tsx | 58 ++ .../modules/diagram/types/bpmn/constants.ts | 90 ++ .../src/modules/diagram/types/bpmn/index.ts | 28 + 30 files changed, 3024 insertions(+), 16 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-3-bpmn-diagram-type-renderer.md create mode 100644 apps/web/src/modules/diagram/lib/bfs-path.test.ts create mode 100644 apps/web/src/modules/diagram/lib/bfs-path.ts create mode 100644 apps/web/src/modules/diagram/lib/bpmn-layout.test.ts create mode 100644 apps/web/src/modules/diagram/lib/bpmn-layout.ts create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnAnnotationNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnAssociationEdge.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnDataObjectNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnMessageEventNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnStartEventNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnSubprocessNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/BpmnTimerEventNode.tsx create mode 100644 apps/web/src/modules/diagram/types/bpmn/constants.ts create mode 100644 apps/web/src/modules/diagram/types/bpmn/index.ts diff --git a/_bmad-output/implementation-artifacts/2-3-bpmn-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-3-bpmn-diagram-type-renderer.md new file mode 100644 index 0000000..447fe1f --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-3-bpmn-diagram-type-renderer.md @@ -0,0 +1,790 @@ +# Story 2.3: BPMN Diagram Type Renderer + +Status: done + + + +## 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 = { + "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; + +export function BpmnActivityNode({ data }: NodeProps) { + return ( +
+ {data.tag &&
{data.tag}
} +
{data.label}
+ + +
+ ); +} +``` + +**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 `
` with `text-center`. The node's total height for @xyflow = shape height + labelH from BPMN_SIZES. + +**Handles:** @xyflow/react requires `` 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 ( + + ); +} +``` + +**SVG marker defs:** Add arrowhead marker definitions to the canvas (filled arrow for sequence, open arrow for message). Use an SVG `` 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 { + 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 { + 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; edgeSet: Set } { + const forward: Record = {}; + const backward: Record = {}; + for (const e of edges) { + (forward[e.from] ??= []).push(e); + (backward[e.to] ??= []).push(e); + } + + const nodeSet = new Set([startId]); + const edgeSet = new Set(); + + // 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 ( +
+
{data.label}
+
+ ); +} + +// Lane node type for @xyflow/react +function BpmnLaneNode({ data }: NodeProps) { + return ( +
+
{data.label}
+
+ ); +} +``` + +**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:** `` +- **End Event:** `` +- **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` 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` diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index eed3f63..1cb660a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -52,7 +52,7 @@ development_status: epic-2: in-progress 2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: 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-5-org-chart-diagram-type-renderer: backlog 2-6-architecture-diagram-type-renderer: backlog diff --git a/apps/web/src/assets/styles/globals.css b/apps/web/src/assets/styles/globals.css index ec6b060..5c65554 100644 --- a/apps/web/src/assets/styles/globals.css +++ b/apps/web/src/assets/styles/globals.css @@ -27,6 +27,13 @@ --diagram-architecture: oklch(0.552 0.016 286); --diagram-sequence: oklch(0.795 0.184 86); --diagram-flowchart: oklch(0.645 0.246 16); + /* BPMN element-specific colors */ + --bpmn-start-event: #2ecc71; + --bpmn-end-event: #e74c3c; + --bpmn-timer-event: #3498db; + --bpmn-message-event: #f39c12; + --bpmn-gateway: #3498db; + --bpmn-data-object: #f39c12; } .dark { @@ -38,10 +45,204 @@ --node-hover: oklch(0.623 0.214 260 / 12%); --edge-default: oklch(0.55 0.01 286); --edge-selected: oklch(0.623 0.214 260); + --bpmn-start-event: #27ae60; + --bpmn-end-event: #c0392b; + --bpmn-timer-event: #5dade2; + --bpmn-message-event: #f5b041; + --bpmn-gateway: #5dade2; + --bpmn-data-object: #f5b041; } /* ELK layout animation — only active during auto-layout transitions */ .react-flow__node.layouting { transition: transform 200ms ease-out; } + + /* ── BPMN Node Styles ─────────────────────────────────────────────────── */ + + .bpmn-activity { + background: var(--node-bg); + border: 1.5px solid var(--diagram-bpmn); + border-radius: 8px; + padding: 8px 12px; + min-width: 200px; + max-width: 280px; + font-size: 12px; + cursor: pointer; + } + + .bpmn-activity-tag { + font-weight: 600; + font-size: 11px; + color: var(--diagram-bpmn); + margin-bottom: 4px; + } + + .bpmn-activity-label { + color: var(--foreground); + line-height: 1.4; + } + + .bpmn-subprocess { + background: var(--node-bg); + border: 1.5px solid var(--diagram-bpmn); + border-radius: 8px; + padding: 8px 12px 20px; + min-width: 200px; + max-width: 280px; + font-size: 12px; + position: relative; + cursor: pointer; + } + + .bpmn-subprocess-marker { + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 16px; + height: 16px; + border: 1.5px solid var(--diagram-bpmn); + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--diagram-bpmn); + line-height: 1; + } + + .bpmn-event-node { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + .bpmn-event-label { + margin-top: 4px; + font-size: 11px; + color: var(--muted-foreground); + text-align: center; + max-width: 140px; + word-wrap: break-word; + line-height: 1.3; + } + + .bpmn-gateway-node { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + .bpmn-data-object-node { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + .bpmn-annotation { + border-left: 2px solid var(--muted-foreground); + padding: 6px 10px; + font-size: 11px; + color: var(--muted-foreground); + max-width: 220px; + cursor: pointer; + } + + .bpmn-annotation-text { + line-height: 1.4; + } + + /* ── BPMN Pool & Lane Styles ──────────────────────────────────────────── */ + + .bpmn-pool { + border: 2px solid var(--node-border); + border-radius: 4px; + background: transparent; + pointer-events: none; + position: relative; + } + + .bpmn-pool-label { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 32px; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-weight: 600; + font-size: 12px; + color: var(--foreground); + display: flex; + align-items: center; + justify-content: center; + border-right: 1px solid var(--node-border); + pointer-events: auto; + } + + .bpmn-lane { + border-top: 1px solid var(--node-border); + background: transparent; + pointer-events: none; + position: relative; + } + + .bpmn-lane-label { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 24px; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-size: 11px; + color: var(--muted-foreground); + display: flex; + align-items: center; + justify-content: center; + border-right: 1px dashed var(--node-border); + pointer-events: auto; + } + + /* ── BPMN Group Styles ───────────────────────────────────────────────── */ + + .bpmn-group { + border: 2px dashed var(--node-border); + border-radius: 8px; + background: transparent; + pointer-events: none; + position: relative; + } + + .bpmn-group-label { + position: absolute; + top: -10px; + left: 12px; + font-size: 11px; + font-weight: 600; + color: var(--muted-foreground); + background: var(--canvas-bg); + padding: 0 6px; + pointer-events: auto; + } + + /* ── Path Highlighting ────────────────────────────────────────────────── */ + + .react-flow__node.dimmed, + .react-flow__edge.dimmed { + opacity: 0.2; + transition: opacity 200ms ease-out; + } + + .react-flow__node.highlighted { + filter: drop-shadow(0 0 6px var(--diagram-bpmn)); + transition: filter 200ms ease-out; + } } diff --git a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx index b3d450d..b555e1d 100644 --- a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +++ b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { ReactFlow, ReactFlowProvider, @@ -9,11 +10,91 @@ import { BackgroundVariant, Panel, } from "@xyflow/react"; +import type { Node } from "@xyflow/react"; import { useGraphStore } from "../../stores/useGraphStore"; import { useAutoLayout } from "../../hooks/useAutoLayout"; +import { bfsPath } from "../../lib/bfs-path"; +import { + BpmnActivityNode, + BpmnSubprocessNode, + BpmnStartEventNode, + BpmnEndEventNode, + BpmnTimerEventNode, + BpmnMessageEventNode, + BpmnGatewayNode, + BpmnDataObjectNode, + BpmnAnnotationNode, + BpmnPoolNode, + BpmnLaneNode, + BpmnGroupNode, + BpmnSequenceEdge, + BpmnMessageEdge, + BpmnAssociationEdge, +} from "../../types/bpmn"; -const nodeTypes = {}; +const nodeTypes = { + bpmnActivity: BpmnActivityNode, + bpmnSubprocess: BpmnSubprocessNode, + bpmnStartEvent: BpmnStartEventNode, + bpmnEndEvent: BpmnEndEventNode, + bpmnTimerEvent: BpmnTimerEventNode, + bpmnMessageEvent: BpmnMessageEventNode, + bpmnGateway: BpmnGatewayNode, + bpmnDataObject: BpmnDataObjectNode, + bpmnAnnotation: BpmnAnnotationNode, + bpmnPool: BpmnPoolNode, + bpmnLane: BpmnLaneNode, + bpmnGroup: BpmnGroupNode, +}; + +const edgeTypes = { + bpmnSequence: BpmnSequenceEdge, + bpmnMessage: BpmnMessageEdge, + bpmnAssociation: BpmnAssociationEdge, +}; + +/** Container node types that should not participate in BFS highlighting */ +const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup"]); + +function BpmnMarkerDefs() { + return ( + + + + + + + + + + + ); +} function CanvasInner() { const nodes = useGraphStore((s) => s.nodes); @@ -21,18 +102,83 @@ function CanvasInner() { const onNodesChange = useGraphStore((s) => s.onNodesChange); const onEdgesChange = useGraphStore((s) => s.onEdgesChange); const onViewportChange = useGraphStore((s) => s.onViewportChange); + const highlightedNodeId = useGraphStore((s) => s.highlightedNodeId); + const setHighlightedNodeId = useGraphStore((s) => s.setHighlightedNodeId); + const setNodes = useGraphStore((s) => s.setNodes); + const setEdges = useGraphStore((s) => s.setEdges); const { isLayouting } = useAutoLayout(); + const clearHighlight = useCallback(() => { + if (!highlightedNodeId) return; + setHighlightedNodeId(null); + setNodes(nodes.map((n) => ({ ...n, className: undefined }))); + setEdges(edges.map((e) => ({ ...e, className: undefined }))); + }, [highlightedNodeId, nodes, edges, setHighlightedNodeId, setNodes, setEdges]); + + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + // Skip container nodes (pools, lanes, groups) + if (CONTAINER_TYPES.has(node.type ?? "")) return; + + const store = useGraphStore.getState(); + + // Toggle off if clicking the same node + if (store.highlightedNodeId === node.id) { + store.setHighlightedNodeId(null); + store.setNodes( + store.nodes.map((n) => ({ ...n, className: undefined })), + ); + store.setEdges( + store.edges.map((e) => ({ ...e, className: undefined })), + ); + return; + } + + // Compute BFS path from clicked node + const graphEdges = store.edges + .filter((e) => e.type !== "bpmnGroup") + .map((e) => ({ from: e.source, to: e.target })); + const { nodeSet, edgeSet } = bfsPath(node.id, graphEdges); + + // Apply highlight/dim classes + store.setHighlightedNodeId(node.id); + store.setNodes( + store.nodes.map((n) => { + if (CONTAINER_TYPES.has(n.type ?? "")) { + return { ...n, className: undefined }; + } + return { + ...n, + className: nodeSet.has(n.id) ? "highlighted" : "dimmed", + }; + }), + ); + store.setEdges( + store.edges.map((e) => ({ + ...e, + className: edgeSet.has(`${e.source}->${e.target}`) + ? "highlighted" + : "dimmed", + })), + ); + }, + [], + ); + return (
+ { + 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); + }); +}); diff --git a/apps/web/src/modules/diagram/lib/bfs-path.ts b/apps/web/src/modules/diagram/lib/bfs-path.ts new file mode 100644 index 0000000..e668122 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/bfs-path.ts @@ -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; edgeSet: Set } { + const forward: Record> = {}; + const backward: Record> = {}; + + for (const e of edges) { + (forward[e.from] ??= []).push(e); + (backward[e.to] ??= []).push(e); + } + + const nodeSet = new Set([startId]); + const edgeSet = new Set(); + + // 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 }; +} diff --git a/apps/web/src/modules/diagram/lib/bpmn-layout.test.ts b/apps/web/src/modules/diagram/lib/bpmn-layout.test.ts new file mode 100644 index 0000000..699484c --- /dev/null +++ b/apps/web/src/modules/diagram/lib/bpmn-layout.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/modules/diagram/lib/bpmn-layout.ts b/apps/web/src/modules/diagram/lib/bpmn-layout.ts new file mode 100644 index 0000000..d7b1ea2 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/bpmn-layout.ts @@ -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; + }>; +} + +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 { + 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 = {}, +): 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(); + 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(); + 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 { + 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; +} + +// ── 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 }, + positions: Map, + 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 }, + positions, + routing, + ), + ); + } + } + + return edges; +} + +// ── Apply BPMN Layout to @xyflow/react Nodes ────────────────────────────── + +export function applyBpmnPositions( + positions: Map, + 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 }, + }; + }); +} diff --git a/apps/web/src/modules/diagram/lib/elk-layout.ts b/apps/web/src/modules/diagram/lib/elk-layout.ts index 09c3612..a907a39 100644 --- a/apps/web/src/modules/diagram/lib/elk-layout.ts +++ b/apps/web/src/modules/diagram/lib/elk-layout.ts @@ -1,8 +1,13 @@ import type { Node, Edge } from "@xyflow/react"; import type { ElkNode, ElkExtendedEdge } from "elkjs"; -import type { DiagramNode } from "../types/graph"; +import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph"; import type { ElkWorkerResponse } from "./elk-worker"; +import { + buildBpmnElkGraph, + resolveBpmnPositions, + applyBpmnPositions, +} from "./bpmn-layout"; // ── Layout Options ────────────────────────────────────────────────────────── @@ -123,6 +128,55 @@ export function terminateWorker(): void { } } +// ── BPMN Compound Layout Detection ────────────────────────────────────────── + +/** Reconstruct GraphData from @xyflow nodes for compound BPMN layout. */ +function flowToBpmnGraphData( + nodes: Node[], + edges: Edge[], +): GraphData { + const poolNodes = nodes.filter((n) => n.type === "bpmnPool"); + const laneNodes = nodes.filter((n) => n.type === "bpmnLane"); + const contentNodes = nodes.filter( + (n) => + n.type !== "bpmnPool" && + n.type !== "bpmnLane" && + n.type !== "bpmnGroup", + ); + + return { + pools: poolNodes.map((pool) => ({ + id: pool.id, + label: String((pool.data as Record).label ?? ""), + lanes: laneNodes + .filter((lane) => lane.parentId === pool.id) + .map((lane) => ({ + id: lane.id, + label: String( + (lane.data as Record).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 | undefined)?.type as + | string + | undefined, + })), + }; +} + // ── Main Layout Function ──────────────────────────────────────────────────── export function computeLayout( @@ -142,7 +196,17 @@ export function computeLayout( pendingReject = null; } - const elkGraph = buildElkGraph(nodes, edges, options); + // Detect BPMN compound layout (pools present in @xyflow nodes) + const isCompound = nodes.some((n) => n.type === "bpmnPool"); + + let elkGraph: ElkNode; + if (isCompound) { + const graphData = flowToBpmnGraphData(nodes, edges); + elkGraph = buildBpmnElkGraph(graphData, options); + } else { + elkGraph = buildElkGraph(nodes, edges, options); + } + const w = getWorker(); let settled = false; @@ -163,7 +227,12 @@ export function computeLayout( w.removeEventListener("message", handler); if (event.data.type === "result" && event.data.graph) { - resolve(resolvePositions(event.data.graph, nodes)); + if (isCompound) { + const positions = resolveBpmnPositions(event.data.graph); + resolve(applyBpmnPositions(positions, nodes)); + } else { + resolve(resolvePositions(event.data.graph, nodes)); + } } else { reject(new Error(event.data.message ?? "ELK layout failed")); } diff --git a/apps/web/src/modules/diagram/lib/graph-converter.test.ts b/apps/web/src/modules/diagram/lib/graph-converter.test.ts index 3989da9..8374669 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.test.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.test.ts @@ -136,9 +136,125 @@ describe("graphToFlow", () => { })); const result = graphToFlow({ nodes, edges: [] }); expect(result.nodes).toHaveLength(6); - result.nodes.forEach((node) => { - expect(node.type).toBe("default"); - }); + // bpmn: prefix resolves to BPMN node type even without diagramType context + expect(result.nodes[0]!.type).toBe("bpmnActivity"); + // Non-BPMN types without diagramType context stay default + expect(result.nodes[1]!.type).toBe("default"); + expect(result.nodes[5]!.type).toBe("default"); + }); + + it("should resolve BPMN node types when diagramType is bpmn", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "BPMN Test", + diagramType: "bpmn", + }, + nodes: [ + { id: "n1", type: "activity", label: "Task" }, + { id: "n2", type: "bpmn:gateway-exclusive", label: "Decision?" }, + { id: "n3", type: "start-event", label: "Start" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("bpmnActivity"); + expect(result.nodes[1]!.type).toBe("bpmnGateway"); + expect(result.nodes[2]!.type).toBe("bpmnStartEvent"); + }); + + it("should resolve BPMN edge types when diagramType is bpmn", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "BPMN Edge Test", + diagramType: "bpmn", + }, + nodes: [ + { id: "a", type: "activity", label: "A" }, + { id: "b", type: "activity", label: "B" }, + { id: "c", type: "activity", label: "C" }, + ], + edges: [ + { id: "e1", from: "a", to: "b", type: "sequence" }, + { id: "e2", from: "a", to: "c", type: "message" }, + { id: "e3", from: "b", to: "c", type: "association" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("bpmnSequence"); + expect(result.edges[1]!.type).toBe("bpmnMessage"); + expect(result.edges[2]!.type).toBe("bpmnAssociation"); + }); + + it("should create pool and lane nodes for BPMN with pools", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Pool Test", + diagramType: "bpmn", + }, + pools: [ + { + id: "pool1", + label: "Pool", + lanes: [{ id: "lane1", label: "Lane 1" }], + }, + ], + nodes: [{ id: "n1", type: "activity", label: "Task", lane: "lane1" }], + edges: [], + }; + const result = graphToFlow(data); + // pool + lane + content node = 3 + expect(result.nodes).toHaveLength(3); + const poolNode = result.nodes.find((n) => n.id === "pool1"); + expect(poolNode?.type).toBe("bpmnPool"); + const laneNode = result.nodes.find((n) => n.id === "lane1"); + expect(laneNode?.type).toBe("bpmnLane"); + expect(laneNode?.parentId).toBe("pool1"); + const contentNode = result.nodes.find((n) => n.id === "n1"); + expect(contentNode?.parentId).toBe("lane1"); + }); + + it("should filter pool/lane nodes from flowToGraph output", () => { + const nodes = [ + { id: "pool1", type: "bpmnPool", position: { x: 0, y: 0 }, data: { label: "Pool" } }, + { id: "lane1", type: "bpmnLane", position: { x: 0, y: 0 }, data: { label: "Lane" } }, + { id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } }, + ]; + const result = flowToGraph(nodes, []); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]!.id).toBe("n1"); + }); + + it("should preserve pools in flowToGraph roundtrip", () => { + const nodes = [ + { id: "pool1", type: "bpmnPool", position: { x: 0, y: 0 }, data: { label: "Main Pool" } }, + { id: "lane1", type: "bpmnLane", position: { x: 0, y: 0 }, parentId: "pool1", data: { label: "Lane A" } }, + { id: "lane2", type: "bpmnLane", position: { x: 0, y: 0 }, parentId: "pool1", data: { label: "Lane B" } }, + { id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } }, + ]; + const result = flowToGraph(nodes, []); + expect(result.pools).toBeDefined(); + expect(result.pools).toHaveLength(1); + expect(result.pools![0]!.id).toBe("pool1"); + expect(result.pools![0]!.label).toBe("Main Pool"); + expect(result.pools![0]!.lanes).toHaveLength(2); + expect(result.pools![0]!.lanes[0]!.id).toBe("lane1"); + }); + + it("should preserve groups in flowToGraph roundtrip", () => { + const nodes = [ + { id: "g1", type: "bpmnGroup", position: { x: 0, y: 0 }, data: { label: "Group 1", color: "#ff0000" } }, + { id: "n1", type: "bpmnActivity", position: { x: 10, y: 20 }, data: { type: "bpmn:activity", label: "Task" } }, + ]; + const result = flowToGraph(nodes, []); + expect(result.groups).toBeDefined(); + expect(result.groups).toHaveLength(1); + expect(result.groups![0]!.id).toBe("g1"); + expect(result.groups![0]!.label).toBe("Group 1"); + expect(result.groups![0]!.color).toBe("#ff0000"); + expect(result.nodes).toHaveLength(1); }); }); diff --git a/apps/web/src/modules/diagram/lib/graph-converter.ts b/apps/web/src/modules/diagram/lib/graph-converter.ts index a7340a8..f7bba30 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.ts @@ -1,10 +1,47 @@ import type { Node, Edge } from "@xyflow/react"; -import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph"; +import type { + DiagramNode, + DiagramEdge, + DiagramType, + GraphData, +} from "../types/graph"; +import { + resolveBpmnNodeType, + resolveBpmnEdgeType, +} from "../types/bpmn/constants"; -export function graphNodeToFlowNode(node: DiagramNode): Node { +// ── Node Type Resolution ─────────────────────────────────────────────────── + +function resolveFlowNodeType( + diagramType: DiagramType | undefined, + nodeType: string, +): string { + if (diagramType === "bpmn" || nodeType.startsWith("bpmn:")) { + return resolveBpmnNodeType(nodeType); + } + // Future: er, orgchart, architecture, sequence, flowchart + return "default"; +} + +function resolveFlowEdgeType( + diagramType: DiagramType | undefined, + edgeType: string | undefined, +): string { + if (diagramType === "bpmn") { + return resolveBpmnEdgeType(edgeType); + } + return "default"; +} + +// ── Graph → Flow Conversion ──────────────────────────────────────────────── + +export function graphNodeToFlowNode( + node: DiagramNode, + diagramType?: DiagramType, +): Node { return { id: node.id, - type: "default", + type: resolveFlowNodeType(diagramType, node.type), position: node.position ?? { x: 0, y: 0 }, data: { ...node, @@ -13,24 +50,148 @@ export function graphNodeToFlowNode(node: DiagramNode): Node { }; } -export function graphEdgeToFlowEdge(edge: DiagramEdge): Edge { +export function graphEdgeToFlowEdge( + edge: DiagramEdge, + diagramType?: DiagramType, +): Edge { return { id: edge.id, source: edge.from, target: edge.to, label: edge.label, - type: "default", + type: resolveFlowEdgeType(diagramType, edge.type), data: { ...edge }, }; } +/** Convert BPMN pools/lanes into @xyflow/react group nodes. + * Also sets parentId on child nodes for @xyflow/react grouping. */ +function createPoolLaneNodes( + data: GraphData, +): { poolLaneNodes: Node[]; childParentMap: Map } { + const poolLaneNodes: Node[] = []; + const childParentMap = new Map(); + + if (!data.pools) return { poolLaneNodes, childParentMap }; + + // Build lane lookup from nodes + const laneIds = new Set(); + 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 } { + const groupNodes: Node[] = []; + const groupChildMap = new Map(); + + if (!data.groups) return { groupNodes, groupChildMap }; + + for (const group of data.groups) { + groupNodes.push({ + id: group.id, + type: "bpmnGroup", + position: { x: 0, y: 0 }, + data: { label: group.label, type: "bpmn:group", color: group.color }, + style: { width: 100, height: 100 }, + }); + } + + // Map nodes to their group + for (const n of data.nodes) { + if (n.group && data.groups.some((g) => g.id === n.group)) { + groupChildMap.set(n.id, n.group); + } + } + + return { groupNodes, groupChildMap }; +} + export function graphToFlow(data: GraphData): { nodes: Node[]; edges: Edge[] } { + const diagramType = data.meta?.diagramType; + + if (diagramType === "bpmn") { + const containerNodes: Node[] = []; + const childParentMap = new Map(); + + // Create pool/lane group nodes if present + if (data.pools?.length) { + const { poolLaneNodes, childParentMap: plMap } = + createPoolLaneNodes(data); + containerNodes.push(...poolLaneNodes); + for (const [k, v] of plMap) childParentMap.set(k, v); + } + + // Create group nodes if present + if (data.groups?.length) { + const { groupNodes, groupChildMap } = createGroupNodes(data); + containerNodes.push(...groupNodes); + for (const [k, v] of groupChildMap) { + // Lane parentId takes precedence over group parentId + if (!childParentMap.has(k)) { + childParentMap.set(k, v); + } + } + } + + const contentNodes = (data.nodes ?? []).map((node) => { + const flowNode = graphNodeToFlowNode(node, diagramType); + const parentId = childParentMap.get(node.id); + if (parentId) { + flowNode.parentId = parentId; + } + return flowNode; + }); + + return { + nodes: [...containerNodes, ...contentNodes], + edges: (data.edges ?? []).map((e) => graphEdgeToFlowEdge(e, diagramType)), + }; + } + return { - nodes: (data.nodes ?? []).map(graphNodeToFlowNode), - edges: (data.edges ?? []).map(graphEdgeToFlowEdge), + nodes: (data.nodes ?? []).map((n) => graphNodeToFlowNode(n, diagramType)), + edges: (data.edges ?? []).map((e) => graphEdgeToFlowEdge(e, diagramType)), }; } +// ── Flow → Graph Conversion ──────────────────────────────────────────────── + export function flowNodeToGraphNode(node: Node): DiagramNode { const data = node.data as unknown as DiagramNode & { label: string }; return { @@ -68,9 +229,54 @@ export function flowToGraph( edges: Edge[], meta?: GraphData["meta"], ): GraphData { + const containerTypes = new Set([ + "bpmnPool", + "bpmnLane", + "bpmnGroup", + ]); + + // Reconstruct pools from pool/lane group nodes + const poolNodes = nodes.filter((n) => n.type === "bpmnPool"); + const laneNodes = nodes.filter((n) => n.type === "bpmnLane"); + const groupNodes = nodes.filter((n) => n.type === "bpmnGroup"); + + const pools = + poolNodes.length > 0 + ? poolNodes.map((pool) => ({ + id: pool.id, + label: String( + (pool.data as Record).label ?? "", + ), + lanes: laneNodes + .filter((lane) => lane.parentId === pool.id) + .map((lane) => ({ + id: lane.id, + label: String( + (lane.data as Record).label ?? "", + ), + })), + })) + : undefined; + + const groups = + groupNodes.length > 0 + ? groupNodes.map((g) => { + const d = g.data as Record; + return { + id: g.id, + label: String(d.label ?? ""), + ...(d.color ? { color: String(d.color) } : {}), + }; + }) + : undefined; + return { meta, - nodes: nodes.map(flowNodeToGraphNode), + nodes: nodes + .filter((n) => !containerTypes.has(n.type ?? "")) + .map(flowNodeToGraphNode), edges: edges.map(flowEdgeToGraphEdge), + ...(pools && { pools }), + ...(groups && { groups }), }; } diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.test.ts b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts index 68da482..9e91131 100644 --- a/apps/web/src/modules/diagram/stores/useGraphStore.test.ts +++ b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts @@ -156,4 +156,27 @@ describe("useGraphStore", () => { expect(useGraphStore.getState().isLayouting).toBe(false); }); }); + + describe("highlightedNodeId", () => { + it("should default to null", () => { + expect(useGraphStore.getState().highlightedNodeId).toBeNull(); + }); + + it("should set highlighted node id", () => { + useGraphStore.getState().setHighlightedNodeId("n1"); + expect(useGraphStore.getState().highlightedNodeId).toBe("n1"); + }); + + it("should clear highlighted node id", () => { + useGraphStore.getState().setHighlightedNodeId("n1"); + useGraphStore.getState().setHighlightedNodeId(null); + expect(useGraphStore.getState().highlightedNodeId).toBeNull(); + }); + + it("should reset highlighted node id on reset()", () => { + useGraphStore.getState().setHighlightedNodeId("n1"); + useGraphStore.getState().reset(); + expect(useGraphStore.getState().highlightedNodeId).toBeNull(); + }); + }); }); diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.ts b/apps/web/src/modules/diagram/stores/useGraphStore.ts index 64e1796..8c9f8b2 100644 --- a/apps/web/src/modules/diagram/stores/useGraphStore.ts +++ b/apps/web/src/modules/diagram/stores/useGraphStore.ts @@ -22,6 +22,7 @@ interface GraphState { layoutDirection: LayoutDirection; edgeRouting: EdgeRouting; isLayouting: boolean; + highlightedNodeId: string | null; setNodes: (nodes: Node[]) => void; setEdges: (edges: Edge[]) => void; onNodesChange: OnNodesChange; @@ -30,6 +31,7 @@ interface GraphState { setLayoutDirection: (direction: LayoutDirection) => void; setEdgeRouting: (routing: EdgeRouting) => void; setIsLayouting: (isLayouting: boolean) => void; + setHighlightedNodeId: (id: string | null) => void; initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void; reset: () => void; } @@ -43,6 +45,7 @@ export const useGraphStore = create((set, get) => ({ layoutDirection: "DOWN", edgeRouting: "ORTHOGONAL", isLayouting: false, + highlightedNodeId: null, setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }), setEdges: (edges) => set({ edges }), @@ -63,6 +66,7 @@ export const useGraphStore = create((set, get) => ({ setLayoutDirection: (layoutDirection) => set({ layoutDirection }), setEdgeRouting: (edgeRouting) => set({ edgeRouting }), setIsLayouting: (isLayouting) => set({ isLayouting }), + setHighlightedNodeId: (highlightedNodeId) => set({ highlightedNodeId }), initializeFromGraphData: (nodes, edges) => { set({ nodes, edges, nodeCount: nodes.length }); @@ -78,6 +82,7 @@ export const useGraphStore = create((set, get) => ({ layoutDirection: "DOWN", edgeRouting: "ORTHOGONAL", isLayouting: false, + highlightedNodeId: null, }); }, })); diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx new file mode 100644 index 0000000..434f439 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnActivityNode.tsx @@ -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 ( +
+ {d.tag &&
{d.tag}
} +
{d.label}
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnAnnotationNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnAnnotationNode.tsx new file mode 100644 index 0000000..079ed9b --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnAnnotationNode.tsx @@ -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 ( +
+
{d.label}
+ + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnAssociationEdge.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnAssociationEdge.tsx new file mode 100644 index 0000000..f43983c --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnAssociationEdge.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnDataObjectNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnDataObjectNode.tsx new file mode 100644 index 0000000..e36caf2 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnDataObjectNode.tsx @@ -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 ( +
+ + + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx new file mode 100644 index 0000000..daa8a1c --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnEndEventNode.tsx @@ -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 ( +
+ + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx new file mode 100644 index 0000000..985cc69 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnGatewayNode.tsx @@ -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 ( + + + + + ); + } + if (bare === "gateway-parallel") { + return ( + + + + + ); + } + // gateway-inclusive + return ( + + ); +} + +export function BpmnGatewayNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+ + + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx new file mode 100644 index 0000000..5a7aa2b --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnGroupNode.tsx @@ -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 ( +
+ {d.label &&
{d.label}
} +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx new file mode 100644 index 0000000..9a5bfb3 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnLaneNode.tsx @@ -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 ( +
+
{d.label}
+
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx new file mode 100644 index 0000000..f5ff6a9 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEdge.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEventNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEventNode.tsx new file mode 100644 index 0000000..f737bd5 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnMessageEventNode.tsx @@ -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 ( +
+ + + + + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx new file mode 100644 index 0000000..6bd0604 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnPoolNode.tsx @@ -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 ( +
+
{d.label}
+
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx new file mode 100644 index 0000000..48989a6 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnSequenceEdge.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnStartEventNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnStartEventNode.tsx new file mode 100644 index 0000000..30a6e2f --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnStartEventNode.tsx @@ -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 ( +
+ + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnSubprocessNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnSubprocessNode.tsx new file mode 100644 index 0000000..14da88a --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnSubprocessNode.tsx @@ -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 ( +
+ {d.tag &&
{d.tag}
} +
{d.label}
+
+
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/BpmnTimerEventNode.tsx b/apps/web/src/modules/diagram/types/bpmn/BpmnTimerEventNode.tsx new file mode 100644 index 0000000..8c79047 --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/BpmnTimerEventNode.tsx @@ -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 ( +
+ + + + + + + {d.label &&
{d.label}
} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/constants.ts b/apps/web/src/modules/diagram/types/bpmn/constants.ts new file mode 100644 index 0000000..008aa3f --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/constants.ts @@ -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" + ); +} diff --git a/apps/web/src/modules/diagram/types/bpmn/index.ts b/apps/web/src/modules/diagram/types/bpmn/index.ts new file mode 100644 index 0000000..7614aae --- /dev/null +++ b/apps/web/src/modules/diagram/types/bpmn/index.ts @@ -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";