diff --git a/_bmad-output/implementation-artifacts/2-4-entity-relationship-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-4-entity-relationship-diagram-type-renderer.md new file mode 100644 index 0000000..fd04bfb --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-4-entity-relationship-diagram-type-renderer.md @@ -0,0 +1,603 @@ +# Story 2.4: Entity-Relationship Diagram Type Renderer + +Status: done + + + +## Story + +As a user, +I want to create and view E-R diagrams with entities, attributes, and relationships, +so that I can model data structures with proper cardinality notation. + +## Acceptance Criteria + +1. **Given** I open or create an E-R diagram, **When** the canvas renders, **Then** entities display as rectangles with a header (entity name) and rows for each attribute (name, type, constraints), **And** primary key attributes are marked with a key icon, **And** foreign key attributes show a reference indicator. + +2. **Given** entities have relationships, **When** rendered, **Then** relationship edges show cardinality notation at each end (1, N, M, 0..1, 0..N, 1..N), **And** relationship labels appear at edge midpoints with a background pill. + +3. **Given** an E-R diagram has a many-to-many relationship, **When** auto-layout runs, **Then** junction tables (if present) are positioned between the related entities, **And** the layout clearly shows the M:N relationship structure. + +## Tasks / Subtasks + +- [x] Task 1: Create E-R constants and type registry (AC: #1) + - [x] 1.1: Create `apps/web/src/modules/diagram/types/er/constants.ts` with `ER_SIZES`, `resolveErNodeType()`, `resolveErEdgeType()`, `getErEntityHeight()` + - [x] 1.2: Create `apps/web/src/modules/diagram/types/er/index.ts` barrel export + +- [x] Task 2: Create ErEntityNode custom @xyflow/react component (AC: #1) + - [x] 2.1: Create `apps/web/src/modules/diagram/types/er/ErEntityNode.tsx` — table-like card with entity name header, attribute rows showing (name, type, PK icon, FK indicator, nullable/unique badges) + +- [x] Task 3: Create ErRelationshipEdge custom @xyflow/react component (AC: #2) + - [x] 3.1: Create `apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx` — edge with cardinality notation at source/target endpoints, midpoint label with background pill + +- [x] Task 4: Update graph converter for E-R type resolution (AC: #1, #2) + - [x] 4.1: Update `resolveFlowNodeType()` in `graph-converter.ts` to handle `er` diagramType and `er:` prefixed node types + - [x] 4.2: Update `resolveFlowEdgeType()` in `graph-converter.ts` to handle `er` diagramType + - [x] 4.3: Update `graphNodeToFlowNode()` to pass `columns` data through for E-R entity nodes (already handled by spread) + - [x] 4.4: Update `graphEdgeToFlowEdge()` to pass `cardinality` data through for E-R edges (already handled by spread) + +- [x] Task 5: Register E-R types in DiagramCanvas (AC: #1, #2, #3) + - [x] 5.1: Import E-R node/edge components in `DiagramCanvas.tsx` + - [x] 5.2: Add E-R entries to `nodeTypes` and `edgeTypes` objects (OUTSIDE component) + - [x] 5.3: Add E-R SVG marker defs for relationship arrows + +- [x] Task 6: E-R layout configuration (AC: #3) + - [x] 6.1: Ensure `computeLayout` uses flat `buildElkGraph` for E-R diagrams (no compound layout needed) + - [x] 6.2: Verify entity node widths are passed to ELK via `data.w` or `measured.width` for proper spacing + +- [x] Task 7: Add E-R CSS styles (AC: #1, #2) + - [x] 7.1: Add `.er-entity`, `.er-entity-header`, `.er-entity-row`, `.er-entity-indicator`, `.er-entity-col-name`, `.er-entity-col-type`, `.er-entity-constraint`, `.er-entity-empty`, `.er-cardinality` styles to `globals.css` + - [x] 7.2: Use `--diagram-er` (violet) accent color for E-R elements + +- [x] Task 8: Tests (AC: all) + - [x] 8.1: Unit tests for E-R constants — `resolveErNodeType`, `resolveErEdgeType`, `getErEntityHeight` (9 tests) + - [x] 8.2: Unit tests for graph converter E-R type mapping — node and edge types correctly resolved for `diagramType === "er"` (3 tests) + - [x] 8.3: All 93 tests pass (81 existing + 12 new) — no regressions + +## Dev Notes + +### Overview — What This Story Builds + +This story adds the E-R diagram type renderer: Entity-Relationship diagrams with table-like entity nodes showing attributes (columns) and relationship edges with cardinality notation. Unlike BPMN (Story 2.3), E-R diagrams use **flat ELK layout** (no compound pool/lane hierarchy). + +**This story builds:** +- 1 custom E-R node component (ErEntityNode — table-like card with attribute rows) +- 1 custom E-R edge component (ErRelationshipEdge — with cardinality labels at endpoints) +- E-R constants and type registry +- Graph converter E-R type resolution +- E-R CSS styles using `--diagram-er` violet accent + +**This story does NOT implement:** +- Other diagram types (Stories 2.5-2.8) +- Smart Inspector for E-R field editing (future epic) +- SQL DDL export from E-R (Epic 6, Story 6.2) +- 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):** E-R nodes use type-prefixed `type` field (`er:entity`). The `columns` field on DiagramNode stores entity attributes. The `cardinality` field on DiagramEdge stores relationship cardinality. + +2. **Component Structure:** Feature code in `~/modules/diagram/types/er/` — E-R-specific node components and constants. NOT co-located in route directories. + +3. **@xyflow/react Custom Nodes:** All custom node components use `NodeProps` typing. The `nodeTypes` and `edgeTypes` objects MUST be defined OUTSIDE the component (performance critical — Story 2.1/2.3 pattern). + +4. **ELK.js Flat Layout:** E-R uses standard flat `buildElkGraph()` — no compound hierarchy needed. The ELK layered algorithm naturally positions junction tables between related entities in an M:N structure. + +5. **Lean JSON Data Model:** No x/y positions stored for E-R nodes. All positioning computed by ELK at render time. Only entity `columns` data is stored. + +6. **Type Prefixing Convention:** `er:entity` prefix on DiagramNode.type. Edge type defaults to `"relationship"` for E-R. + +### E-R Node Types — Size Constants + +E-R entities are dynamic-height: the height depends on the number of columns. + +```typescript +export const ER_SIZES = { + entity: { + w: 260, // Fixed width for all entities + headerH: 36, // Entity name header + rowH: 24, // Per-attribute row + paddingY: 8, // Top/bottom padding + minRows: 1, // Minimum 1 row even if no columns + }, +} as const; + +/** Compute entity height based on column count */ +export function getErEntityHeight(columns?: Column[]): number { + const rows = Math.max(ER_SIZES.entity.minRows, columns?.length ?? 0); + return ER_SIZES.entity.headerH + rows * ER_SIZES.entity.rowH + ER_SIZES.entity.paddingY * 2; +} +``` + +### E-R Node Type → @xyflow/react Type Mapping + +| DiagramNode.type | @xyflow Node type | Component | +|---|---|---| +| `er:entity` | `erEntity` | `ErEntityNode` | + +Only one node type needed. The entity node handles all E-R data, including attribute display with PK/FK indicators. + +**Type resolution:** +```typescript +export function resolveErNodeType(type: string): string { + const bare = type.startsWith("er:") ? type.slice(3) : type; + switch (bare) { + case "entity": + default: + return "erEntity"; + } +} + +export function resolveErEdgeType(type: string | undefined): string { + // E-R only has one edge type: relationship + return "erRelationship"; +} +``` + +### E-R Edge Type — Cardinality Notation + +| DiagramEdge.type | @xyflow Edge type | Visual | +|---|---|---| +| `relationship` (or default) | `erRelationship` | Solid line with cardinality labels at endpoints, midpoint label | + +**Cardinality format:** The `DiagramEdge.cardinality` field stores notation like `"1:N"`, `"N:M"`, `"0..1:1"`, `"1..N:0..N"`. The format is `sourceCardinality:targetCardinality`, split on `:`. + +**Cardinality rendering approach:** + +Use @xyflow/react's `EdgeLabelRenderer` to position cardinality labels at the source and target ends. The edge itself uses `BaseEdge` + `getSmoothStepPath` for orthogonal routing. The midpoint label (relationship name) uses `BaseEdge`'s built-in `label` prop with `labelShowBg`. + +```typescript +import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function ErRelationshipEdge(props: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath({ ... }); + const cardinality = (props.data as { cardinality?: string })?.cardinality; + const [srcCard, tgtCard] = (cardinality ?? "").split(":"); + + return ( + <> + + + {/* Source cardinality positioned near source */} + {srcCard &&
{srcCard}
} + {/* Target cardinality positioned near target */} + {tgtCard &&
{tgtCard}
} +
+ + ); +} +``` + +**Positioning cardinality labels:** Calculate positions offset from source/target coordinates along the edge direction. Source label: ~20px from source endpoint. Target label: ~20px from target endpoint. Use the edge direction vector to compute offset positions. + +### ErEntityNode — Implementation Pattern + +The entity node renders as a table-like card: + +``` +┌──────────────────────────┐ +│ Entity Name │ ← Header (bold, violet accent border-top) +├──────────────────────────┤ +│ 🔑 id INTEGER │ ← PK row (key icon) +│ → user_id TEXT │ ← FK row (arrow indicator) +│ name VARCHAR │ ← Normal row +│ email VARCHAR ? │ ← Nullable (? indicator) +│ code TEXT U │ ← Unique (U indicator) +└──────────────────────────┘ +``` + +**Key implementation details:** + +```typescript +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode, Column } from "../graph"; + +export function ErEntityNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const columns = d.columns ?? []; + + return ( +
+
{d.label}
+
+ {columns.length === 0 && ( +
No attributes
+ )} + {columns.map((col, i) => ( +
+ + {col.isPrimaryKey ? "🔑" : col.isForeignKey ? "→" : " "} + + {col.name} + {col.type} + + {col.isNullable ? "?" : ""}{col.isUnique ? "U" : ""} + +
+ ))} +
+ + + + +
+ ); +} +``` + +**All 4 handles** (top/right/bottom/left) for edge connections in any layout direction — same pattern as BPMN nodes. + +### Graph Converter Updates + +Add E-R type resolution alongside the existing BPMN resolution: + +```typescript +// In resolveFlowNodeType(): +if (diagramType === "er" || nodeType.startsWith("er:")) { + return resolveErNodeType(nodeType); +} + +// In resolveFlowEdgeType(): +if (diagramType === "er") { + return resolveErEdgeType(edgeType); +} +``` + +**Import from er/constants.ts** — same pattern as BPMN imports. + +**No container nodes needed** — E-R has no pools/lanes/groups. The `graphToFlow()` function's existing default path (flat node mapping) handles E-R correctly. No need to add an `if (diagramType === "er")` block for container handling. + +**Columns data already preserved:** `graphNodeToFlowNode` spreads all node data into `data: { ...node }`, which includes `columns`. And `flowNodeToGraphNode` already preserves `columns` via the conditional spread. No changes needed. + +**Cardinality data already preserved:** `graphEdgeToFlowEdge` spreads all edge data into `data: { ...edge }`, which includes `cardinality`. And `flowEdgeToGraphEdge` already preserves `cardinality`. No changes needed. + +### DiagramCanvas Updates + +Add E-R node/edge types to the `nodeTypes` and `edgeTypes` objects: + +```typescript +import { ErEntityNode } from "../../types/er"; +import { ErRelationshipEdge } from "../../types/er"; + +const nodeTypes = { + // Existing BPMN types... + erEntity: ErEntityNode, +}; + +const edgeTypes = { + // Existing BPMN types... + erRelationship: ErRelationshipEdge, +}; +``` + +**E-R marker defs:** Add an SVG marker for E-R relationship arrows. A simple solid arrowhead marker, similar to BPMN but using `--diagram-er` color: + +```typescript +function ErMarkerDefs() { + return ( + + + + + + + + ); +} +``` + +**BFS Path Highlighting:** The existing `handleNodeClick` uses `CONTAINER_TYPES` to skip container nodes. E-R has no container nodes, so no changes needed. BFS highlighting works on E-R entity nodes out of the box (the `bfsPath` utility is diagram-type-agnostic). + +### Layout — ELK Flat Layout for E-R + +E-R uses the existing flat `buildElkGraph()` — no compound layout detection needed. + +**Key:** Entity node height must be dynamic based on column count. The `buildElkGraph()` function reads `data.w` or `measured.width` for width, and `measured.height` for height. For ELK layout to work correctly: +- Set `data.w` to `ER_SIZES.entity.w` (260) on entity nodes via the graph converter +- The height comes from `measured.height` after DOM render, which will be correct since the ErEntityNode renders all rows + +**Alternatively**, compute the height upfront via `getErEntityHeight(columns)` and pass it to ELK. This ensures correct spacing on first layout before DOM measurement. In `buildElkGraph`, when the node has E-R data with columns, use the computed height: + +```typescript +children: nodes.map((node) => { + const data = node.data as unknown as DiagramNode; + const h = data.columns + ? getErEntityHeight(data.columns) + : (node.measured?.height ?? DEFAULT_NODE_HEIGHT); + return { + id: node.id, + width: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH, + height: h, + }; +}), +``` + +This small change to `buildElkGraph` ensures E-R entities get correct ELK spacing on initial layout. + +### CSS Styles for E-R Nodes + +Add to `globals.css`: + +```css +/* E-R Entity Styles */ +.er-entity { + background: var(--node-bg); + border: 1.5px solid var(--diagram-er); + border-radius: 6px; + min-width: 220px; + font-size: 12px; + overflow: hidden; +} + +.er-entity-header { + background: var(--diagram-er); + color: white; + font-weight: 600; + font-size: 13px; + padding: 6px 12px; +} + +.er-entity-body { + padding: 4px 0; +} + +.er-entity-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + line-height: 20px; +} + +.er-entity-row:hover { + background: var(--node-hover, oklch(0.623 0.214 260 / 5%)); +} + +.er-entity-indicator { + width: 18px; + text-align: center; + flex-shrink: 0; + font-size: 12px; +} + +.er-entity-col-name { + font-weight: 500; + color: var(--foreground); + flex: 1; + min-width: 0; +} + +.er-entity-col-type { + color: var(--muted-foreground); + font-size: 10px; + flex-shrink: 0; +} + +.er-entity-constraint { + color: var(--diagram-er); + font-size: 10px; + font-weight: 600; + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.er-entity-empty { + color: var(--muted-foreground); + font-style: italic; + justify-content: center; + padding: 8px 10px; +} + +/* E-R Cardinality labels on edges */ +.er-cardinality { + background: var(--edge-label-bg, oklch(1 0 0 / 90%)); + border: 1px solid var(--diagram-er); + border-radius: 4px; + padding: 1px 5px; + font-size: 10px; + font-weight: 600; + color: var(--diagram-er); + pointer-events: none; +} +``` + +**Color note:** The entity header uses `--diagram-er` (violet) as background with white text. This works in both light and dark modes because the violet oklch value provides sufficient contrast against white text. + +### Existing Code to Reuse / Modify + +| File | Action | What | +|------|--------|------| +| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Register E-R node types and edge types, add ErMarkerDefs | +| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add E-R type resolution for nodes and edges (import from er/constants) | +| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Update `buildElkGraph` to compute E-R entity height from `columns` data | +| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add E-R entity and cardinality CSS styles | +| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode (columns), DiagramEdge (cardinality) — already defined, no changes | +| `apps/web/src/modules/diagram/lib/bfs-path.ts` | **REUSE** | Path highlighting works for E-R — diagram-type-agnostic | +| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **REUSE** | highlightedNodeId/setHighlightedNodeId — no changes needed | +| `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` | **REUSE** | Auto-layout works with flat ELK — no changes needed | + +### Library & Framework Requirements + +**No new packages required.** Everything built with existing dependencies: +- `@xyflow/react` 12.10.1 — custom nodes, edges, handles, EdgeLabelRenderer, BaseEdge +- `elkjs` 0.11.0 — flat layered layout +- `zustand` 5.0.8 — highlight state (reuse existing) + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/er/ +│ ├── index.ts # Exports all E-R components + constants +│ ├── constants.ts # ER_SIZES, type mappings, getErEntityHeight +│ ├── constants.test.ts # Tests for E-R constants +│ ├── ErEntityNode.tsx # Entity custom node (table-like card) +│ └── ErRelationshipEdge.tsx # Relationship edge with cardinality +``` + +Modified files: +``` +apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register E-R node/edge types +apps/web/src/modules/diagram/lib/graph-converter.ts # E-R type mapping +apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add E-R converter tests +apps/web/src/modules/diagram/lib/elk-layout.ts # E-R entity height in buildElkGraph +apps/web/src/assets/styles/globals.css # E-R CSS styles +``` + +### Project Structure Notes + +- E-R node components go in `~/modules/diagram/types/er/` — follows the BPMN pattern (`~/modules/diagram/types/bpmn/`) +- Layout utilities in `~/modules/diagram/lib/` — E-R uses the existing flat `buildElkGraph`, only a small height computation change +- `bfs-path.ts` reused as-is — diagram-type-agnostic +- 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 (Story 2.1/2.3 pattern) +- **NEVER hardcode positions for E-R nodes** — all positioning from ELK 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 persisted graph data** — positions are ephemeral +- **NEVER use compound/hierarchical ELK layout for E-R** — flat layout is correct (no pools/lanes) +- **DO NOT implement Smart Inspector for E-R** — that's a future story +- **DO NOT implement SQL DDL export** — that's Story 6.2 +- **DO NOT implement other diagram type renderers** — those are Stories 2.5-2.8 +- **DO NOT break existing tests** — 81 web tests must continue passing (50 original + 31 BPMN) +- **DO NOT use inline styles for E-R colors** — use CSS custom properties (`--diagram-er`) +- **DO NOT use BPMN-specific pool/lane logic for E-R** — E-R is flat graph only + +### Previous Story Intelligence (Story 2.3 — BPMN) + +**Key learnings to carry forward:** +- Constants file pattern: `BPMN_SIZES` map → `ER_SIZES` equivalent. Type resolution functions: `resolveXNodeType`, `resolveXEdgeType` +- Node component pattern: Cast `data` as `DiagramNode & { label: string }`, use `Handle` with `style={{ opacity: 0 }}` at 4 positions +- Edge component pattern: `BaseEdge` + `getSmoothStepPath`, `markerEnd` for arrows, `label` + `labelShowBg` for midpoint labels +- Graph converter: `resolveFlowNodeType` switch on `diagramType`, import type resolver from `types/[type]/constants` +- DiagramCanvas: import components, add to `nodeTypes`/`edgeTypes` objects, add marker defs SVG +- CSS: Use `--diagram-[type]` CSS variable for accent colors, all styles inside `@layer base { :root { ... } }` block +- `graphNodeToFlowNode` already spreads all DiagramNode fields into `data` — custom nodes access `data.columns` directly +- `graphEdgeToFlowEdge` already spreads all DiagramEdge fields into `data` — custom edges access `data.cardinality` directly +- `flowNodeToGraphNode` already preserves `columns` field — roundtrip works +- `flowEdgeToGraphEdge` already preserves `cardinality` field — roundtrip works +- 81 web tests currently pass (50 original + 31 BPMN) — don't break them + +### Previous Story Intelligence (Story 2.2 — ELK Layout) + +**Key learnings:** +- `buildElkGraph()` is the flat layout builder — E-R uses this directly +- `resolvePositions()` handles flat graphs — E-R uses this directly +- `computeLayout()` detects BPMN compound layout via `isCompound = nodes.some(n => n.type === "bpmnPool")` — E-R won't trigger this since it has no pools +- Entity height must be known at ELK time for correct spacing — compute from `columns.length` + +### Git Intelligence + +Recent commits: +- `0a7838a feat: implement Story 2.3 — BPMN diagram type renderer` +- `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 +- `diagramTypeConfig` in DiagramCard.tsx has E-R config already: `{ label: "E-R", icon: Icons.Database, color: "text-violet-500" }` +- CSS variable `--diagram-er: oklch(0.606 0.25 293)` already defined in globals.css + +### Latest Tech Information + +**@xyflow/react 12.10.1 — EdgeLabelRenderer API:** +- `EdgeLabelRenderer` renders edge labels in a portal layer above edges — required for cardinality labels at endpoints +- Position labels using `transform: translate(x, y)` with absolute positioning inside the label renderer +- Must add `pointer-events: none` on cardinality labels to prevent them from blocking edge/node interactions +- `getSmoothStepPath` returns `[path, labelX, labelY, offsetX, offsetY]` — `labelX/Y` is the midpoint, `offsetX/Y` is the offset for the label + +**@xyflow/react 12.10.1 — BaseEdge label support:** +- `label` prop renders text at edge midpoint +- `labelShowBg` adds a background rectangle behind the label +- `labelBgStyle` accepts CSS-like style object for the background +- `labelStyle` for the text styling + +**Column type in DiagramNode (already defined):** +```typescript +interface Column { + name: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + isNullable?: boolean; + isUnique?: boolean; + references?: string; +} +``` + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.4] — Full AC and technical notes +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model (hybrid schema, E-R extensions: columns, cardinality) +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules, type prefixing: `er:entity` +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Renderers] — E-R: Entity with field rows, Relationship with cardinality +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Journey 3] — Elena's E-R schema editing flow +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Color Accents] — E-R: violet oklch(0.606 0.25 293) +- [Source: _bmad-output/implementation-artifacts/2-3-bpmn-diagram-type-renderer.md] — BPMN implementation patterns (constants, nodes, edges, converter, canvas registration) +- [Source: _bmad-output/project-context.md] — 62 critical implementation rules +- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (columns), DiagramEdge (cardinality), Column interface +- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Current converter with BPMN resolution to extend for E-R +- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — Current flat layout builder to reuse for E-R +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register E-R types +- [Source: apps/web/src/assets/styles/globals.css] — CSS with `--diagram-er` already defined + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +None — clean implementation with no failures. + +### Completion Notes List + +- **Task 1:** Created `constants.ts` with `ER_SIZES`, `getErEntityHeight()`, `resolveErNodeType()`, `resolveErEdgeType()`. Created `index.ts` barrel export. +- **Task 2:** Created `ErEntityNode.tsx` — table-like card with violet header, attribute rows showing PK (key emoji), FK (arrow), nullable (?), unique (U) indicators. 4 handles for multi-direction edge connections. +- **Task 3:** Created `ErRelationshipEdge.tsx` — uses `EdgeLabelRenderer` for cardinality labels at source/target endpoints (24px offset along edge direction). Midpoint label via `BaseEdge` built-in label with background pill. Violet-colored edge stroke and arrow marker. +- **Task 4:** Updated `graph-converter.ts` — added `er` diagramType and `er:` prefix resolution for both nodes and edges. Import from `er/constants`. Data fields (`columns`, `cardinality`) already preserved by existing spread pattern — no additional changes needed. +- **Task 5:** Updated `DiagramCanvas.tsx` — registered `erEntity` and `erRelationship` in `nodeTypes`/`edgeTypes` objects. Consolidated `BpmnMarkerDefs` into `MarkerDefs` containing both BPMN and E-R SVG markers. +- **Task 6:** Updated `elk-layout.ts` — `buildElkGraph` now computes E-R entity height from `columns` data via `getErEntityHeight()` for correct ELK spacing on initial layout before DOM measurement. E-R uses flat layout (no compound detection triggered since no pools). +- **Task 7:** Added E-R CSS styles to `globals.css` — entity card, header, body, attribute rows, indicators, cardinality labels. All using `--diagram-er` violet accent. Also fixed highlighted node drop-shadow to use `--node-selected` instead of hardcoded `--diagram-bpmn`. +- **Task 8:** 12 new tests (9 constants + 3 converter). All 93 tests pass. +- **Code Review Fixes:** 4 MEDIUM issues fixed — cardinality label positioning (M1), M:N junction table test coverage (M2), type-guarded ELK height computation (M3), CSS variable for hover color (M4). 4 new tests added. All 97 tests pass. + +### Change Log + +- 2026-02-24: Implemented Story 2.4 — E-R diagram type renderer with entity nodes, relationship edges with cardinality, graph converter integration, ELK layout support, and CSS styles. +- 2026-02-24: Code review (Opus 4.6) — Fixed 4 MEDIUM issues: M1 cardinality label alignment on orthogonal edges, M2 added M:N junction table test for AC #3, M3 type-guarded buildElkGraph columns check, M4 CSS hover color uses color-mix instead of hardcoded oklch. 97 tests pass. + +### File List + +New files: +- `apps/web/src/modules/diagram/types/er/constants.ts` +- `apps/web/src/modules/diagram/types/er/constants.test.ts` +- `apps/web/src/modules/diagram/types/er/ErEntityNode.tsx` +- `apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx` + +Modified files: +- `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/lib/elk-layout.test.ts` +- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` +- `apps/web/src/assets/styles/globals.css` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` diff --git a/_bmad-output/implementation-artifacts/2-5-org-chart-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-5-org-chart-diagram-type-renderer.md new file mode 100644 index 0000000..f741913 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-5-org-chart-diagram-type-renderer.md @@ -0,0 +1,603 @@ +# Story 2.5: Org Chart Diagram Type Renderer + +Status: done + + + +## Story + +As a user, +I want to create and view organizational charts with hierarchical nodes, +so that I can model team structures with roles and departments. + +## Acceptance Criteria + +1. **Given** I open or create an org chart, **When** the canvas renders, **Then** person nodes display with name, role/title, and department, **And** nodes have a professional card-like appearance with an avatar placeholder. + +2. **Given** an org chart has hierarchical relationships, **When** auto-layout runs, **Then** the tree layout positions nodes in a top-down hierarchy, **And** managers appear above their direct reports with clear parent-child connections. + +3. **Given** an org chart renders, **When** I view the edges, **Then** hierarchical connections use straight/orthogonal routing (not curved), **And** sibling nodes are evenly spaced. + +## Tasks / Subtasks + +- [x] Task 1: Create org chart constants and type registry (AC: #1) + - [x]1.1: Create `apps/web/src/modules/diagram/types/orgchart/constants.ts` with `OC_SIZES`, `resolveOrgchartNodeType()`, `resolveOrgchartEdgeType()`, `getOrgchartPersonHeight()` + - [x]1.2: Create `apps/web/src/modules/diagram/types/orgchart/index.ts` barrel export + +- [x] Task 2: Create OrgchartPersonNode custom @xyflow/react component (AC: #1) + - [x]2.1: Create `apps/web/src/modules/diagram/types/orgchart/OrgchartPersonNode.tsx` — professional card with avatar placeholder (icon emoji or default user icon), person name (label), role/title (tag), department (group), department color stripe + +- [x] Task 3: Create OrgchartHierarchyEdge custom @xyflow/react component (AC: #2, #3) + - [x]3.1: Create `apps/web/src/modules/diagram/types/orgchart/OrgchartHierarchyEdge.tsx` — clean orthogonal edge with `getSmoothStepPath`, no cardinality labels, subtle color using `--diagram-orgchart` + +- [x] Task 4: Update graph converter for org chart type resolution (AC: #1, #2) + - [x]4.1: Import `resolveOrgchartNodeType`, `resolveOrgchartEdgeType` from `../types/orgchart/constants` in `graph-converter.ts` + - [x]4.2: Add `orgchart` diagramType and `org:` prefix handling in `resolveFlowNodeType()` + - [x]4.3: Add `orgchart` diagramType handling in `resolveFlowEdgeType()` + +- [x] Task 5: Register org chart types in DiagramCanvas (AC: #1, #2, #3) + - [x]5.1: Import OrgchartPersonNode and OrgchartHierarchyEdge in `DiagramCanvas.tsx` + - [x]5.2: Add org chart entries to `nodeTypes` and `edgeTypes` objects (OUTSIDE component) + +- [x] Task 6: Org chart layout verification (AC: #2, #3) + - [x]6.1: Verify `buildElkGraph` handles org chart person nodes with fixed `OC_SIZES.person` dimensions — add type guard similar to E-R entity height computation + - [x]6.2: Verify ELK layered algorithm with DOWN direction produces correct top-down hierarchy for org charts + +- [x] Task 7: Add org chart CSS styles (AC: #1) + - [x]7.1: Add `.oc-person`, `.oc-person-avatar`, `.oc-person-info`, `.oc-person-name`, `.oc-person-role`, `.oc-person-dept` styles to `globals.css` + - [x]7.2: Use `--diagram-orgchart` (green) accent color for org chart elements + +- [x] Task 8: Tests (AC: all) + - [x]8.1: Unit tests for org chart constants — `resolveOrgchartNodeType`, `resolveOrgchartEdgeType`, `getOrgchartPersonHeight` (6+ tests) + - [x]8.2: Unit tests for graph converter org chart type mapping — node and edge types correctly resolved for `diagramType === "orgchart"` (3+ tests) + - [x]8.3: Unit test for ELK buildElkGraph with org chart person nodes using fixed dimensions + - [x]8.4: All existing 97 tests must continue passing — no regressions + +## Dev Notes + +### Overview — What This Story Builds + +This story adds the Org Chart diagram type renderer: hierarchical organizational charts with professional person card nodes and clean orthogonal hierarchy edges. Like E-R (Story 2.4), org charts use **flat ELK layout** (no compound pool/lane hierarchy). The ELK layered algorithm with DOWN direction naturally produces tree-like top-down hierarchies. + +**This story builds:** +- 1 custom org chart node component (OrgchartPersonNode — professional card with avatar, name, role, department) +- 1 custom org chart edge component (OrgchartHierarchyEdge — clean orthogonal hierarchy line) +- Org chart constants and type registry +- Graph converter org chart type resolution +- Org chart CSS styles using `--diagram-orgchart` green accent + +**This story does NOT implement:** +- Other diagram types (Stories 2.6-2.8) +- Smart Inspector for org chart field editing (future epic) +- 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):** Org chart nodes use type-prefixed `type` field (`org:person`). Existing DiagramNode fields are semantically reused: `label` = person name, `tag` = role/title, `icon` = avatar emoji, `color` = department accent color, `group` = department name. NO new fields on DiagramNode needed. + +2. **Component Structure:** Feature code in `~/modules/diagram/types/orgchart/` — follows the BPMN (`types/bpmn/`) and E-R (`types/er/`) pattern. NOT co-located in route directories. + +3. **@xyflow/react Custom Nodes:** All custom node components use `NodeProps` typing. The `nodeTypes` and `edgeTypes` objects MUST be defined OUTSIDE the component (performance critical — Story 2.1/2.3/2.4 pattern). + +4. **ELK.js Flat Layout:** Org charts use standard flat `buildElkGraph()` — no compound hierarchy needed. The ELK layered algorithm with `direction: DOWN` naturally positions managers above their reports. + +5. **Lean JSON Data Model:** No x/y positions stored for org chart nodes. All positioning computed by ELK at render time. + +6. **Type Prefixing Convention (Enforcement Rule #5):** `org:person` prefix on DiagramNode.type. Edge type defaults to `"hierarchy"` for org chart. + +### Org Chart Data Model — Field Mapping + +The existing DiagramNode fields perfectly satisfy org chart needs: + +```typescript +// How DiagramNode fields map to org chart display +interface OrgChartSemantics { + id: string; // Unique person/role identifier + type: "org:person"; // Type-prefixed node type + label: string; // Person name (e.g., "Antonio García") + tag?: string; // Role/title (e.g., "CEO", "Director of Engineering") + icon?: string; // Avatar emoji (e.g., "👤", "💻", "🔧") + color?: string; // Department accent color (hex or CSS variable) + w?: number; // Node width override (default: 280) + group?: string; // Department name (e.g., "Technology", "Sales") + // position, manuallyPositioned — handled by ELK layout +} +``` + +**Reference from Flexicar org chart JSON:** +- `tag: "PRESIDENTE"` → role/title +- `label: "Oliver"` → person name +- `icon: "👤"` → avatar emoji +- `color: "#2B2A2A"` → level color (dark for executives, orange for department heads) +- `w: 300` → node width + +### Org Chart Node Types — Size Constants + +Org chart person nodes have fixed height (unlike E-R which is dynamic based on columns): + +```typescript +export const OC_SIZES = { + person: { + w: 280, // Fixed width for all person cards + h: 80, // Fixed height (avatar + name + role + department fits in 80px) + }, +} as const; + +/** Get org chart person node height (fixed — not dynamic like E-R) */ +export function getOrgchartPersonHeight(): number { + return OC_SIZES.person.h; +} +``` + +### Org Chart Node Type → @xyflow/react Type Mapping + +| DiagramNode.type | @xyflow Node type | Component | +|---|---|---| +| `org:person` | `orgchartPerson` | `OrgchartPersonNode` | + +Only one node type needed. The person node handles all org chart roles — executives, managers, and staff are differentiated by `color` and hierarchy position, not by node type. + +**Type resolution:** +```typescript +export function resolveOrgchartNodeType(type: string): string { + const bare = type.startsWith("org:") ? type.slice(4) : type; + switch (bare) { + case "person": + default: + return "orgchartPerson"; + } +} + +export function resolveOrgchartEdgeType(_type: string | undefined): string { + return "orgchartHierarchy"; +} +``` + +### Org Chart Edge Type — Hierarchy Connection + +| DiagramEdge.type | @xyflow Edge type | Visual | +|---|---|---| +| `hierarchy` (or default) | `orgchartHierarchy` | Clean orthogonal step edge, no labels, subtle green color | + +**Edge rendering approach:** +Use `getSmoothStepPath` for orthogonal routing (matching AC #3: "straight/orthogonal routing"). The edge is clean and professional — no arrow markers, no labels. Just a solid line connecting parent to child. + +```typescript +import { BaseEdge, getSmoothStepPath } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function OrgchartHierarchyEdge(props: EdgeProps) { + const [edgePath] = getSmoothStepPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + borderRadius: 8, + }); + + return ( + + ); +} +``` + +**No arrow markers needed** — org charts use clean lines to show hierarchy. The direction (top-down) already communicates the reporting relationship. + +### OrgchartPersonNode — Implementation Pattern + +The person node renders as a professional card: + +``` +┌──────────────────────────────────────┐ +│ ▌ 👤 Antonio García │ ← Green left border stripe +│ ▌ CEO │ ← Role/title in muted color +│ ▌ Executive │ ← Department in smaller text +└──────────────────────────────────────┘ +``` + +**Key implementation details:** + +```typescript +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; + +export function OrgchartPersonNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon ?? "👤"; + const role = d.tag; + const department = d.group; + const accentColor = d.color ?? "var(--diagram-orgchart)"; + + return ( +
+
{icon}
+
+
{d.label}
+ {role &&
{role}
} + {department &&
{department}
} +
+ + + + +
+ ); +} +``` + +**All 4 handles** (top/right/bottom/left) for edge connections in any layout direction — same pattern as BPMN and E-R nodes. + +**Avatar:** Uses `icon` emoji field. If not set, defaults to `"👤"`. This matches the Flexicar reference pattern where each person has an emoji icon. + +### Graph Converter Updates + +Add org chart type resolution alongside existing BPMN and E-R resolution: + +```typescript +// In resolveFlowNodeType(): +if (diagramType === "orgchart" || nodeType.startsWith("org:")) { + return resolveOrgchartNodeType(nodeType); +} + +// In resolveFlowEdgeType(): +if (diagramType === "orgchart") { + return resolveOrgchartEdgeType(edgeType); +} +``` + +**Import from orgchart/constants.ts** — same pattern as BPMN and E-R imports. + +**No container nodes needed** — Org charts are flat hierarchies. The `graphToFlow()` function's existing default path (flat node mapping) handles org charts correctly. + +**Existing data fields already preserved:** `graphNodeToFlowNode` spreads all node data into `data: { ...node }`, which includes `tag`, `icon`, `group`. And `flowNodeToGraphNode` already preserves `tag`, `icon`, `color`, `group`. No changes needed to the roundtrip logic. + +### DiagramCanvas Updates + +Add org chart node/edge types to the `nodeTypes` and `edgeTypes` objects: + +```typescript +import { OrgchartPersonNode } from "../../types/orgchart/OrgchartPersonNode"; +import { OrgchartHierarchyEdge } from "../../types/orgchart/OrgchartHierarchyEdge"; + +const nodeTypes = { + // Existing BPMN + E-R types... + orgchartPerson: OrgchartPersonNode, +}; + +const edgeTypes = { + // Existing BPMN + E-R types... + orgchartHierarchy: OrgchartHierarchyEdge, +}; +``` + +**No additional marker defs needed** — org chart hierarchy edges don't use arrow markers. + +**BFS Path Highlighting:** The existing `handleNodeClick` uses `CONTAINER_TYPES` to skip container nodes. Org charts have no container nodes, so no changes needed. BFS highlighting works on org chart person nodes out of the box. + +### Layout — ELK Flat Layout for Org Charts + +Org charts use the existing flat `buildElkGraph()` — no compound layout detection needed. + +**Key:** Person node dimensions are fixed (unlike E-R entities which have dynamic height). In `buildElkGraph`, add type detection for `orgchartPerson` nodes to use `OC_SIZES.person`: + +```typescript +children: nodes.map((node) => { + const data = node.data as unknown as DiagramNode; + // E-R entities: compute height from columns + const isErEntity = node.type === "erEntity"; + // Org chart persons: fixed dimensions + const isOcPerson = node.type === "orgchartPerson"; + const height = + isErEntity && data.columns + ? getErEntityHeight(data.columns) + : isOcPerson + ? OC_SIZES.person.h + : (node.measured?.height ?? DEFAULT_NODE_HEIGHT); + const width = + isOcPerson + ? (data.w ?? OC_SIZES.person.w) + : (data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH); + return { + id: node.id, + width, + height, + }; +}), +``` + +**Layout algorithm note:** The ELK `layered` algorithm produces good tree-like layouts with `direction: DOWN`. The `mrtree` algorithm would be even more specialized for pure trees (centers parents over children), but `layered` is sufficient for v1 and maintains consistency with other diagram types. Optimizing to `mrtree` can be a future enhancement. + +**Sibling spacing (AC #3):** The default `nodeSpacing: 80` ensures even spacing between sibling nodes at the same hierarchy level. + +### CSS Styles for Org Chart Nodes + +Add to `globals.css`: + +```css +/* ── Org Chart Person Styles ──────────────────────────────────────── */ + +.oc-person { + display: flex; + align-items: center; + gap: 10px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-orgchart); + border-left: 4px solid var(--diagram-orgchart); + border-radius: 8px; + padding: 10px 14px; + min-width: 240px; + max-width: 320px; + cursor: pointer; +} + +.oc-person-avatar { + font-size: 24px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: color-mix(in oklch, var(--diagram-orgchart) 10%, transparent); + border-radius: 50%; +} + +.oc-person-info { + min-width: 0; + flex: 1; +} + +.oc-person-name { + font-weight: 600; + font-size: 13px; + color: var(--foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.oc-person-role { + font-size: 11px; + color: var(--muted-foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.oc-person-dept { + font-size: 10px; + color: var(--diagram-orgchart); + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +``` + +**Color note:** The person card uses `--diagram-orgchart` (green) for the left border stripe and department text. The `borderLeftColor` can be overridden per-node via `data.color` for department-specific coloring (executives = dark, sales = orange, tech = blue, etc.). + +**Dark mode:** Uses CSS custom properties (`--node-bg`, `--foreground`, `--muted-foreground`, `--diagram-orgchart`) which automatically adapt to dark mode through the existing `:root` / `.dark` theme definitions. + +### Existing Code to Reuse / Modify + +| File | Action | What | +|------|--------|------| +| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Register org chart node types and edge types | +| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add org chart type resolution for nodes and edges (import from orgchart/constants) | +| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Add org chart person node size handling in `buildElkGraph` | +| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add org chart person node CSS styles | +| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode (tag, icon, group, color) — already defined, no changes | +| `apps/web/src/modules/diagram/lib/bfs-path.ts` | **REUSE** | Path highlighting works for org charts — diagram-type-agnostic | +| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **REUSE** | highlightedNodeId/setHighlightedNodeId — no changes needed | +| `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` | **REUSE** | Auto-layout works with flat ELK — no changes needed | + +### Library & Framework Requirements + +**No new packages required.** Everything built with existing dependencies: +- `@xyflow/react` 12.10.1 — custom nodes, edges, handles, BaseEdge, getSmoothStepPath +- `elkjs` 0.11.0 — flat layered layout with DOWN direction +- `zustand` 5.0.8 — highlight state (reuse existing) + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/orgchart/ +│ ├── index.ts # Exports all org chart components + constants +│ ├── constants.ts # OC_SIZES, type mappings, getOrgchartPersonHeight +│ ├── constants.test.ts # Tests for org chart constants +│ ├── OrgchartPersonNode.tsx # Person custom node (professional card) +│ └── OrgchartHierarchyEdge.tsx # Hierarchy edge (clean orthogonal line) +``` + +Modified files: +``` +apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register org chart node/edge types +apps/web/src/modules/diagram/lib/graph-converter.ts # Org chart type mapping +apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add org chart converter tests +apps/web/src/modules/diagram/lib/elk-layout.ts # Org chart person size in buildElkGraph +apps/web/src/modules/diagram/lib/elk-layout.test.ts # Add org chart ELK tests +apps/web/src/assets/styles/globals.css # Org chart CSS styles +``` + +### Project Structure Notes + +- Org chart node components go in `~/modules/diagram/types/orgchart/` — follows BPMN (`types/bpmn/`) and E-R (`types/er/`) pattern +- Layout utilities in `~/modules/diagram/lib/` — org chart uses the existing flat `buildElkGraph`, only person node size handling added +- `bfs-path.ts` reused as-is — diagram-type-agnostic +- 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 (Story 2.1/2.3/2.4 pattern) +- **NEVER hardcode positions for org chart nodes** — all positioning from ELK 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 persisted graph data** — positions are ephemeral +- **NEVER use compound/hierarchical ELK layout for org chart** — flat layout is correct (no pools/lanes) +- **NEVER add new fields to DiagramNode** — use existing fields (`tag`, `icon`, `group`, `color`) with org chart semantics +- **DO NOT implement Smart Inspector for org chart** — that's a future story +- **DO NOT implement other diagram type renderers** — those are Stories 2.6-2.8 +- **DO NOT break existing tests** — 97 tests must continue passing +- **DO NOT use inline styles for org chart colors** — use CSS custom properties (`--diagram-orgchart`) +- **DO NOT add arrow markers to hierarchy edges** — clean lines communicate hierarchy via direction + +### Previous Story Intelligence (Story 2.4 — E-R) + +**Key learnings to carry forward:** +- Constants file pattern: `ER_SIZES` map → `OC_SIZES` equivalent. Type resolution functions: `resolveXNodeType`, `resolveXEdgeType` +- Node component pattern: Cast `data` as `DiagramNode & { label: string }`, use `Handle` with `style={{ opacity: 0 }}` at 4 positions +- Edge component pattern: `BaseEdge` + `getSmoothStepPath`, simpler than E-R (no cardinality labels needed) +- Graph converter: `resolveFlowNodeType` switch on `diagramType`, import type resolver from `types/[type]/constants` +- DiagramCanvas: import components, add to `nodeTypes`/`edgeTypes` objects +- CSS: Use `--diagram-[type]` CSS variable for accent colors +- `graphNodeToFlowNode` already spreads all DiagramNode fields into `data` — custom nodes access `data.tag`, `data.icon`, `data.group` directly +- `flowNodeToGraphNode` already preserves `tag`, `icon`, `group`, `color` fields — roundtrip works +- Code review from 2.4: Use `color-mix()` for hover/bg tints instead of hardcoded oklch values +- 97 web tests currently pass — don't break them + +### Previous Story Intelligence (Story 2.2 — ELK Layout) + +**Key learnings:** +- `buildElkGraph()` is the flat layout builder — org chart uses this directly +- `resolvePositions()` handles flat graphs — org chart uses this directly +- `computeLayout()` detects BPMN compound layout via `isCompound = nodes.some(n => n.type === "bpmnPool")` — org chart won't trigger this since it has no pools +- Person node dimensions are fixed (unlike E-R where height depends on column count) — simpler size handling + +### Git Intelligence + +Recent commits: +- `0a7838a feat: implement Story 2.3 — BPMN diagram type renderer` +- `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 +- `diagramTypeConfig` in DiagramCard.tsx already has org chart config: `{ label: "Org Chart", icon: Icons.UsersRound, color: "text-green-500" }` +- CSS variable `--diagram-orgchart: oklch(0.723 0.219 150)` already defined in globals.css + +### Latest Tech Information + +**@xyflow/react 12.10.1 — getSmoothStepPath for orthogonal edges:** +- `getSmoothStepPath` produces step/orthogonal edges (right-angle turns) — perfect for org chart hierarchy +- `borderRadius` parameter controls corner rounding (8px recommended for professional look) +- Returns `[path, labelX, labelY, offsetX, offsetY]` — only `path` needed for hierarchy edges (no labels) +- `BaseEdge` accepts `path` and `style` props — minimal edge implementation + +**ELK.js Layered Algorithm with DOWN direction:** +- `elk.direction: DOWN` + `elk.algorithm: layered` produces top-down hierarchical layout +- `elk.layered.nodePlacement.strategy: BRANDES_KOEPF` centers nodes well in tree structures +- `elk.layered.crossingMinimization.strategy: LAYER_SWEEP` minimizes edge crossings +- Default settings already configured in `elk-layout.ts` — no changes to layout options needed +- The layered algorithm naturally positions parent nodes in upper layers and child nodes in lower layers + +**DiagramNode fields (already defined in graph.ts):** +```typescript +interface DiagramNode { + id: string; + type: string; // "org:person" + tag?: string; // role/title + label: string; // person name + icon?: string; // avatar emoji + color?: string; // department accent color + w?: number; // width override + group?: string; // department name + // ... other fields (position, manuallyPositioned, etc.) +} +``` + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.5] — Full AC: person nodes with name/role/department, top-down tree layout, orthogonal routing +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model: `org:person` prefix, shared base fields +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules, type prefixing: `org:` +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Renderers] — Org Chart: Person node (avatar, name, role) +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Color Accents] — Org Chart: green oklch(0.723 0.219 150) +- [Source: _bmad-output/implementation-artifacts/2-4-entity-relationship-diagram-type-renderer.md] — E-R implementation patterns (constants, nodes, edges, converter, canvas registration) +- [Source: _bmad-output/project-context.md] — 62 critical implementation rules +- [Source: /Users/agutierrez/Desktop/flexicar-context/.diagrams/app/orgchart.json] — Reference org chart: tag=role, label=name, icon=emoji, color per department +- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (tag, icon, group, color), DiagramType includes "orgchart" +- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Current converter with BPMN + E-R resolution to extend for org chart +- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — Current flat layout builder, E-R height pattern to follow for org chart width/height +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register org chart types +- [Source: apps/web/src/assets/styles/globals.css] — CSS with `--diagram-orgchart` already defined + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +None — clean implementation with no failures. + +### Completion Notes List + +- **Task 1:** Created `constants.ts` with `OC_SIZES` (w:280, h:80), `getOrgchartPersonHeight()`, `resolveOrgchartNodeType()`, `resolveOrgchartEdgeType()`. Created `index.ts` barrel export. +- **Task 2:** Created `OrgchartPersonNode.tsx` — professional card with green left border stripe, avatar emoji (defaults to person emoji), name (label), role/title (tag), department (group). Accent color overridable via `data.color`. 4 handles for multi-direction edges. +- **Task 3:** Created `OrgchartHierarchyEdge.tsx` — clean orthogonal edge using `getSmoothStepPath` with 8px border radius. Green stroke (2px) from `--diagram-orgchart`. No arrow markers — direction communicates hierarchy. +- **Task 4:** Updated `graph-converter.ts` — added `orgchart` diagramType and `org:` prefix resolution for both nodes and edges. Imported from `orgchart/constants`. Data fields (`tag`, `icon`, `group`, `color`) already preserved by existing spread pattern. +- **Task 5:** Updated `DiagramCanvas.tsx` — registered `orgchartPerson` and `orgchartHierarchy` in `nodeTypes`/`edgeTypes` objects. No additional marker defs needed. +- **Task 6:** Updated `elk-layout.ts` — `buildElkGraph` now detects `orgchartPerson` nodes and uses `OC_SIZES.person` for fixed width (280) and height (80). Respects `data.w` override for width. Org chart uses flat layout (no compound detection triggered). +- **Task 7:** Added org chart CSS to `globals.css` — person card with flex layout, avatar circle with green tint background, name/role/department text with proper overflow handling. All using `--diagram-orgchart` green accent and `color-mix()` for tints. +- **Task 8:** 11 new tests (6 constants + 3 converter + 2 ELK). Updated 1 existing test (`org:` prefix now resolves to `orgchartPerson`). All 108 tests pass (97 existing + 11 new). + +### Senior Developer Review (AI) + +**Reviewer:** Claude Opus 4.6 (adversarial code review) +**Date:** 2026-02-25 +**Outcome:** Approved with fixes applied + +**Issues Found (7 total: 3 Medium, 4 Low) — All resolved:** + +- [x] [AI-Review][MEDIUM] Removed dead code `getOrgchartPersonHeight()` — never used in production, `elk-layout.ts` uses `OC_SIZES.person.h` directly. Removed from `constants.ts`, `index.ts`, and test file. +- [x] [AI-Review][MEDIUM] Fixed empty string color guard in `OrgchartPersonNode` — changed `??` to `||` so falsy `data.color` (including `""`) falls back to CSS default correctly. +- [x] [AI-Review][MEDIUM] Eliminated unnecessary inline style object on every render — now conditionally applies `borderLeftColor` only when `data.color` is truthy, avoiding 100+ redundant object allocations per render cycle. +- [x] [AI-Review][LOW] Removed dead `getOrgchartPersonHeight` export from barrel `index.ts`. +- [x] [AI-Review][LOW] Fixed misleading test data — changed `org:unit` to `org:person` in "handle all 6 diagram types" test. +- [x] [AI-Review][LOW] Added `:hover` effect on `.oc-person` card with subtle green tint. +- [ ] [AI-Review][LOW] No component rendering tests for OrgchartPersonNode/OrgchartHierarchyEdge — noted as known gap, consistent with existing BPMN/E-R pattern. Deferred to E2E testing phase. + +**Test results after fixes:** 107 passing (removed 1 dead test for getOrgchartPersonHeight). + +### Change Log + +- 2026-02-25: Code review — fixed 6 issues (dead code, empty string guard, render perf, test accuracy, hover UX). 107 tests pass. +- 2026-02-24: Implemented Story 2.5 — Org Chart diagram type renderer with person nodes, hierarchy edges, graph converter integration, ELK layout support, and CSS styles. + +### File List + +New files: +- `apps/web/src/modules/diagram/types/orgchart/constants.ts` +- `apps/web/src/modules/diagram/types/orgchart/constants.test.ts` +- `apps/web/src/modules/diagram/types/orgchart/index.ts` +- `apps/web/src/modules/diagram/types/orgchart/OrgchartPersonNode.tsx` +- `apps/web/src/modules/diagram/types/orgchart/OrgchartHierarchyEdge.tsx` + +Modified files: +- `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/lib/elk-layout.test.ts` +- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` +- `apps/web/src/assets/styles/globals.css` +- `_bmad-output/implementation-artifacts/sprint-status.yaml` diff --git a/_bmad-output/implementation-artifacts/2-6-architecture-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-6-architecture-diagram-type-renderer.md new file mode 100644 index 0000000..d19be16 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-6-architecture-diagram-type-renderer.md @@ -0,0 +1,827 @@ +# Story 2.6: Architecture Diagram Type Renderer + +Status: done + + + +## Story + +As a user, +I want to create and view architecture diagrams with services, databases, and connections, +so that I can model system infrastructure and service relationships. + +## Acceptance Criteria + +1. **Given** I open or create an architecture diagram, **When** the canvas renders, **Then** I see typed nodes: Service (rectangle with gear icon), Database (cylinder shape), Queue (parallelogram), Load Balancer (diamond/pentagon), External System (cloud shape), **And** each node shows its label and optional metadata (protocol, port). + +2. **Given** architecture nodes have connections, **When** rendered, **Then** edges show protocol labels (HTTP, gRPC, WebSocket, AMQP) at midpoints, **And** edge styles differentiate sync (solid) vs async (dashed) communication. + +3. **Given** auto-layout runs, **When** the diagram has service groups, **Then** related services cluster together with clear inter-group connections. + +## Tasks / Subtasks + +- [x] Task 1: Create architecture constants and type registry (AC: #1) + - [x] 1.1: Create `apps/web/src/modules/diagram/types/architecture/constants.ts` with `ARCH_SIZES`, `resolveArchitectureNodeType()`, `resolveArchitectureEdgeType()` + - [x] 1.2: ~~Create barrel export~~ Removed — per project rule "no barrel files in feature modules"; imports use specific subpaths + +- [x] Task 2: Create ArchServiceNode custom @xyflow/react component (AC: #1) + - [x] 2.1: Create `apps/web/src/modules/diagram/types/architecture/ArchServiceNode.tsx` — rectangle with gear icon (⚙️), label, optional tag metadata (protocol:port) + +- [x] Task 3: Create ArchDatabaseNode custom @xyflow/react component (AC: #1) + - [x] 3.1: Create `apps/web/src/modules/diagram/types/architecture/ArchDatabaseNode.tsx` — cylinder shape via CSS (elliptical top + body), label, optional tag metadata + +- [x] Task 4: Create ArchQueueNode custom @xyflow/react component (AC: #1) + - [x] 4.1: Create `apps/web/src/modules/diagram/types/architecture/ArchQueueNode.tsx` — parallelogram via CSS transform skewX, label, optional tag metadata + +- [x] Task 5: Create ArchLoadBalancerNode custom @xyflow/react component (AC: #1) + - [x] 5.1: Create `apps/web/src/modules/diagram/types/architecture/ArchLoadBalancerNode.tsx` — diamond shape via CSS rotate(45deg) container, label, optional tag metadata + +- [x] Task 6: Create ArchExternalNode custom @xyflow/react component (AC: #1) + - [x] 6.1: Create `apps/web/src/modules/diagram/types/architecture/ArchExternalNode.tsx` — cloud shape via CSS border-radius/clip-path, label, optional tag metadata + +- [x] Task 7: Create ArchConnectionEdge custom @xyflow/react component (AC: #2) + - [x] 7.1: Create `apps/web/src/modules/diagram/types/architecture/ArchConnectionEdge.tsx` — edge with protocol label at midpoint, solid stroke for sync, dashed stroke for async (`data.type === "async"`) + +- [x] Task 8: Update graph converter for architecture type resolution (AC: #1, #2) + - [x] 8.1: Import `resolveArchitectureNodeType`, `resolveArchitectureEdgeType` from `../types/architecture/constants` in `graph-converter.ts` + - [x] 8.2: Add `architecture` diagramType and `arch:` prefix handling in `resolveFlowNodeType()` + - [x] 8.3: Add `architecture` diagramType handling in `resolveFlowEdgeType()` + +- [x] Task 9: Register architecture types in DiagramCanvas (AC: #1, #2) + - [x] 9.1: Import all Arch node components and ArchConnectionEdge in `DiagramCanvas.tsx` + - [x] 9.2: Add architecture entries to `nodeTypes` and `edgeTypes` objects (OUTSIDE component) + - [x] 9.3: Add architecture arrow marker to `MarkerDefs` component + +- [x] Task 10: Architecture node size handling in ELK layout (AC: #3) + - [x] 10.1: Import `getArchNodeSize` from `../types/architecture/constants` in `elk-layout.ts` + - [x] 10.2: Add architecture node type guards in `buildElkGraph` for each subtype's dimensions + +- [x] Task 11: Add architecture CSS styles (AC: #1, #2) + - [x] 11.1: Add `.arch-service`, `.arch-database`, `.arch-queue`, `.arch-lb`, `.arch-external` styles to `globals.css` + - [x] 11.2: Add cylinder shape CSS for database, parallelogram for queue, diamond for LB, cloud for external + - [x] 11.3: Use `--diagram-architecture` neutral accent color for all architecture elements + +- [x] Task 12: Tests (AC: all) + - [x] 12.1: Unit tests for architecture constants — `resolveArchitectureNodeType` for all 5 subtypes, `resolveArchitectureEdgeType` (14 tests) + - [x] 12.2: Unit tests for graph converter architecture type mapping — node and edge types correctly resolved for `diagramType === "architecture"` (3 new tests + 1 updated) + - [x] 12.3: Unit test for ELK `buildElkGraph` with architecture nodes using fixed dimensions per subtype (2 tests) + - [x] 12.4: All 126 tests pass — no regressions (was 107, added 19 architecture tests) + +## Dev Notes + +### Overview — What This Story Builds + +This story adds the Architecture diagram type renderer: system infrastructure diagrams with 5 distinct node shapes (Service, Database, Queue, Load Balancer, External System) and protocol-labeled connection edges with sync/async differentiation. Like E-R (Story 2.4) and Org Chart (Story 2.5), architecture diagrams use **flat ELK layout** (no compound pool/lane hierarchy). + +**This story builds:** +- 5 custom architecture node components (ArchServiceNode, ArchDatabaseNode, ArchQueueNode, ArchLoadBalancerNode, ArchExternalNode) +- 1 custom architecture edge component (ArchConnectionEdge — solid/dashed with protocol label) +- Architecture constants and type registry (5 node subtypes + edge type) +- Graph converter architecture type resolution +- Architecture CSS styles with distinct shapes using `--diagram-architecture` neutral accent +- Architecture arrow marker in MarkerDefs + +**This story does NOT implement:** +- Other diagram types (Stories 2.7-2.8) +- Smart Inspector for architecture field editing (future epic) +- 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):** Architecture nodes use type-prefixed `type` field (`arch:service`, `arch:database`, `arch:queue`, `arch:loadbalancer`, `arch:external`). Existing DiagramNode fields are semantically reused: `label` = component name, `tag` = metadata (protocol:port or technology), `icon` = override emoji (default per subtype), `color` = custom accent override. NO new fields on DiagramNode needed. + +2. **Component Structure:** Feature code in `~/modules/diagram/types/architecture/` — follows the BPMN (`types/bpmn/`), E-R (`types/er/`), and Org Chart (`types/orgchart/`) pattern. NOT co-located in route directories. + +3. **@xyflow/react Custom Nodes:** All custom node components use `NodeProps` typing. The `nodeTypes` and `edgeTypes` objects MUST be defined OUTSIDE the component (performance critical — Story 2.1/2.3/2.4/2.5 pattern). + +4. **ELK.js Flat Layout:** Architecture diagrams use standard flat `buildElkGraph()` — no compound hierarchy needed. The ELK layered algorithm naturally clusters connected service groups. + +5. **Lean JSON Data Model:** No x/y positions stored for architecture nodes. All positioning computed by ELK at render time. + +6. **Type Prefixing Convention (Enforcement Rule #5):** `arch:` prefix on DiagramNode.type. Edge type uses `"sync"` (default) or `"async"`. + +### Architecture Data Model — Field Mapping + +The existing DiagramNode fields satisfy architecture diagram needs: + +```typescript +// How DiagramNode fields map to architecture display +// arch:service node +{ + id: "payment-svc", + type: "arch:service", + label: "Payment Service", // Component name + tag: "Node.js:3000", // Technology:port metadata + icon: "⚙️", // Default for service (overridable) + color: "#4285F4", // Custom accent (optional) + w: 200, // Width override (optional) +} + +// arch:database node +{ + id: "user-db", + type: "arch:database", + label: "User DB", + tag: "PostgreSQL:5432", + icon: "🗄️", // Default for database +} + +// arch:queue node +{ + id: "order-queue", + type: "arch:queue", + label: "Order Events", + tag: "RabbitMQ:5672", + icon: "📨", // Default for queue +} + +// arch:loadbalancer node +{ + id: "api-lb", + type: "arch:loadbalancer", + label: "API Gateway", + tag: "nginx:443", + icon: "⚖️", // Default for LB +} + +// arch:external node +{ + id: "stripe-api", + type: "arch:external", + label: "Stripe API", + tag: "HTTPS", + icon: "☁️", // Default for external +} +``` + +**Edge model:** +```typescript +// Architecture connection +{ + id: "e1", + from: "payment-svc", + to: "user-db", + label: "PostgreSQL", // Protocol shown at midpoint + type: "sync", // "sync" = solid line, "async" = dashed +} +``` + +### Architecture Node Types — Size Constants + +Architecture nodes have fixed dimensions per subtype: + +```typescript +export const ARCH_SIZES = { + service: { w: 200, h: 80 }, // Rectangle — most common + database: { w: 160, h: 100 }, // Cylinder — taller for shape + queue: { w: 180, h: 70 }, // Parallelogram + loadbalancer: { w: 120, h: 120 }, // Diamond — square for rotation + external: { w: 180, h: 80 }, // Cloud shape +} as const; +``` + +### Architecture Node Type → @xyflow/react Type Mapping + +| DiagramNode.type | @xyflow Node type | Component | Visual Shape | +|---|---|---|---| +| `arch:service` | `archService` | `ArchServiceNode` | Rectangle with gear icon | +| `arch:database` | `archDatabase` | `ArchDatabaseNode` | Cylinder (elliptical top + body) | +| `arch:queue` | `archQueue` | `ArchQueueNode` | Parallelogram (CSS skewX) | +| `arch:loadbalancer` | `archLoadBalancer` | `ArchLoadBalancerNode` | Diamond (CSS rotate 45deg) | +| `arch:external` | `archExternal` | `ArchExternalNode` | Cloud (CSS border-radius) | + +**Type resolution:** +```typescript +export function resolveArchitectureNodeType(type: string): string { + const bare = type.startsWith("arch:") ? type.slice(5) : type; + switch (bare) { + case "service": + return "archService"; + case "database": + return "archDatabase"; + case "queue": + return "archQueue"; + case "loadbalancer": + return "archLoadBalancer"; + case "external": + return "archExternal"; + default: + return "archService"; // Default to service + } +} + +export function resolveArchitectureEdgeType(_type: string | undefined): string { + return "archConnection"; +} +``` + +### Architecture Edge Type — Connection + +| DiagramEdge.type | @xyflow Edge type | Visual | +|---|---|---| +| `sync` (or default) | `archConnection` | Solid line with arrow, protocol label at midpoint | +| `async` | `archConnection` | Dashed line with arrow, protocol label at midpoint | + +**Edge rendering approach:** +Use `getBezierPath` for smooth curved connections (architecture diagrams look better with curves than orthogonal step edges). The component checks `data.type` for sync vs async styling: + +```typescript +import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function ArchConnectionEdge(props: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + }); + + const edgeData = props.data as Record | undefined; + const isAsync = edgeData?.type === "async"; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} +``` + +**Arrow marker needed** — architecture connections show direction of communication. Add to `MarkerDefs`: +```tsx + + + +``` + +### Node Component Patterns + +All 5 architecture node components follow the same structure: + +```typescript +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; + +export function ArchServiceNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon ?? "⚙️"; // Default icon per subtype + const metadata = d.tag; // Protocol:port or technology + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} +``` + +**All 4 handles** (top/right/bottom/left) for edge connections in any layout direction — same pattern as BPMN, E-R, and Org Chart nodes. + +**Key differences between subtypes are CSS classes only:** +- `arch-service` — plain rectangle +- `arch-database` — cylinder shape (pseudo-element ellipse top) +- `arch-queue` — skewed parallelogram (with counter-skew on inner content) +- `arch-lb` — diamond (rotated container with counter-rotated content) +- `arch-external` — cloud shape (border-radius blob) + +### Graph Converter Updates + +Add architecture type resolution alongside existing BPMN, E-R, and Org Chart resolution: + +```typescript +// In resolveFlowNodeType(): +if (diagramType === "architecture" || nodeType.startsWith("arch:")) { + return resolveArchitectureNodeType(nodeType); +} + +// In resolveFlowEdgeType(): +if (diagramType === "architecture") { + return resolveArchitectureEdgeType(edgeType); +} +``` + +**Import from architecture/constants.ts** — same pattern as BPMN, E-R, and Org Chart imports. + +**No container nodes needed** — Architecture diagrams are flat graphs. The `graphToFlow()` function's existing default path (flat node mapping) handles architecture correctly. + +**Existing data fields already preserved:** `graphNodeToFlowNode` spreads all node data into `data: { ...node }`, which includes `tag`, `icon`. And `flowNodeToGraphNode` already preserves `tag`, `icon`, `color`. No changes needed to the roundtrip logic. + +### DiagramCanvas Updates + +Add architecture node/edge types to the `nodeTypes` and `edgeTypes` objects: + +```typescript +import { ArchServiceNode } from "../../types/architecture/ArchServiceNode"; +import { ArchDatabaseNode } from "../../types/architecture/ArchDatabaseNode"; +import { ArchQueueNode } from "../../types/architecture/ArchQueueNode"; +import { ArchLoadBalancerNode } from "../../types/architecture/ArchLoadBalancerNode"; +import { ArchExternalNode } from "../../types/architecture/ArchExternalNode"; +import { ArchConnectionEdge } from "../../types/architecture/ArchConnectionEdge"; + +const nodeTypes = { + // Existing BPMN + E-R + Org Chart types... + archService: ArchServiceNode, + archDatabase: ArchDatabaseNode, + archQueue: ArchQueueNode, + archLoadBalancer: ArchLoadBalancerNode, + archExternal: ArchExternalNode, +}; + +const edgeTypes = { + // Existing BPMN + E-R + Org Chart types... + archConnection: ArchConnectionEdge, +}; +``` + +**Additional arch-arrow marker** needed in `MarkerDefs` — architecture connections show direction. + +**BFS Path Highlighting:** The existing `handleNodeClick` uses `CONTAINER_TYPES` to skip container nodes. Architecture has no container nodes, so no changes needed. BFS highlighting works on architecture nodes out of the box. + +### Layout — ELK Flat Layout for Architecture Diagrams + +Architecture diagrams use the existing flat `buildElkGraph()` — no compound layout detection needed. + +**Key:** Architecture node dimensions vary by subtype (unlike Org Chart which has one fixed size). In `buildElkGraph`, add type detection for each architecture node type: + +```typescript +children: nodes.map((node) => { + const data = node.data as unknown as DiagramNode; + const isErEntity = node.type === "erEntity"; + const isOcPerson = node.type === "orgchartPerson"; + // Architecture nodes: per-subtype dimensions + const archSize = getArchNodeSize(node.type); + const height = + isErEntity && data.columns + ? getErEntityHeight(data.columns) + : isOcPerson + ? OC_SIZES.person.h + : archSize + ? archSize.h + : (node.measured?.height ?? DEFAULT_NODE_HEIGHT); + const width = archSize + ? (data.w ?? archSize.w) + : isOcPerson + ? (data.w ?? OC_SIZES.person.w) + : (data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH); + return { id: node.id, width, height }; +}), +``` + +**Helper function in constants.ts:** +```typescript +const ARCH_TYPE_MAP: Record = { + archService: ARCH_SIZES.service, + archDatabase: ARCH_SIZES.database, + archQueue: ARCH_SIZES.queue, + archLoadBalancer: ARCH_SIZES.loadbalancer, + archExternal: ARCH_SIZES.external, +}; + +export function getArchNodeSize( + flowType: string | undefined, +): { w: number; h: number } | null { + if (!flowType) return null; + return ARCH_TYPE_MAP[flowType] ?? null; +} +``` + +**Layout algorithm note:** The ELK `layered` algorithm with default settings naturally clusters connected nodes. For AC #3 ("service groups cluster together"), the existing `crossingMinimization: LAYER_SWEEP` and `nodePlacement: BRANDES_KOEPF` settings already optimize for this. No additional ELK configuration needed. + +### CSS Styles for Architecture Nodes + +Add to `globals.css`. The architecture theme uses `--diagram-architecture` (neutral/slate accent: `oklch(0.552 0.016 286)`). + +**Shared architecture node base:** +```css +/* ── Architecture Diagram Styles ─────────────────────────────────── */ + +/* Shared node structure */ +.arch-service, +.arch-database, +.arch-queue, +.arch-lb, +.arch-external { + /* Common styles set per class below */ +} + +.arch-node-icon { + font-size: 20px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.arch-node-info { min-width: 0; flex: 1; } +.arch-node-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.arch-node-meta { + font-size: 10px; + color: var(--muted-foreground); + line-height: 1.3; + font-family: monospace; +} +.arch-edge-label { + font-size: 10px; + color: var(--diagram-architecture); + background: var(--node-bg); + padding: 1px 6px; + border-radius: 4px; + border: 1px solid var(--diagram-architecture); + font-family: monospace; + pointer-events: none; + position: absolute; +} +``` + +**Service node (rectangle):** +```css +.arch-service { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + border-radius: 8px; + padding: 10px 14px; + min-width: 160px; + max-width: 240px; + cursor: pointer; +} +.arch-service:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); +} +``` + +**Database node (cylinder):** +```css +.arch-database { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + border-radius: 4px; + padding: 20px 14px 10px; + min-width: 120px; + max-width: 200px; + position: relative; + cursor: pointer; +} +.arch-database::before { + content: ""; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + height: 20px; + background: color-mix(in oklch, var(--diagram-architecture) 15%, var(--node-bg)); + border: 1.5px solid var(--diagram-architecture); + border-radius: 50% / 100%; +} +.arch-database:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); +} +``` + +**Queue node (parallelogram):** +```css +.arch-queue { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + padding: 10px 20px; + min-width: 140px; + max-width: 220px; + transform: skewX(-8deg); + cursor: pointer; +} +.arch-queue > * { transform: skewX(8deg); } +.arch-queue:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); +} +``` + +**Load Balancer node (diamond):** +```css +.arch-lb { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + width: 100px; + height: 100px; + transform: rotate(45deg); + cursor: pointer; +} +.arch-lb > * { transform: rotate(-45deg); } +.arch-lb:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); +} +``` + +**External System node (cloud):** +```css +.arch-external { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 2px dashed var(--diagram-architecture); + border-radius: 20px; + padding: 10px 16px; + min-width: 140px; + max-width: 220px; + cursor: pointer; +} +.arch-external:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); +} +``` + +**Design rationale:** Each shape is created with pure CSS transforms and borders — no SVG backgrounds. This keeps the implementation lightweight and consistent with the existing BPMN/E-R/Org Chart patterns. The external system uses dashed border to visually communicate it's outside the system boundary. The load balancer diamond uses rotate(45deg) with counter-rotation on children (same technique as BPMN gateway). + +### Existing Code to Reuse / Modify + +| File | Action | What | +|------|--------|------| +| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Register architecture node types, edge type, and arrow marker | +| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add architecture type resolution for nodes and edges (import from architecture/constants) | +| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Add architecture node size handling in `buildElkGraph` per subtype | +| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add architecture node CSS styles (5 shapes + edge label) | +| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode (tag, icon, color) — already defined, no changes | +| `apps/web/src/modules/diagram/lib/bfs-path.ts` | **REUSE** | Path highlighting works for architecture — diagram-type-agnostic | +| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **REUSE** | highlightedNodeId/setHighlightedNodeId — no changes needed | +| `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` | **REUSE** | Auto-layout works with flat ELK — no changes needed | + +### Library & Framework Requirements + +**No new packages required.** Everything built with existing dependencies: +- `@xyflow/react` 12.10.1 — custom nodes, edges, handles, BaseEdge, getBezierPath, EdgeLabelRenderer +- `elkjs` 0.11.0 — flat layered layout +- `zustand` 5.0.8 — highlight state (reuse existing) + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/architecture/ +│ ├── index.ts # Exports all architecture components + constants +│ ├── constants.ts # ARCH_SIZES, type mappings, getArchNodeSize +│ ├── constants.test.ts # Tests for architecture constants +│ ├── ArchServiceNode.tsx # Service custom node (rectangle + gear) +│ ├── ArchDatabaseNode.tsx # Database custom node (cylinder) +│ ├── ArchQueueNode.tsx # Queue custom node (parallelogram) +│ ├── ArchLoadBalancerNode.tsx # Load Balancer custom node (diamond) +│ ├── ArchExternalNode.tsx # External System custom node (cloud) +│ └── ArchConnectionEdge.tsx # Connection edge (solid/dashed + protocol label) +``` + +Modified files: +``` +apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register architecture node/edge types + marker +apps/web/src/modules/diagram/lib/graph-converter.ts # Architecture type mapping +apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add architecture converter tests +apps/web/src/modules/diagram/lib/elk-layout.ts # Architecture node sizes in buildElkGraph +apps/web/src/modules/diagram/lib/elk-layout.test.ts # Add architecture ELK tests +apps/web/src/assets/styles/globals.css # Architecture CSS styles +``` + +### Project Structure Notes + +- Architecture node components go in `~/modules/diagram/types/architecture/` — follows BPMN (`types/bpmn/`), E-R (`types/er/`), and Org Chart (`types/orgchart/`) pattern +- Layout utilities in `~/modules/diagram/lib/` — architecture uses the existing flat `buildElkGraph`, only node size handling added per subtype +- `bfs-path.ts` reused as-is — diagram-type-agnostic +- 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 (Story 2.1/2.3/2.4/2.5 pattern) +- **NEVER hardcode positions for architecture nodes** — all positioning from ELK 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 persisted graph data** — positions are ephemeral +- **NEVER use compound/hierarchical ELK layout for architecture** — flat layout is correct (no pools/lanes) +- **NEVER add new fields to DiagramNode** — use existing fields (`tag`, `icon`, `color`) with architecture semantics +- **NEVER create inline style objects in render** — use CSS classes; conditionally apply inline styles only when data overrides exist (lesson from Story 2.5 code review) +- **NEVER use `??` for empty string color guard** — use `||` so falsy `data.color` (including `""`) falls back correctly (lesson from Story 2.5 code review) +- **DO NOT implement Smart Inspector for architecture** — that's a future story +- **DO NOT implement other diagram type renderers** — those are Stories 2.7-2.8 +- **DO NOT break existing tests** — 107 tests must continue passing +- **DO NOT use inline styles for architecture colors** — use CSS custom properties (`--diagram-architecture`) +- **DO NOT add arrow markers to architecture edges inline** — use SVG `` in `MarkerDefs` with `markerEnd="url(#arch-arrow)"` + +### Previous Story Intelligence (Story 2.5 — Org Chart) + +**Key learnings to carry forward:** +- Constants file pattern: `OC_SIZES` map → `ARCH_SIZES` equivalent. Type resolution functions: `resolveXNodeType`, `resolveXEdgeType` +- Node component pattern: Cast `data` as `DiagramNode & { label: string }`, use `Handle` with `style={{ opacity: 0 }}` at 4 positions +- Edge component pattern: `BaseEdge` + path function. Architecture uses `getBezierPath` (not `getSmoothStepPath` like orgchart) for smooth curved connections +- Graph converter: `resolveFlowNodeType` switch on `diagramType`, import type resolver from `types/[type]/constants` +- DiagramCanvas: import components, add to `nodeTypes`/`edgeTypes` objects +- CSS: Use `--diagram-[type]` CSS variable for accent colors. Use `color-mix()` for hover/bg tints +- `graphNodeToFlowNode` already spreads all DiagramNode fields into `data` — custom nodes access `data.tag`, `data.icon` directly +- `flowNodeToGraphNode` already preserves `tag`, `icon`, `color` fields — roundtrip works +- Code review from 2.5: Remove dead helper functions (don't export `getArchXHeight` unless used in elk-layout.ts), use `||` not `??` for empty string color guards, don't create inline style objects on every render +- 107 web tests currently pass — don't break them + +### Previous Story Intelligence (Story 2.4 — E-R) + +**Key learnings:** +- Multiple node subtypes resolved via switch statement in `resolveXNodeType` (E-R only has 1, but the pattern supports N) +- Edge component with labels: E-R uses `EdgeLabelRenderer` for cardinality labels at source/target — architecture will use same `EdgeLabelRenderer` for protocol labels at midpoint +- `getErEntityHeight()` pattern for dynamic node sizing → architecture uses `getArchNodeSize()` for per-subtype fixed sizing + +### Git Intelligence + +Recent commits: +- `0a7838a feat: implement Story 2.3 — BPMN diagram type renderer` +- `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 +- `diagramTypeConfig` in DiagramCard.tsx already has architecture config: `{ label: "Architecture", icon: Icons.Network, color: "text-zinc-500" }` +- CSS variable `--diagram-architecture: oklch(0.552 0.016 286)` already defined in globals.css + +### Latest Tech Information + +**@xyflow/react 12.10.1 — getBezierPath + EdgeLabelRenderer:** +- `getBezierPath` produces smooth curved paths — appropriate for architecture connections (protocol flows) +- Returns `[path, labelX, labelY, offsetX, offsetY]` — `labelX/labelY` used for edge label positioning +- `EdgeLabelRenderer` renders HTML at edge label position — use `position: absolute` + `transform: translate()` with the label coordinates +- `BaseEdge` accepts `path`, `style`, `markerEnd` props +- `markerEnd="url(#arch-arrow)"` references SVG marker defined in `MarkerDefs` + +**ELK.js Layered Algorithm — Service Clustering:** +- The layered algorithm naturally groups densely connected nodes into the same or adjacent layers +- `crossingMinimization: LAYER_SWEEP` minimizes edge crossings, which effectively clusters related services +- No explicit "grouping" configuration needed — the algorithm's edge-based optimization handles AC #3 +- For diagrams where explicit grouping is desired, the `group` field on DiagramNode could be used in future to create ELK partition constraints + +**CSS Shapes without SVG:** +- Cylinder: `border-radius: 50% / 100%` on `::before` pseudo-element creates elliptical top +- Parallelogram: `transform: skewX(-8deg)` on container, `skewX(8deg)` on children to counter-skew text +- Diamond: `transform: rotate(45deg)` on container, `rotate(-45deg)` on children +- Cloud: `border-radius: 20px` + dashed border for "external" visual metaphor +- All shapes use `cursor: pointer` and `color-mix()` hover states + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.6] — Full AC: typed nodes (Service, Database, Queue, LB, External), protocol labels, sync/async edges, service clustering +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model: `arch:` prefix, shared base fields +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules, type prefixing: `arch:` +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Renderers] — Architecture: Service, Database, Queue, Load Balancer, External System +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Color Accents] — Architecture: neutral oklch(0.552 0.016 286) +- [Source: _bmad-output/implementation-artifacts/2-5-org-chart-diagram-type-renderer.md] — Org Chart implementation patterns (constants, nodes, edges, converter, canvas registration, code review learnings) +- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (tag, icon, color), DiagramType includes "architecture" +- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Current converter with BPMN + E-R + Org Chart resolution to extend for architecture +- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — Current flat layout builder, E-R/Org Chart size pattern to follow for architecture +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register architecture types + add arch-arrow marker +- [Source: apps/web/src/assets/styles/globals.css] — CSS with `--diagram-architecture` already defined + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (claude-opus-4-6) + +### Debug Log References + +None — clean implementation with no errors or retries. + +### Completion Notes List + +- All 12 tasks and subtasks implemented following existing BPMN/E-R/Org Chart patterns +- Applied Story 2.5 code review lessons: `||` for icon fallback (not `??`), no dead exports, CSS classes over inline styles +- Architecture uses flat ELK layout (no compound hierarchy) — service clustering achieved naturally via layered algorithm +- ArchConnectionEdge uses `getBezierPath` for smooth curves (distinct from orgchart's `getSmoothStepPath`) +- 19 new tests added (14 constants + 4 graph-converter + 2 elk-layout), total 126 tests passing +- No new fields added to DiagramNode — reuses `tag`, `icon`, `color` with architecture semantics +- CSS shapes: cylinder (::before pseudo-element), parallelogram (skewX), diamond (rotate 45deg), cloud (dashed border-radius) + +### File List + +**New files:** +- `apps/web/src/modules/diagram/types/architecture/constants.ts` — ARCH_SIZES, resolveArchitectureNodeType, resolveArchitectureEdgeType, getArchNodeSize +- ~~`apps/web/src/modules/diagram/types/architecture/index.ts`~~ — removed (dead barrel, no barrel files per project rules) +- `apps/web/src/modules/diagram/types/architecture/ArchServiceNode.tsx` — Service node (rectangle + gear) +- `apps/web/src/modules/diagram/types/architecture/ArchDatabaseNode.tsx` — Database node (cylinder) +- `apps/web/src/modules/diagram/types/architecture/ArchQueueNode.tsx` — Queue node (parallelogram) +- `apps/web/src/modules/diagram/types/architecture/ArchLoadBalancerNode.tsx` — Load Balancer node (diamond) +- `apps/web/src/modules/diagram/types/architecture/ArchExternalNode.tsx` — External System node (cloud) +- `apps/web/src/modules/diagram/types/architecture/ArchConnectionEdge.tsx` — Connection edge (solid/dashed + protocol label) +- `apps/web/src/modules/diagram/types/architecture/constants.test.ts` — 14 architecture constant tests + +**Modified files:** +- `apps/web/src/modules/diagram/lib/graph-converter.ts` — architecture type resolution in resolveFlowNodeType/resolveFlowEdgeType +- `apps/web/src/modules/diagram/lib/graph-converter.test.ts` — 3 new + 1 updated architecture converter tests +- `apps/web/src/modules/diagram/lib/elk-layout.ts` — architecture node size handling in buildElkGraph +- `apps/web/src/modules/diagram/lib/elk-layout.test.ts` — 2 architecture ELK layout tests +- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` — register 5 node types + 1 edge type + arch-arrow marker +- `apps/web/src/assets/styles/globals.css` — architecture CSS styles (5 node shapes + edge label + shared classes) + +## Senior Developer Review (AI) + +**Reviewer:** Mou on 2026-02-26 +**Model:** Claude Opus 4.6 + +### Findings Summary + +| Severity | Count | Fixed | +|----------|-------|-------| +| HIGH | 0 | — | +| MEDIUM | 3 | 3 | +| LOW | 3 | 2 | + +### Issues Found & Resolved + +**M1 — Unrelated changes in git (ProjectTree.tsx, router.ts)** +Reverted. These changes (rename `overProjectId`→`dragOverProjectId`, batch SQL optimization) are valid improvements but out of scope for Story 2.6. Should be committed separately. + +**M2 — Story test count discrepancy (12.2: "4 tests" vs 12.4: "19 total")** +Fixed. Task 12.2 now reads "3 new tests + 1 updated" — the "all 6 diagram types" test was updated, not added. 14 + 3 + 2 = 19 matches. + +**M3 — Dead barrel `index.ts` violating "no barrel files" rule** +Deleted `types/architecture/index.ts`. All imports already use specific subpaths. Task 1.2 updated in story. + +**L1 — Handle style objects `{ opacity: 0 }` recreated on every render** +Fixed. Extracted `HIDDEN_HANDLE` constant in `constants.ts`, referenced by all 5 node components. Eliminates 20 unnecessary object allocations per render cycle. + +**L2 — Five near-identical node components (NOT FIXED)** +Acknowledged. All 5 `Arch*Node` components only differ in CSS class and default emoji. Could be a single parameterized component. Deferred — follows existing BPMN/E-R/Org Chart project pattern. + +**L3 — Database cylinder double-border CSS overlap** +Fixed. Removed top border from `.arch-database` parent (`border-top: none; border-radius: 0 0 4px 4px`), so only the `::before` cylinder cap draws the top edge. + +### Change Log + +- 2026-02-26: Code review — reverted out-of-scope changes, removed dead barrel, extracted HIDDEN_HANDLE const, fixed cylinder CSS double-border, corrected test count documentation diff --git a/_bmad-output/implementation-artifacts/2-7-sequence-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-7-sequence-diagram-type-renderer.md new file mode 100644 index 0000000..504eb47 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-7-sequence-diagram-type-renderer.md @@ -0,0 +1,875 @@ +# Story 2.7: Sequence Diagram Type Renderer + +Status: done + + + +## Story + +As a user, +I want to create and view sequence diagrams with actors, lifelines, and messages, +so that I can model interaction patterns between system components over time. + +## Acceptance Criteria + +1. **Given** I open or create a sequence diagram, **When** the canvas renders, **Then** actors display at the top as labeled boxes with dashed vertical lifelines extending downward, **And** activation bars (thin rectangles) appear on lifelines during active processing. + +2. **Given** a sequence diagram has messages, **When** rendered, **Then** synchronous messages show as solid arrows between lifelines, **And** asynchronous messages show as open/dashed arrows, **And** return messages show as dashed arrows, **And** message labels appear above the arrow. + +3. **Given** a sequence diagram has fragments, **When** rendered, **Then** alt/loop/opt fragments display as labeled rectangles enclosing message groups, **And** fragment headers show the guard condition. + +## Tasks / Subtasks + +- [x] Task 1: Create sequence constants and type registry (AC: #1, #2) + - [x]1.1: Create `apps/web/src/modules/diagram/types/sequence/constants.ts` with `SEQ_SIZES`, `resolveSequenceNodeType()`, `resolveSequenceEdgeType()`, `getSeqNodeSize()` + - [x]1.2: Define `SEQ_LAYOUT` constants: `PARTICIPANT_SPACING`, `MESSAGE_START_Y`, `MESSAGE_SPACING`, `LIFELINE_PADDING` + +- [x] Task 2: Create SeqParticipantNode custom @xyflow/react component (AC: #1) + - [x]2.1: Create `apps/web/src/modules/diagram/types/sequence/SeqParticipantNode.tsx` — labeled box at top with icon, dashed vertical lifeline extending downward (CSS `::after`), activation bar rendering from `data.activations` + +- [x] Task 3: Create SeqFragmentNode custom @xyflow/react component (AC: #3) + - [x]3.1: Create `apps/web/src/modules/diagram/types/sequence/SeqFragmentNode.tsx` — labeled rectangle with fragment type header (alt/loop/opt) and guard condition from `data.tag` + +- [x] Task 4: Create SeqSyncEdge custom @xyflow/react component (AC: #2) + - [x]4.1: Create `apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx` — solid arrow, message label above, horizontal path at computed Y from `data.order` + +- [x] Task 5: Create SeqAsyncEdge custom @xyflow/react component (AC: #2) + - [x]5.1: Create `apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx` — dashed arrow with open arrowhead, message label above, horizontal path at computed Y from `data.order` + +- [x] Task 6: Create SeqReturnEdge custom @xyflow/react component (AC: #2) + - [x]6.1: Create `apps/web/src/modules/diagram/types/sequence/SeqReturnEdge.tsx` — dashed arrow (thinner), message label above, horizontal path at computed Y from `data.order` + +- [x] Task 7: Create custom sequence layout function (AC: #1, #2, #3) + - [x]7.1: Create `apps/web/src/modules/diagram/lib/sequence-layout.ts` with `computeSequenceLayout()` — custom layout that positions participants horizontally, computes message Y positions from edge order, sizes participant nodes to full lifeline height, positions fragment nodes around their message ranges + - [x]7.2: Unit tests for `computeSequenceLayout()` in `sequence-layout.test.ts` + +- [x] Task 8: Update graph converter for sequence type resolution (AC: #1, #2) + - [x]8.1: Import `resolveSequenceNodeType`, `resolveSequenceEdgeType` from `../types/sequence/constants` in `graph-converter.ts` + - [x]8.2: Add `sequence` diagramType and `seq:` prefix handling in `resolveFlowNodeType()` + - [x]8.3: Add `sequence` diagramType handling in `resolveFlowEdgeType()` + +- [x] Task 9: Register sequence types in DiagramCanvas (AC: #1, #2, #3) + - [x]9.1: Import SeqParticipantNode, SeqFragmentNode, SeqSyncEdge, SeqAsyncEdge, SeqReturnEdge in `DiagramCanvas.tsx` + - [x]9.2: Add sequence entries to `nodeTypes` and `edgeTypes` objects (OUTSIDE component) + - [x]9.3: Add sequence arrow markers to `MarkerDefs` — filled arrow for sync, open arrow for async/return + +- [x] Task 10: Integrate sequence layout in computeLayout (AC: #1, #2, #3) + - [x]10.1: In `elk-layout.ts` `computeLayout()`, detect sequence diagrams (check for `seqParticipant` node types) and route to `computeSequenceLayout()` instead of ELK + - [x]10.2: Sequence node size handling in `buildElkGraph` for fallback (if used) — `getSeqNodeSize()` for participant fixed dimensions + +- [x] Task 11: Add sequence CSS styles (AC: #1, #2, #3) + - [x]11.1: Add `.seq-participant`, `.seq-participant-box`, `.seq-lifeline`, `.seq-activation`, `.seq-fragment`, `.seq-fragment-header` styles to `globals.css` + - [x]11.2: Use `--diagram-sequence` amber accent color for all sequence elements + +- [x] Task 12: Tests (AC: all) + - [x]12.1: Unit tests for sequence constants — `resolveSequenceNodeType` for participant/fragment, `resolveSequenceEdgeType` for sync/async/return + - [x]12.2: Unit tests for graph converter sequence type mapping — node and edge types correctly resolved for `diagramType === "sequence"` + - [x]12.3: Unit tests for sequence layout — participant positioning, message Y computation, fragment bounds + - [x]12.4: Update "all 6 diagram types" test to assert `seq:participant` → `seqParticipant` (currently untested at line 147 of graph-converter.test.ts) + - [x]12.5: All tests pass — no regressions + +## Dev Notes + +### Overview — What This Story Builds + +This story adds the Sequence diagram type renderer: interaction diagrams with actors/participants, dashed lifelines, temporal message ordering, and fragment containers (alt/loop/opt). **Sequence diagrams are fundamentally different from all other diagram types** because they use time-ordered vertical positioning rather than graph-theoretic layout algorithms like ELK. + +**This story builds:** +- 2 custom sequence node components (SeqParticipantNode with lifeline, SeqFragmentNode) +- 3 custom sequence edge components (SeqSyncEdge, SeqAsyncEdge, SeqReturnEdge) +- Sequence constants and type registry (2 node subtypes + 3 edge types) +- **Custom sequence layout function** (NOT ELK — time-ordered vertical positioning) +- Graph converter sequence type resolution +- Sequence CSS styles with amber `--diagram-sequence` accent +- Sequence arrow markers in MarkerDefs + +**This story does NOT implement:** +- Other diagram types (Story 2.8) +- Smart Inspector for sequence field editing (future epic) +- Manual node repositioning (Story 2.9) +- Liveblocks/CRDT integration (Epic 4) +- AI-triggered mutations (Epic 3) +- Nested diagrams (Epic 7) + +### Architecture Compliance + +**MANDATORY patterns from Architecture Decision Document:** + +1. **Unified Graph Data Model (Decision 1):** Sequence nodes use type-prefixed `type` field (`seq:participant`, `seq:fragment`). Existing DiagramNode fields are semantically reused: `label` = actor name, `tag` = metadata (guard condition for fragments), `icon` = actor emoji (override), `color` = custom accent override, `lifeline` = true for participant nodes. Edge types use lowercase strings: `sync`, `async`, `return`. NO new fields on DiagramNode or DiagramEdge needed. + +2. **Component Structure:** Feature code in `~/modules/diagram/types/sequence/` — follows the BPMN (`types/bpmn/`), E-R (`types/er/`), Org Chart (`types/orgchart/`), Architecture (`types/architecture/`) pattern. + +3. **@xyflow/react Custom Nodes:** All custom node components use `NodeProps` typing. The `nodeTypes` and `edgeTypes` objects MUST be defined OUTSIDE the component (performance critical — established in Stories 2.1-2.6). + +4. **Custom Layout (NOT ELK):** Sequence diagrams DO NOT use the standard ELK layered algorithm. A dedicated `computeSequenceLayout()` function handles time-ordered vertical positioning. The `computeLayout()` function in `elk-layout.ts` must detect sequence diagrams and route to this custom layout. + +5. **Lean JSON Data Model:** No x/y positions stored for sequence nodes. All positioning computed by the custom layout at render time. + +6. **Type Prefixing Convention (Enforcement Rule #5):** `seq:` prefix on DiagramNode.type. Edge types are bare lowercase strings: `sync`, `async`, `return`. + +### Sequence Diagram Data Model — Field Mapping + +The existing DiagramNode and DiagramEdge fields satisfy sequence diagram needs: + +```typescript +// seq:participant node +{ + id: "server", + type: "seq:participant", + label: "API Server", // Actor name + icon: "⚙️", // Actor icon (overridable) + color: "#f59e0b", // Custom accent (optional) + lifeline: true, // Marks as lifeline participant +} + +// seq:fragment node +{ + id: "alt-1", + type: "seq:fragment", + label: "alt", // Fragment type: "alt" | "loop" | "opt" + tag: "[valid credentials]", // Guard condition + group: "m2,m3,m4", // Comma-separated message IDs this fragment encompasses +} +``` + +**Edge model:** +```typescript +// Synchronous message +{ + id: "m1", + from: "client", + to: "server", + label: "POST /login", // Message label shown above arrow + type: "sync", // "sync" = solid arrow +} + +// Asynchronous message +{ + id: "m2", + from: "server", + to: "worker", + label: "enqueue job", + type: "async", // "async" = dashed open arrow +} + +// Return message +{ + id: "m3", + from: "server", + to: "client", + label: "200 OK", + type: "return", // "return" = dashed arrow +} +``` + +**Message ordering:** Messages are ordered by their position in the `edges` array. The layout function uses array index as temporal order. Edge `data.order` is set by the layout function at runtime. + +### Sequence Node Types — Size Constants + +```typescript +export const SEQ_SIZES = { + participant: { w: 160, h: 60 }, // Top box dimensions (lifeline extends below) + fragment: { w: 0, h: 0 }, // Computed dynamically from contained messages +} as const; + +export const SEQ_LAYOUT = { + participantSpacing: 200, // Horizontal spacing between participants + messageStartY: 100, // Y offset for first message below participant box + messageSpacing: 50, // Vertical spacing between messages + lifelinePadding: 40, // Extra space below last message + activationWidth: 12, // Width of activation bar rectangles + fragmentPadding: 16, // Padding inside fragment rectangles +} as const; +``` + +### Sequence Node Type → @xyflow/react Type Mapping + +| DiagramNode.type | @xyflow Node type | Component | Visual | +|---|---|---|---| +| `seq:participant` | `seqParticipant` | `SeqParticipantNode` | Labeled box + dashed vertical lifeline + activation bars | +| `seq:fragment` | `seqFragment` | `SeqFragmentNode` | Labeled rectangle with type header and guard condition | + +**Type resolution:** +```typescript +export function resolveSequenceNodeType(type: string): string { + const bare = type.startsWith("seq:") ? type.slice(4) : type; + switch (bare) { + case "participant": + return "seqParticipant"; + case "fragment": + return "seqFragment"; + default: + return "seqParticipant"; // Default to participant + } +} + +export function resolveSequenceEdgeType(type: string | undefined): string { + switch (type) { + case "sync": + return "seqSync"; + case "async": + return "seqAsync"; + case "return": + return "seqReturn"; + default: + return "seqSync"; // Default to synchronous + } +} +``` + +### Sequence Edge Types — Messages + +| DiagramEdge.type | @xyflow Edge type | Visual | +|---|---|---| +| `sync` (or default) | `seqSync` | Solid line with filled arrowhead, label above | +| `async` | `seqAsync` | Dashed line with open arrowhead, label above | +| `return` | `seqReturn` | Dashed thin line with filled arrowhead, label above | + +### Custom Sequence Layout — CRITICAL DIFFERENCE + +**Sequence diagrams CANNOT use ELK.** ELK's layered/Sugiyama algorithm optimizes for graph-theoretic properties (crossing minimization, layer assignment). Sequence diagrams require **temporal ordering** — messages appear top-to-bottom in the order they occur, not based on graph structure. + +**Layout Algorithm (`computeSequenceLayout`):** + +```typescript +export function computeSequenceLayout( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + const participants = nodes.filter(n => + n.type === "seqParticipant" + ); + const fragments = nodes.filter(n => + n.type === "seqFragment" + ); + + // 1. Position participants horizontally + participants.forEach((p, i) => { + p.position = { x: i * SEQ_LAYOUT.participantSpacing, y: 0 }; + }); + + // 2. Compute lifeline height from message count + const lifelineHeight = + SEQ_LAYOUT.messageStartY + + edges.length * SEQ_LAYOUT.messageSpacing + + SEQ_LAYOUT.lifelinePadding; + + // 3. Set participant node height to cover full lifeline + participants.forEach(p => { + p.style = { ...p.style, height: lifelineHeight }; + (p.data as Record).lifelineHeight = lifelineHeight; + }); + + // 4. Compute message Y positions and store in edge data + const enrichedEdges = edges.map((edge, i) => ({ + ...edge, + data: { + ...edge.data, + order: i, + messageY: SEQ_LAYOUT.messageStartY + i * SEQ_LAYOUT.messageSpacing, + }, + })); + + // 5. Compute activation bars per participant + // Activation: starts at sync message received, ends at return sent + // Store in participant data for rendering + + // 6. Position fragment nodes around their messages + const participantXMap = new Map( + participants.map(p => [p.id, p.position.x]) + ); + fragments.forEach(frag => { + const fragData = frag.data as Record; + const messageIds = ((fragData.group as string) || "").split(","); + const messageIndices = messageIds + .map(id => enrichedEdges.findIndex(e => e.id === id)) + .filter(i => i >= 0); + + if (messageIndices.length > 0) { + const minIdx = Math.min(...messageIndices); + const maxIdx = Math.max(...messageIndices); + const topY = + SEQ_LAYOUT.messageStartY + + minIdx * SEQ_LAYOUT.messageSpacing - + SEQ_LAYOUT.fragmentPadding; + const bottomY = + SEQ_LAYOUT.messageStartY + + maxIdx * SEQ_LAYOUT.messageSpacing + + SEQ_LAYOUT.fragmentPadding; + + // Find leftmost and rightmost participants involved + const involvedEdges = messageIndices.map(i => enrichedEdges[i]!); + const allParticipantIds = new Set( + involvedEdges.flatMap(e => [e.source, e.target]) + ); + const xPositions = [...allParticipantIds] + .map(id => participantXMap.get(id) ?? 0); + const minX = Math.min(...xPositions) - SEQ_LAYOUT.fragmentPadding; + const maxX = Math.max(...xPositions) + + SEQ_SIZES.participant.w + + SEQ_LAYOUT.fragmentPadding; + + frag.position = { x: minX, y: topY }; + frag.style = { + width: maxX - minX, + height: bottomY - topY, + }; + } + }); + + return { + nodes: [...participants, ...fragments], + edges: enrichedEdges, + }; +} +``` + +**Integration with `computeLayout` in `elk-layout.ts`:** +```typescript +// In computeLayout(), before ELK dispatch: +const isSequence = nodes.some(n => n.type === "seqParticipant"); +if (isSequence) { + // Sequence uses custom layout, not ELK worker + const result = computeSequenceLayout(nodes, edges); + resolve(result.nodes); + // Note: edges are also enriched with data.order/messageY + return; +} +``` + +**Important:** `computeSequenceLayout` is synchronous (no Web Worker needed). Sequence diagrams are always small (tens of participants, not hundreds of nodes), so the computation is instant. + +### Sequence Edge Components — Custom Y-Positioned Rendering + +All three edge components share a similar pattern: they compute their Y position from `data.order` and draw a horizontal arrow: + +```typescript +import { EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; +import { SEQ_LAYOUT } from "./constants"; + +export function SeqSyncEdge(props: EdgeProps) { + const edgeData = props.data as Record | undefined; + const order = (edgeData?.order as number) ?? 0; + const messageY = (edgeData?.messageY as number) ?? + SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing; + + // Horizontal arrow from source to target at messageY + const isLeftToRight = props.sourceX < props.targetX; + const startX = isLeftToRight + ? props.sourceX + SEQ_SIZES.participant.w / 2 + : props.sourceX - SEQ_SIZES.participant.w / 2; + const endX = isLeftToRight + ? props.targetX - SEQ_SIZES.participant.w / 2 + : props.targetX + SEQ_SIZES.participant.w / 2; + + const path = `M ${startX} ${messageY} L ${endX} ${messageY}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} +``` + +**Differences between edge types:** +- `SeqSyncEdge`: solid stroke, filled arrowhead (`#seq-arrow-filled`) +- `SeqAsyncEdge`: dashed stroke (`strokeDasharray: "6 3"`), open arrowhead (`#seq-arrow-open`) +- `SeqReturnEdge`: dashed stroke (`strokeDasharray: "4 4"`), filled arrowhead, thinner stroke (1px) + +### Participant Node Component — Lifeline + Activation Bars + +```typescript +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "../architecture/constants"; + +export function SeqParticipantNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { + label: string; + lifelineHeight?: number; + activations?: { y: number; height: number }[]; + }; + const icon = d.icon || "👤"; + const lifelineHeight = d.lifelineHeight ?? 400; + const activations = d.activations ?? []; + + return ( +
+ {/* Actor box at top */} +
+ {icon} + {d.label} +
+ {/* Dashed lifeline */} +
+ {/* Activation bars */} + {activations.map((act, i) => ( +
+ ))} + + + + +
+ ); +} +``` + +**Reuses `HIDDEN_HANDLE`** from architecture/constants.ts (extracted in Story 2.6 code review). + +### Fragment Node Component + +```typescript +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; + +export function SeqFragmentNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const fragmentType = d.label; // "alt" | "loop" | "opt" + const guardCondition = d.tag; // "[condition]" + + return ( +
+
+ {fragmentType} + {guardCondition && ( + {guardCondition} + )} +
+
+ ); +} +``` + +**Note:** Fragment nodes do NOT need handles — they are container nodes, not edge endpoints. They should be in the CONTAINER_TYPES set for BFS highlighting skip. + +### Graph Converter Updates + +Add sequence type resolution alongside existing types: + +```typescript +// In resolveFlowNodeType(): +if (diagramType === "sequence" || nodeType.startsWith("seq:")) { + return resolveSequenceNodeType(nodeType); +} + +// In resolveFlowEdgeType(): +if (diagramType === "sequence") { + return resolveSequenceEdgeType(edgeType); +} +``` + +**Import from sequence/constants.ts** — same pattern as BPMN, E-R, Org Chart, Architecture. + +**graphToFlow for sequence diagrams:** Uses the standard default path (flat node mapping). No container nodes needed in the converter — fragments are regular nodes. + +**CONTAINER_TYPES update:** Add `"seqFragment"` to the set so BFS highlighting skips fragment container nodes. + +### DiagramCanvas Updates + +```typescript +import { SeqParticipantNode } from "../../types/sequence/SeqParticipantNode"; +import { SeqFragmentNode } from "../../types/sequence/SeqFragmentNode"; +import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge"; +import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge"; +import { SeqReturnEdge } from "../../types/sequence/SeqReturnEdge"; + +const nodeTypes = { + // Existing BPMN + E-R + Org Chart + Architecture types... + seqParticipant: SeqParticipantNode, + seqFragment: SeqFragmentNode, +}; + +const edgeTypes = { + // Existing types... + seqSync: SeqSyncEdge, + seqAsync: SeqAsyncEdge, + seqReturn: SeqReturnEdge, +}; + +const CONTAINER_TYPES = new Set([ + "bpmnPool", "bpmnLane", "bpmnGroup", + "seqFragment", // ← ADD +]); +``` + +**Arrow markers needed in MarkerDefs:** +```tsx +{/* Sequence markers */} + + + + + + +``` + +### CSS Styles for Sequence Diagram + +Add to `globals.css`. The sequence theme uses `--diagram-sequence` (amber accent: `oklch(0.795 0.184 86)`, already defined). + +```css +/* ── Sequence Diagram Styles ────────────────────────────────────── */ + +.seq-participant { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 160px; +} + +.seq-participant-box { + display: flex; + align-items: center; + gap: 6px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-sequence); + border-radius: 6px; + padding: 8px 14px; + width: 100%; + cursor: pointer; + z-index: 1; +} +.seq-participant-box:hover { + background: color-mix(in oklch, var(--diagram-sequence) 8%, var(--node-bg)); +} + +.seq-participant-icon { + font-size: 18px; + flex-shrink: 0; +} + +.seq-participant-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.seq-lifeline { + width: 0; + border-left: 2px dashed var(--diagram-sequence); + opacity: 0.5; + position: absolute; + top: 60px; /* Below participant box */ + left: 50%; + transform: translateX(-50%); +} + +.seq-activation { + position: absolute; + width: 12px; + left: 50%; + transform: translateX(-50%); + background: color-mix(in oklch, var(--diagram-sequence) 20%, var(--node-bg)); + border: 1.5px solid var(--diagram-sequence); + border-radius: 2px; + z-index: 1; +} + +.seq-fragment { + background: color-mix(in oklch, var(--diagram-sequence) 4%, transparent); + border: 1.5px solid var(--diagram-sequence); + border-radius: 4px; + width: 100%; + height: 100%; + position: relative; +} + +.seq-fragment-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-bottom: 1px solid var(--diagram-sequence); + border-right: 1px solid var(--diagram-sequence); + border-bottom-right-radius: 8px; + width: fit-content; +} + +.seq-fragment-type { + font-weight: 700; + font-size: 11px; + color: var(--diagram-sequence); + text-transform: uppercase; + font-family: monospace; +} + +.seq-fragment-guard { + font-size: 11px; + color: var(--muted-foreground); + font-family: monospace; +} + +.seq-edge-label { + font-size: 11px; + color: var(--diagram-sequence); + background: var(--node-bg); + padding: 1px 6px; + border-radius: 4px; + font-family: monospace; + pointer-events: none; + white-space: nowrap; +} +``` + +### Existing Code to Reuse / Modify + +| File | Action | What | +|------|--------|------| +| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Register sequence node types, edge types, arrow markers, add `seqFragment` to CONTAINER_TYPES | +| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add sequence type resolution for nodes and edges | +| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Add sequence diagram detection → route to custom layout; add `getSeqNodeSize()` handling | +| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add sequence CSS styles | +| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode (lifeline, tag, icon, color, group) — already defined, no changes | +| `apps/web/src/modules/diagram/lib/bfs-path.ts` | **REUSE** | Path highlighting works for sequence — diagram-type-agnostic | +| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **REUSE** | highlightedNodeId — no changes needed | +| `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` | **REUSE** | Auto-layout triggers computeLayout → routes to sequence layout | +| `apps/web/src/modules/diagram/types/architecture/constants.ts` | **REUSE** | `HIDDEN_HANDLE` constant for handle styles | + +### Library & Framework Requirements + +**No new packages required.** Everything built with existing dependencies: +- `@xyflow/react` 12.10.1 — custom nodes, edges, handles, EdgeLabelRenderer +- `elkjs` 0.11.0 — NOT used for sequence layout, but still used for other diagram types +- `zustand` 5.0.8 — highlight state (reuse existing) + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/sequence/ +│ ├── constants.ts # SEQ_SIZES, SEQ_LAYOUT, type resolution functions +│ ├── constants.test.ts # Tests for sequence constants +│ ├── SeqParticipantNode.tsx # Participant + lifeline + activation bars +│ ├── SeqFragmentNode.tsx # Alt/loop/opt fragment container +│ ├── SeqSyncEdge.tsx # Synchronous message (solid arrow) +│ ├── SeqAsyncEdge.tsx # Asynchronous message (dashed open arrow) +│ └── SeqReturnEdge.tsx # Return message (dashed arrow) +├── lib/ +│ ├── sequence-layout.ts # Custom sequence layout function +│ └── sequence-layout.test.ts # Tests for sequence layout +``` + +Modified files: +``` +apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register sequence types + markers + CONTAINER_TYPES +apps/web/src/modules/diagram/lib/graph-converter.ts # Sequence type mapping +apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add sequence converter tests + fix line 147 +apps/web/src/modules/diagram/lib/elk-layout.ts # Sequence detection → custom layout routing +apps/web/src/modules/diagram/lib/elk-layout.test.ts # Add sequence layout integration tests +apps/web/src/assets/styles/globals.css # Sequence CSS styles +``` + +### Project Structure Notes + +- Sequence node components go in `~/modules/diagram/types/sequence/` — follows BPMN/E-R/Org Chart/Architecture pattern +- Custom sequence layout in `~/modules/diagram/lib/sequence-layout.ts` — separate module from `elk-layout.ts` because it uses a fundamentally different algorithm +- Tests co-located next to source files +- No barrel files — import from specific subpaths + +### Anti-Patterns to Avoid + +- **NEVER use ELK for sequence diagram layout** — sequence diagrams require temporal vertical ordering, not graph-theoretic layout. This is the single biggest mistake a developer could make on this story. +- **NEVER put `nodeTypes` or `edgeTypes` inside the component** — causes re-renders (established pattern) +- **NEVER hardcode positions for sequence nodes in GraphData** — all positioning from custom 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 layout-computed positions in persisted graph data** — positions are ephemeral +- **NEVER add new fields to DiagramNode or DiagramEdge** — use existing fields (`tag`, `icon`, `color`, `group`, `lifeline`) with sequence semantics +- **NEVER create inline style objects in render** — use CSS classes; conditionally apply inline styles only when data-driven (e.g., activation bar positions) +- **NEVER use `??` for empty string fallbacks** — use `||` so falsy `""` falls back correctly (lesson from Story 2.5 code review) +- **NEVER create barrel `index.ts` files** — per project rules, no barrel files in feature modules +- **DO NOT implement Smart Inspector for sequence** — future story +- **DO NOT implement other diagram type renderers** — Story 2.8 handles flowcharts +- **DO NOT break existing tests** — 443 tests must continue passing (27 test files across monorepo) +- **DO NOT add arrow markers inline** — use SVG `` in `MarkerDefs` with `markerEnd="url(#seq-arrow-filled)"` / `url(#seq-arrow-open)` + +### Previous Story Intelligence (Story 2.6 — Architecture) + +**Key learnings to carry forward:** +- Constants file pattern: `ARCH_SIZES` → `SEQ_SIZES` equivalent. Type resolution functions: `resolveXNodeType()`, `resolveXEdgeType()` +- Node component pattern: Cast `data` as `DiagramNode & { label: string }`, use `Handle` with `style={HIDDEN_HANDLE}` (constant from architecture/constants.ts — reuse it) +- Edge component pattern: `BaseEdge` not used for sequence — custom SVG path for horizontal arrows at computed Y positions +- Graph converter: `resolveFlowNodeType` switch on `diagramType`, import type resolver from `types/[type]/constants` +- DiagramCanvas: import components, add to `nodeTypes`/`edgeTypes` objects OUTSIDE component +- CSS: Use `--diagram-[type]` CSS variable for accent colors. Use `color-mix()` for hover/bg tints +- `graphNodeToFlowNode` already spreads all DiagramNode fields into `data` — custom nodes access `data.tag`, `data.icon`, `data.lifeline` directly +- `flowNodeToGraphNode` already preserves `tag`, `icon`, `color`, `lifeline`, `group` fields — roundtrip works +- Code review lessons from 2.5/2.6: Remove dead helper functions, use `||` not `??` for empty string color guards, don't create inline style objects on every render, reuse `HIDDEN_HANDLE` constant +- `CONTAINER_TYPES` set needs to include `"seqFragment"` for BFS highlighting to skip fragments +- 443 tests currently pass across monorepo — don't break them + +### Previous Story Intelligence (Story 2.3 — BPMN) + +**Key learnings relevant to sequence:** +- BPMN uses compound ELK layout with pools/lanes — sequence does NOT. But BPMN's compound detection pattern in `computeLayout()` is the template for sequence detection routing +- BPMN's `MarkerDefs` pattern: `#bpmn-arrow-filled` and `#bpmn-arrow-open` — reuse same SVG marker structure for `#seq-arrow-filled` and `#seq-arrow-open` +- Multiple edge types per diagram (BPMN has sequence/message/association, sequence has sync/async/return) — each gets its own custom edge component +- The `flowToBpmnGraphData()` pattern shows how to reconstruct GraphData from @xyflow nodes for custom layout — sequence might need a similar helper + +### Git Intelligence + +Recent commits: +- `0a7838a feat: implement Story 2.3 — BPMN diagram type renderer` +- `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 +- `diagramTypeConfig` in DiagramCard.tsx already has sequence config: `{ label: "Sequence", icon: Icons.ArrowRightLeft, color: "text-amber-500" }` +- CSS variable `--diagram-sequence: oklch(0.795 0.184 86)` already defined in globals.css +- Graph-converter.test.ts line 129 already uses `seq:participant` but has no assertion (nodes[4] untested) — must update + +### Latest Tech Information + +**@xyflow/react 12.10.1 — Custom Edge SVG Paths:** +- Edge components render inside the `` layer of the ReactFlow pane +- Custom paths can be drawn directly with `` elements using the same coordinate system as node positions +- `EdgeLabelRenderer` renders HTML labels in a separate div layer on top of the SVG — use `position: absolute` + `transform` for positioning +- Edge components receive `sourceX, sourceY, targetX, targetY` from handle positions, but custom implementations can ignore these and compute their own coordinates +- `markerEnd` prop on `` references SVG markers defined in `` — same pattern as BPMN and architecture + +**Edge Viewport Culling Consideration:** +- @xyflow determines edge visibility based on computed edge bounds from source/target handle positions +- If custom edges render far from handle positions (sequence messages at different Y), they may be culled from rendering +- Mitigation: Size participant nodes to cover the full lifeline height so handles are within the relevant viewport area +- Alternative: Use `data.order` to set participant node height covering all message positions + +**CSS Shapes for Sequence Elements:** +- Lifeline: `border-left: 2px dashed` on a div positioned below the participant box +- Activation bar: Small rectangle (`width: 12px`) positioned absolutely on the lifeline +- Fragment: `border: 1.5px solid` rectangle with a header corner tab (border-bottom-right-radius on header div) +- All use `color-mix()` for tinted backgrounds and `cursor: pointer` for interactivity + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.7] — Full AC: actors, lifelines, activation bars, sync/async/return messages, alt/loop/opt fragments +- [Source: _bmad-output/planning-artifacts/epics.md#Technical Notes] — Custom layout logic needed, NOT standard ELK; custom nodes at types/sequence/; amber accent +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model: `seq:` prefix, shared base fields, lifeline boolean +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules, type prefixing: `seq:` +- [Source: _bmad-output/planning-artifacts/architecture.md#DiagramNode] — lifeline?: boolean for sequence participant nodes +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Renderers] — Sequence: Actor/Lifeline, Activation bar, Fragment (alt/loop/opt) +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Color Accents] — Sequence: amber oklch(0.795 0.184 86) +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#DiagramEdgeRenderer] — Sequence edges: sync/async/return +- [Source: _bmad-output/implementation-artifacts/2-6-architecture-diagram-type-renderer.md] — Architecture patterns: constants, nodes, edges, converter, canvas registration, HIDDEN_HANDLE, code review learnings +- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (lifeline, tag, icon, color, group), DiagramType includes "sequence" +- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Current converter with BPMN + E-R + OrgChart + Architecture resolution; line 43 comment "Future: sequence, flowchart" +- [Source: apps/web/src/modules/diagram/lib/graph-converter.test.ts] — Line 129 uses seq:participant, line 147 has no assertion for it (BUG to fix) +- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — computeLayout() with compound BPMN detection pattern to follow for sequence detection +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register sequence types + markers + CONTAINER_TYPES +- [Source: apps/web/src/modules/diagram/components/DiagramCard.tsx] — Line 59: sequence config already defined with Icons.ArrowRightLeft +- [Source: apps/web/src/assets/styles/globals.css] — Line 28: `--diagram-sequence: oklch(0.795 0.184 86)` already defined + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (1M context) + +### Debug Log References + +No issues encountered. All tests passed on first run. + +### Completion Notes List + +- Created sequence constants with SEQ_SIZES, SEQ_LAYOUT, type resolution functions, and getSeqNodeSize +- Implemented SeqParticipantNode with labeled box, dashed lifeline (CSS ::after equivalent via absolute div), and activation bar rendering +- Implemented SeqFragmentNode with fragment type header (alt/loop/opt) and guard condition display +- Implemented 3 custom edge components (SeqSyncEdge, SeqAsyncEdge, SeqReturnEdge) with horizontal arrow paths at computed Y positions, each with distinct visual styles (solid/dashed/thin-dashed) +- Created custom `computeSequenceLayout()` — synchronous time-ordered layout that positions participants horizontally, computes message Y from edge order, sizes lifelines, and positions fragments around their message ranges +- Updated graph-converter.ts with sequence type resolution (node: seq: prefix, edge: sync/async/return) +- Registered all sequence node/edge types in DiagramCanvas, added seqFragment to CONTAINER_TYPES for BFS skip +- Added seq-arrow-filled and seq-arrow-open SVG markers to MarkerDefs +- Integrated sequence detection in computeLayout() — routes to custom layout instead of ELK worker +- Added getSeqNodeSize fallback handling in buildElkGraph +- Added sequence CSS styles with amber accent (--diagram-sequence) following established pattern +- Fixed untested seq:participant assertion at line 147 of graph-converter.test.ts +- All 469 tests pass across monorepo (0 regressions) + +### File List + +**New files:** +- apps/web/src/modules/diagram/types/sequence/constants.ts +- apps/web/src/modules/diagram/types/sequence/constants.test.ts +- apps/web/src/modules/diagram/types/sequence/SeqParticipantNode.tsx +- apps/web/src/modules/diagram/types/sequence/SeqFragmentNode.tsx +- apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx +- apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx +- apps/web/src/modules/diagram/types/sequence/SeqReturnEdge.tsx +- apps/web/src/modules/diagram/lib/sequence-layout.ts +- apps/web/src/modules/diagram/lib/sequence-layout.test.ts + +**Modified files:** +- 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/lib/elk-layout.test.ts +- apps/web/src/modules/diagram/hooks/useAutoLayout.ts +- apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +- apps/web/src/assets/styles/globals.css + +### Change Log + +- 2026-02-27: Implemented Story 2.7 — Sequence Diagram Type Renderer. Added custom sequence layout, 2 node components, 3 edge components, constants, CSS styles, graph converter + canvas registration. 469 tests passing. +- 2026-02-27: Code review fixes (7 issues). CRITICAL: computeLayout now returns LayoutResult{nodes,edges?} so enriched sequence edges flow through useAutoLayout to the store — fixes all messages rendering at same Y. CRITICAL: computeSequenceLayout now computes activation bars (sync→open, return→close). MEDIUM: layout function no longer mutates input nodes/edges (immutable). MEDIUM: added sequence node size tests to elk-layout.test.ts. MEDIUM: edge components handle self-messages with U-shaped loop. LOW: seq-edge-label-positioned CSS class for position:absolute. LOW: corrected test count (469→476). 476 tests passing. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 1cb660a..4b37744 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -53,10 +53,10 @@ development_status: 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: done - 2-4-entity-relationship-diagram-type-renderer: backlog - 2-5-org-chart-diagram-type-renderer: backlog - 2-6-architecture-diagram-type-renderer: backlog - 2-7-sequence-diagram-type-renderer: backlog + 2-4-entity-relationship-diagram-type-renderer: done + 2-5-org-chart-diagram-type-renderer: done + 2-6-architecture-diagram-type-renderer: done + 2-7-sequence-diagram-type-renderer: done 2-8-flowchart-diagram-type-renderer: backlog 2-9-node-selection-and-manual-repositioning: backlog epic-2-retrospective: optional diff --git a/apps/web/src/assets/styles/globals.css b/apps/web/src/assets/styles/globals.css index 5c65554..ec6c535 100644 --- a/apps/web/src/assets/styles/globals.css +++ b/apps/web/src/assets/styles/globals.css @@ -233,6 +233,423 @@ pointer-events: auto; } + /* ── E-R Entity Styles ──────────────────────────────────────────────── */ + + .er-entity { + background: var(--node-bg); + border: 1.5px solid var(--diagram-er); + border-radius: 6px; + min-width: 220px; + font-size: 12px; + overflow: hidden; + cursor: pointer; + } + + .er-entity-header { + background: var(--diagram-er); + color: white; + font-weight: 600; + font-size: 13px; + padding: 6px 12px; + } + + .er-entity-body { + padding: 4px 0; + } + + .er-entity-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + line-height: 20px; + } + + .er-entity-row:hover { + background: color-mix(in oklch, var(--diagram-er) 5%, transparent); + } + + .er-entity-indicator { + width: 18px; + text-align: center; + flex-shrink: 0; + font-size: 12px; + } + + .er-entity-col-name { + font-weight: 500; + color: var(--foreground); + flex: 1; + min-width: 0; + } + + .er-entity-col-type { + color: var(--muted-foreground); + font-size: 10px; + flex-shrink: 0; + } + + .er-entity-constraint { + color: var(--diagram-er); + font-size: 10px; + font-weight: 600; + width: 16px; + text-align: center; + flex-shrink: 0; + } + + .er-entity-empty { + color: var(--muted-foreground); + font-style: italic; + justify-content: center; + padding: 8px 10px; + } + + .er-cardinality { + background: var(--node-bg); + border: 1px solid var(--diagram-er); + border-radius: 4px; + padding: 1px 5px; + font-size: 10px; + font-weight: 600; + color: var(--diagram-er); + } + + /* ── Org Chart Person Styles ─────────────────────────────────────────── */ + + .oc-person { + display: flex; + align-items: center; + gap: 10px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-orgchart); + border-left: 4px solid var(--diagram-orgchart); + border-radius: 8px; + padding: 10px 14px; + min-width: 240px; + max-width: 320px; + cursor: pointer; + } + + .oc-person:hover { + background: color-mix(in oklch, var(--diagram-orgchart) 5%, var(--node-bg)); + } + + .oc-person-avatar { + font-size: 24px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: color-mix(in oklch, var(--diagram-orgchart) 10%, transparent); + border-radius: 50%; + } + + .oc-person-info { + min-width: 0; + flex: 1; + } + + .oc-person-name { + font-weight: 600; + font-size: 13px; + color: var(--foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .oc-person-role { + font-size: 11px; + color: var(--muted-foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .oc-person-dept { + font-size: 10px; + color: var(--diagram-orgchart); + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* ── Architecture Diagram Styles ────────────────────────────────────── */ + + .arch-node-icon { + font-size: 20px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .arch-node-info { + min-width: 0; + flex: 1; + } + + .arch-node-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .arch-node-meta { + font-size: 10px; + color: var(--muted-foreground); + line-height: 1.3; + font-family: var(--font-mono, ui-monospace, monospace); + } + + .arch-edge-label { + font-size: 10px; + color: var(--diagram-architecture); + background: var(--node-bg); + padding: 1px 6px; + border-radius: 4px; + border: 1px solid var(--diagram-architecture); + font-family: var(--font-mono, ui-monospace, monospace); + pointer-events: none; + position: absolute; + } + + .arch-service { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + border-radius: 8px; + padding: 10px 14px; + min-width: 160px; + max-width: 240px; + cursor: pointer; + } + + .arch-service:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); + } + + .arch-database { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + border-top: none; + border-radius: 0 0 4px 4px; + padding: 20px 14px 10px; + min-width: 120px; + max-width: 200px; + position: relative; + cursor: pointer; + } + + .arch-database::before { + content: ""; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + height: 20px; + background: color-mix(in oklch, var(--diagram-architecture) 15%, var(--node-bg)); + border: 1.5px solid var(--diagram-architecture); + border-radius: 50% / 100%; + } + + .arch-database:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); + } + + .arch-queue { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + padding: 10px 20px; + min-width: 140px; + max-width: 220px; + transform: skewX(-8deg); + cursor: pointer; + } + + .arch-queue > * { + transform: skewX(8deg); + } + + .arch-queue:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); + } + + .arch-lb { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-architecture); + width: 100px; + height: 100px; + transform: rotate(45deg); + cursor: pointer; + } + + .arch-lb > * { + transform: rotate(-45deg); + } + + .arch-lb:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); + } + + .arch-external { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 2px dashed var(--diagram-architecture); + border-radius: 20px; + padding: 10px 16px; + min-width: 140px; + max-width: 220px; + cursor: pointer; + } + + .arch-external:hover { + background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg)); + } + + /* ── Sequence Diagram Styles ────────────────────────────────────────── */ + + .seq-participant { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 160px; + } + + .seq-participant-box { + display: flex; + align-items: center; + gap: 6px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-sequence); + border-radius: 6px; + padding: 8px 14px; + width: 100%; + cursor: pointer; + z-index: 1; + } + + .seq-participant-box:hover { + background: color-mix(in oklch, var(--diagram-sequence) 8%, var(--node-bg)); + } + + .seq-participant-icon { + font-size: 18px; + flex-shrink: 0; + } + + .seq-participant-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .seq-lifeline { + width: 0; + border-left: 2px dashed var(--diagram-sequence); + opacity: 0.5; + position: absolute; + top: 60px; + left: 50%; + transform: translateX(-50%); + } + + .seq-activation { + position: absolute; + width: 12px; + left: 50%; + transform: translateX(-50%); + background: color-mix(in oklch, var(--diagram-sequence) 20%, var(--node-bg)); + border: 1.5px solid var(--diagram-sequence); + border-radius: 2px; + z-index: 1; + } + + .seq-fragment { + background: color-mix(in oklch, var(--diagram-sequence) 4%, transparent); + border: 1.5px solid var(--diagram-sequence); + border-radius: 4px; + width: 100%; + height: 100%; + position: relative; + } + + .seq-fragment-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-bottom: 1px solid var(--diagram-sequence); + border-right: 1px solid var(--diagram-sequence); + border-bottom-right-radius: 8px; + width: fit-content; + } + + .seq-fragment-type { + font-weight: 700; + font-size: 11px; + color: var(--diagram-sequence); + text-transform: uppercase; + font-family: monospace; + } + + .seq-fragment-guard { + font-size: 11px; + color: var(--muted-foreground); + font-family: monospace; + } + + .seq-edge-label { + font-size: 11px; + color: var(--diagram-sequence); + background: var(--node-bg); + padding: 1px 6px; + border-radius: 4px; + font-family: monospace; + pointer-events: none; + white-space: nowrap; + } + + .seq-edge-label-positioned { + position: absolute; + } + /* ── Path Highlighting ────────────────────────────────────────────────── */ .react-flow__node.dimmed, @@ -242,7 +659,7 @@ } .react-flow__node.highlighted { - filter: drop-shadow(0 0 6px var(--diagram-bpmn)); + filter: drop-shadow(0 0 6px var(--node-selected)); 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 b555e1d..f508f02 100644 --- a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +++ b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx @@ -32,6 +32,21 @@ import { BpmnMessageEdge, BpmnAssociationEdge, } from "../../types/bpmn"; +import { ErEntityNode } from "../../types/er/ErEntityNode"; +import { ErRelationshipEdge } from "../../types/er/ErRelationshipEdge"; +import { OrgchartPersonNode } from "../../types/orgchart/OrgchartPersonNode"; +import { OrgchartHierarchyEdge } from "../../types/orgchart/OrgchartHierarchyEdge"; +import { ArchServiceNode } from "../../types/architecture/ArchServiceNode"; +import { ArchDatabaseNode } from "../../types/architecture/ArchDatabaseNode"; +import { ArchQueueNode } from "../../types/architecture/ArchQueueNode"; +import { ArchLoadBalancerNode } from "../../types/architecture/ArchLoadBalancerNode"; +import { ArchExternalNode } from "../../types/architecture/ArchExternalNode"; +import { ArchConnectionEdge } from "../../types/architecture/ArchConnectionEdge"; +import { SeqParticipantNode } from "../../types/sequence/SeqParticipantNode"; +import { SeqFragmentNode } from "../../types/sequence/SeqFragmentNode"; +import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge"; +import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge"; +import { SeqReturnEdge } from "../../types/sequence/SeqReturnEdge"; const nodeTypes = { bpmnActivity: BpmnActivityNode, @@ -46,21 +61,37 @@ const nodeTypes = { bpmnPool: BpmnPoolNode, bpmnLane: BpmnLaneNode, bpmnGroup: BpmnGroupNode, + erEntity: ErEntityNode, + orgchartPerson: OrgchartPersonNode, + archService: ArchServiceNode, + archDatabase: ArchDatabaseNode, + archQueue: ArchQueueNode, + archLoadBalancer: ArchLoadBalancerNode, + archExternal: ArchExternalNode, + seqParticipant: SeqParticipantNode, + seqFragment: SeqFragmentNode, }; const edgeTypes = { bpmnSequence: BpmnSequenceEdge, bpmnMessage: BpmnMessageEdge, bpmnAssociation: BpmnAssociationEdge, + erRelationship: ErRelationshipEdge, + orgchartHierarchy: OrgchartHierarchyEdge, + archConnection: ArchConnectionEdge, + seqSync: SeqSyncEdge, + seqAsync: SeqAsyncEdge, + seqReturn: SeqReturnEdge, }; /** Container node types that should not participate in BFS highlighting */ -const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup"]); +const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup", "seqFragment"]); -function BpmnMarkerDefs() { +function MarkerDefs() { return ( + {/* BPMN markers */} + {/* E-R markers */} + + + + {/* Architecture markers */} + + + + {/* Sequence markers */} + + + + + + ); @@ -168,7 +260,7 @@ function CanvasInner() { return (
- + s.nodeCount); const setNodes = useGraphStore((s) => s.setNodes); + const setEdges = useGraphStore((s) => s.setEdges); const layoutDirection = useGraphStore((s) => s.layoutDirection); const edgeRouting = useGraphStore((s) => s.edgeRouting); const isLayouting = useGraphStore((s) => s.isLayouting); @@ -53,7 +54,7 @@ export function useAutoLayout() { flowNodes.forEach((el) => el.classList.add("layouting")); try { - const layoutedNodes = await computeLayout( + const result: LayoutResult = await computeLayout( currentNodes, currentEdges, { @@ -67,7 +68,10 @@ export function useAutoLayout() { }, ); - setNodes(layoutedNodes); + setNodes(result.nodes); + if (result.edges) { + setEdges(result.edges); + } // Fit view after layout, with a small delay to let transition run setTimeout(() => { @@ -96,7 +100,7 @@ export function useAutoLayout() { }, LAYOUT_ANIMATION_MS); } }, - [setNodes, setIsLayouting, fitView], + [setNodes, setEdges, setIsLayouting, fitView], ); const triggerLayout = useCallback( diff --git a/apps/web/src/modules/diagram/lib/elk-layout.test.ts b/apps/web/src/modules/diagram/lib/elk-layout.test.ts index bba2587..438d224 100644 --- a/apps/web/src/modules/diagram/lib/elk-layout.test.ts +++ b/apps/web/src/modules/diagram/lib/elk-layout.test.ts @@ -106,6 +106,247 @@ describe("buildElkGraph", () => { expect(elkEdge.targets).toEqual(["n2"]); }); + it("should compute E-R entity height from columns only for erEntity nodes", () => { + const erNode: Node = { + id: "users", + type: "erEntity", + position: { x: 0, y: 0 }, + data: { + id: "users", + type: "er:entity", + label: "Users", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "name", type: "text" }, + { name: "email", type: "varchar" }, + ], + }, + }; + const regularNode = createNode("n1"); + const result = buildElkGraph([erNode, regularNode], []); + + // E-R entity: headerH(36) + 3*rowH(24) + paddingY(8)*2 = 124 + expect(result.children?.[0]?.height).toBe(124); + // Regular node: default height (50) + expect(result.children?.[1]?.height).toBe(50); + }); + + it("should build correct ELK graph for M:N junction table scenario (AC #3)", () => { + const students: Node = { + id: "students", + type: "erEntity", + position: { x: 0, y: 0 }, + data: { + id: "students", + type: "er:entity", + label: "Students", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "name", type: "text" }, + ], + }, + }; + const courses: Node = { + id: "courses", + type: "erEntity", + position: { x: 0, y: 0 }, + data: { + id: "courses", + type: "er:entity", + label: "Courses", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "title", type: "text" }, + ], + }, + }; + const enrollments: Node = { + id: "enrollments", + type: "erEntity", + position: { x: 0, y: 0 }, + data: { + id: "enrollments", + type: "er:entity", + label: "Enrollments", + columns: [ + { name: "student_id", type: "uuid", isForeignKey: true }, + { name: "course_id", type: "uuid", isForeignKey: true }, + ], + }, + }; + const edges: Edge[] = [ + { id: "e1", source: "students", target: "enrollments", type: "erRelationship", data: {} }, + { id: "e2", source: "courses", target: "enrollments", type: "erRelationship", data: {} }, + ]; + + const result = buildElkGraph([students, courses, enrollments], edges); + + // All 3 entities as flat children (no hierarchy) + expect(result.children).toHaveLength(3); + // Junction table has correct height: headerH(36) + 2*rowH(24) + paddingY(8)*2 = 100 + const junctionChild = result.children?.find((c) => c.id === "enrollments"); + expect(junctionChild?.height).toBe(100); + // 2 edges connecting to junction table + expect(result.edges).toHaveLength(2); + // No nested children (flat layout, not compound) + expect(result.children?.every((c) => !("children" in c && (c as { children?: unknown[] }).children?.length))).toBe(true); + }); + + it("should NOT use E-R height for non-erEntity nodes with columns property", () => { + const nodeWithColumns: Node = { + id: "n1", + type: "default", + position: { x: 0, y: 0 }, + data: { + id: "n1", + type: "flow:process", + label: "Not E-R", + columns: [{ name: "a", type: "text" }], + }, + }; + const result = buildElkGraph([nodeWithColumns], []); + // Should use default height (50), NOT E-R computed height + expect(result.children?.[0]?.height).toBe(50); + }); + + it("should use OC_SIZES for orgchartPerson nodes", () => { + const ocNode: Node = { + id: "ceo", + type: "orgchartPerson", + position: { x: 0, y: 0 }, + data: { + id: "ceo", + type: "org:person", + label: "Alice", + tag: "CEO", + }, + }; + const regularNode = createNode("n1"); + const result = buildElkGraph([ocNode, regularNode], []); + + // Org chart person: fixed w=280, h=80 + expect(result.children?.[0]?.width).toBe(280); + expect(result.children?.[0]?.height).toBe(80); + // Regular node: default dimensions + expect(result.children?.[1]?.width).toBe(150); + expect(result.children?.[1]?.height).toBe(50); + }); + + it("should respect data.w override for orgchartPerson nodes", () => { + const ocNode: Node = { + id: "ceo", + type: "orgchartPerson", + position: { x: 0, y: 0 }, + data: { + id: "ceo", + type: "org:person", + label: "Alice", + tag: "CEO", + w: 320, + }, + }; + const result = buildElkGraph([ocNode], []); + expect(result.children?.[0]?.width).toBe(320); + expect(result.children?.[0]?.height).toBe(80); + }); + + it("should use ARCH_SIZES for architecture node subtypes", () => { + const archNodes: Node[] = [ + { + id: "api", + type: "archService", + position: { x: 0, y: 0 }, + data: { id: "api", type: "arch:service", label: "API Gateway" }, + }, + { + id: "db", + type: "archDatabase", + position: { x: 0, y: 0 }, + data: { id: "db", type: "arch:database", label: "PostgreSQL" }, + }, + { + id: "q", + type: "archQueue", + position: { x: 0, y: 0 }, + data: { id: "q", type: "arch:queue", label: "SQS" }, + }, + { + id: "lb", + type: "archLoadBalancer", + position: { x: 0, y: 0 }, + data: { id: "lb", type: "arch:loadbalancer", label: "ALB" }, + }, + { + id: "ext", + type: "archExternal", + position: { x: 0, y: 0 }, + data: { id: "ext", type: "arch:external", label: "Stripe" }, + }, + ]; + + const result = buildElkGraph(archNodes, []); + + // archService: w=200, h=80 + expect(result.children?.[0]?.width).toBe(200); + expect(result.children?.[0]?.height).toBe(80); + // archDatabase: w=160, h=100 + expect(result.children?.[1]?.width).toBe(160); + expect(result.children?.[1]?.height).toBe(100); + // archQueue: w=180, h=70 + expect(result.children?.[2]?.width).toBe(180); + expect(result.children?.[2]?.height).toBe(70); + // archLoadBalancer: w=120, h=120 + expect(result.children?.[3]?.width).toBe(120); + expect(result.children?.[3]?.height).toBe(120); + // archExternal: w=180, h=80 + expect(result.children?.[4]?.width).toBe(180); + expect(result.children?.[4]?.height).toBe(80); + }); + + it("should respect data.w override for architecture nodes", () => { + const archNode: Node = { + id: "api", + type: "archService", + position: { x: 0, y: 0 }, + data: { id: "api", type: "arch:service", label: "Wide Service", w: 300 }, + }; + const result = buildElkGraph([archNode], []); + expect(result.children?.[0]?.width).toBe(300); + expect(result.children?.[0]?.height).toBe(80); + }); + + it("should use SEQ_SIZES for seqParticipant nodes", () => { + const seqNode: Node = { + id: "client", + type: "seqParticipant", + position: { x: 0, y: 0 }, + data: { id: "client", type: "seq:participant", label: "Client", lifeline: true }, + }; + const regularNode = createNode("n1"); + const result = buildElkGraph([seqNode, regularNode], []); + + // seqParticipant: w=160, h=60 + expect(result.children?.[0]?.width).toBe(160); + expect(result.children?.[0]?.height).toBe(60); + // Regular node: default dimensions + expect(result.children?.[1]?.width).toBe(150); + expect(result.children?.[1]?.height).toBe(50); + }); + + it("should return null size for seqFragment (dynamically computed)", () => { + const fragNode: Node = { + id: "alt-1", + type: "seqFragment", + position: { x: 0, y: 0 }, + data: { id: "alt-1", type: "seq:fragment", label: "alt" }, + }; + const result = buildElkGraph([fragNode], []); + + // seqFragment returns null from getSeqNodeSize → falls through to default + expect(result.children?.[0]?.width).toBe(150); + expect(result.children?.[0]?.height).toBe(50); + }); + it("should handle empty nodes and edges", () => { const result = buildElkGraph([], []); diff --git a/apps/web/src/modules/diagram/lib/elk-layout.ts b/apps/web/src/modules/diagram/lib/elk-layout.ts index a907a39..406d061 100644 --- a/apps/web/src/modules/diagram/lib/elk-layout.ts +++ b/apps/web/src/modules/diagram/lib/elk-layout.ts @@ -8,6 +8,11 @@ import { resolveBpmnPositions, applyBpmnPositions, } from "./bpmn-layout"; +import { getErEntityHeight } from "../types/er/constants"; +import { OC_SIZES } from "../types/orgchart/constants"; +import { getArchNodeSize } from "../types/architecture/constants"; +import { getSeqNodeSize } from "../types/sequence/constants"; +import { computeSequenceLayout } from "./sequence-layout"; // ── Layout Options ────────────────────────────────────────────────────────── @@ -56,10 +61,35 @@ export function buildElkGraph( }, children: nodes.map((node) => { const data = node.data as unknown as DiagramNode; + // E-R entities: compute height from columns for correct ELK spacing + const isErEntity = node.type === "erEntity"; + // Org chart persons: fixed dimensions + const isOcPerson = node.type === "orgchartPerson"; + // Architecture nodes: per-subtype dimensions + const archSize = getArchNodeSize(node.type); + // Sequence nodes: per-subtype dimensions + const seqSize = getSeqNodeSize(node.type); + const height = + isErEntity && data.columns + ? getErEntityHeight(data.columns) + : isOcPerson + ? OC_SIZES.person.h + : archSize + ? archSize.h + : seqSize + ? seqSize.h + : (node.measured?.height ?? DEFAULT_NODE_HEIGHT); + const width = archSize + ? (data.w ?? archSize.w) + : seqSize + ? (data.w ?? seqSize.w) + : isOcPerson + ? (data.w ?? OC_SIZES.person.w) + : (data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH); return { id: node.id, - width: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH, - height: node.measured?.height ?? DEFAULT_NODE_HEIGHT, + width, + height, }; }), edges: edges.map( @@ -179,14 +209,19 @@ function flowToBpmnGraphData( // ── Main Layout Function ──────────────────────────────────────────────────── +export interface LayoutResult { + nodes: Node[]; + edges?: Edge[]; +} + export function computeLayout( nodes: Node[], edges: Edge[], options: Partial = {}, -): Promise { +): Promise { return new Promise((resolve, reject) => { if (nodes.length === 0) { - resolve(nodes); + resolve({ nodes }); return; } @@ -196,6 +231,14 @@ export function computeLayout( pendingReject = null; } + // Detect sequence diagram — uses custom synchronous layout, not ELK + const isSequence = nodes.some((n) => n.type === "seqParticipant"); + if (isSequence) { + const result = computeSequenceLayout(nodes, edges); + resolve({ nodes: result.nodes, edges: result.edges }); + return; + } + // Detect BPMN compound layout (pools present in @xyflow nodes) const isCompound = nodes.some((n) => n.type === "bpmnPool"); @@ -229,9 +272,9 @@ export function computeLayout( if (event.data.type === "result" && event.data.graph) { if (isCompound) { const positions = resolveBpmnPositions(event.data.graph); - resolve(applyBpmnPositions(positions, nodes)); + resolve({ nodes: applyBpmnPositions(positions, nodes) }); } else { - resolve(resolvePositions(event.data.graph, nodes)); + resolve({ nodes: 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 8374669..285ec09 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.test.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.test.ts @@ -124,7 +124,7 @@ describe("graphToFlow", () => { const diagramTypes = [ "bpmn:activity", "er:entity", - "org:unit", + "org:person", "arch:service", "seq:participant", "flow:process", @@ -138,11 +138,332 @@ describe("graphToFlow", () => { expect(result.nodes).toHaveLength(6); // 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"); + // er: prefix resolves to E-R node type even without diagramType context + expect(result.nodes[1]!.type).toBe("erEntity"); + // org: prefix resolves to org chart node type even without diagramType context + expect(result.nodes[2]!.type).toBe("orgchartPerson"); + // arch: prefix resolves to architecture node type even without diagramType context + expect(result.nodes[3]!.type).toBe("archService"); + // seq: prefix resolves to sequence node type even without diagramType context + expect(result.nodes[4]!.type).toBe("seqParticipant"); + // Non-prefixed types without diagramType context stay default expect(result.nodes[5]!.type).toBe("default"); }); + it("should resolve architecture node types when diagramType is architecture", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Architecture Test", + diagramType: "architecture", + }, + nodes: [ + { id: "n1", type: "service", label: "API" }, + { id: "n2", type: "arch:database", label: "DB" }, + { id: "n3", type: "arch:queue", label: "Queue" }, + { id: "n4", type: "arch:loadbalancer", label: "LB" }, + { id: "n5", type: "arch:external", label: "Stripe" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("archService"); + expect(result.nodes[1]!.type).toBe("archDatabase"); + expect(result.nodes[2]!.type).toBe("archQueue"); + expect(result.nodes[3]!.type).toBe("archLoadBalancer"); + expect(result.nodes[4]!.type).toBe("archExternal"); + }); + + it("should resolve architecture edge types when diagramType is architecture", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Architecture Edge Test", + diagramType: "architecture", + }, + nodes: [ + { id: "svc", type: "arch:service", label: "API" }, + { id: "db", type: "arch:database", label: "DB" }, + ], + edges: [ + { id: "e1", from: "svc", to: "db", type: "sync", label: "PostgreSQL" }, + { id: "e2", from: "svc", to: "db", type: "async", label: "AMQP" }, + { id: "e3", from: "svc", to: "db" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("archConnection"); + expect(result.edges[1]!.type).toBe("archConnection"); + expect(result.edges[2]!.type).toBe("archConnection"); + }); + + it("should use flat layout for architecture diagrams (no container nodes)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Architecture Flat Layout", + diagramType: "architecture", + }, + nodes: [ + { id: "lb", type: "arch:loadbalancer", label: "LB" }, + { id: "svc1", type: "arch:service", label: "Service 1" }, + { id: "svc2", type: "arch:service", label: "Service 2" }, + { id: "db", type: "arch:database", label: "DB" }, + ], + edges: [ + { id: "e1", from: "lb", to: "svc1", type: "sync", label: "HTTP" }, + { id: "e2", from: "lb", to: "svc2", type: "sync", label: "HTTP" }, + { id: "e3", from: "svc1", to: "db", type: "sync", label: "PostgreSQL" }, + ], + }; + const result = graphToFlow(data); + expect(result.nodes).toHaveLength(4); + expect(result.edges).toHaveLength(3); + }); + + it("should resolve E-R node types when diagramType is er", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "E-R Test", + diagramType: "er", + }, + nodes: [ + { id: "n1", type: "entity", label: "Users" }, + { id: "n2", type: "er:entity", label: "Orders" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("erEntity"); + expect(result.nodes[1]!.type).toBe("erEntity"); + }); + + it("should resolve E-R edge types when diagramType is er", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "E-R Edge Test", + diagramType: "er", + }, + nodes: [ + { id: "users", type: "er:entity", label: "Users" }, + { id: "orders", type: "er:entity", label: "Orders" }, + ], + edges: [ + { id: "e1", from: "users", to: "orders", type: "relationship", cardinality: "1:N" }, + { id: "e2", from: "users", to: "orders" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("erRelationship"); + expect(result.edges[1]!.type).toBe("erRelationship"); + expect(result.edges[0]!.data?.cardinality).toBe("1:N"); + }); + + it("should use flat layout for E-R diagrams (no container nodes)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "E-R Flat Layout", + diagramType: "er", + }, + nodes: [ + { id: "users", type: "er:entity", label: "Users", columns: [{ name: "id", type: "uuid", isPrimaryKey: true }] }, + { id: "orders", type: "er:entity", label: "Orders" }, + ], + edges: [{ id: "e1", from: "users", to: "orders", cardinality: "1:N" }], + }; + const result = graphToFlow(data); + // E-R uses flat layout — no pool/lane/group container nodes + expect(result.nodes).toHaveLength(2); + expect(result.nodes.every((n) => n.type === "erEntity")).toBe(true); + }); + + it("should produce correct flat structure for E-R M:N with junction table (AC #3)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "M:N Junction Table", + diagramType: "er", + }, + nodes: [ + { + id: "students", + type: "er:entity", + label: "Students", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "name", type: "text" }, + ], + }, + { + id: "courses", + type: "er:entity", + label: "Courses", + columns: [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "title", type: "text" }, + ], + }, + { + id: "enrollments", + type: "er:entity", + label: "Enrollments", + columns: [ + { name: "student_id", type: "uuid", isForeignKey: true }, + { name: "course_id", type: "uuid", isForeignKey: true }, + ], + }, + ], + edges: [ + { id: "e1", from: "students", to: "enrollments", cardinality: "1:N" }, + { id: "e2", from: "courses", to: "enrollments", cardinality: "1:N" }, + ], + }; + const result = graphToFlow(data); + // All 3 entities are flat nodes (no container hierarchy) + expect(result.nodes).toHaveLength(3); + expect(result.nodes.every((n) => n.type === "erEntity")).toBe(true); + // Junction table has edges connecting it to both parent entities + expect(result.edges).toHaveLength(2); + expect(result.edges[0]!.type).toBe("erRelationship"); + expect(result.edges[1]!.type).toBe("erRelationship"); + // Junction table node preserves FK columns for ELK height computation + const junctionNode = result.nodes.find((n) => n.id === "enrollments"); + expect((junctionNode!.data as unknown as DiagramNode).columns).toHaveLength(2); + }); + + it("should resolve org chart node types when diagramType is orgchart", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Org Chart Test", + diagramType: "orgchart", + }, + nodes: [ + { id: "n1", type: "person", label: "Alice", tag: "CEO" }, + { id: "n2", type: "org:person", label: "Bob", tag: "CTO" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("orgchartPerson"); + expect(result.nodes[1]!.type).toBe("orgchartPerson"); + }); + + it("should resolve org chart edge types when diagramType is orgchart", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Org Chart Edge Test", + diagramType: "orgchart", + }, + nodes: [ + { id: "ceo", type: "org:person", label: "Alice", tag: "CEO" }, + { id: "cto", type: "org:person", label: "Bob", tag: "CTO" }, + ], + edges: [ + { id: "e1", from: "ceo", to: "cto", type: "hierarchy" }, + { id: "e2", from: "ceo", to: "cto" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("orgchartHierarchy"); + expect(result.edges[1]!.type).toBe("orgchartHierarchy"); + }); + + it("should resolve sequence node types when diagramType is sequence", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Sequence Test", + diagramType: "sequence", + }, + nodes: [ + { id: "n1", type: "participant", label: "Client" }, + { id: "n2", type: "seq:participant", label: "Server" }, + { id: "n3", type: "seq:fragment", label: "alt" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("seqParticipant"); + expect(result.nodes[1]!.type).toBe("seqParticipant"); + expect(result.nodes[2]!.type).toBe("seqFragment"); + }); + + it("should resolve sequence edge types when diagramType is sequence", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Sequence Edge Test", + diagramType: "sequence", + }, + nodes: [ + { id: "client", type: "seq:participant", label: "Client" }, + { id: "server", type: "seq:participant", label: "Server" }, + ], + edges: [ + { id: "e1", from: "client", to: "server", type: "sync", label: "POST /login" }, + { id: "e2", from: "server", to: "client", type: "async", label: "enqueue" }, + { id: "e3", from: "server", to: "client", type: "return", label: "200 OK" }, + { id: "e4", from: "client", to: "server" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("seqSync"); + expect(result.edges[1]!.type).toBe("seqAsync"); + expect(result.edges[2]!.type).toBe("seqReturn"); + expect(result.edges[3]!.type).toBe("seqSync"); // default + }); + + it("should use flat layout for sequence diagrams (no container nodes)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Sequence Flat Layout", + diagramType: "sequence", + }, + nodes: [ + { id: "client", type: "seq:participant", label: "Client", lifeline: true }, + { id: "server", type: "seq:participant", label: "Server", lifeline: true }, + { id: "alt-1", type: "seq:fragment", label: "alt", tag: "[valid]", group: "m2" }, + ], + edges: [ + { id: "m1", from: "client", to: "server", type: "sync", label: "request" }, + { id: "m2", from: "server", to: "client", type: "return", label: "response" }, + ], + }; + const result = graphToFlow(data); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + }); + + it("should use flat layout for org chart diagrams (no container nodes)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Org Chart Flat Layout", + diagramType: "orgchart", + }, + nodes: [ + { id: "ceo", type: "org:person", label: "Alice", tag: "CEO", group: "Executive" }, + { id: "cto", type: "org:person", label: "Bob", tag: "CTO", group: "Technology" }, + { id: "dev", type: "org:person", label: "Carol", tag: "Developer", group: "Technology" }, + ], + edges: [ + { id: "e1", from: "ceo", to: "cto" }, + { id: "e2", from: "cto", to: "dev" }, + ], + }; + const result = graphToFlow(data); + // Org chart uses flat layout — no pool/lane/group container nodes + expect(result.nodes).toHaveLength(3); + expect(result.nodes.every((n) => n.type === "orgchartPerson")).toBe(true); + expect(result.edges).toHaveLength(2); + }); + it("should resolve BPMN node types when diagramType is bpmn", () => { const data: GraphData = { meta: { diff --git a/apps/web/src/modules/diagram/lib/graph-converter.ts b/apps/web/src/modules/diagram/lib/graph-converter.ts index f7bba30..e12c07b 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.ts @@ -9,6 +9,22 @@ import { resolveBpmnNodeType, resolveBpmnEdgeType, } from "../types/bpmn/constants"; +import { + resolveErNodeType, + resolveErEdgeType, +} from "../types/er/constants"; +import { + resolveOrgchartNodeType, + resolveOrgchartEdgeType, +} from "../types/orgchart/constants"; +import { + resolveArchitectureNodeType, + resolveArchitectureEdgeType, +} from "../types/architecture/constants"; +import { + resolveSequenceNodeType, + resolveSequenceEdgeType, +} from "../types/sequence/constants"; // ── Node Type Resolution ─────────────────────────────────────────────────── @@ -19,7 +35,19 @@ function resolveFlowNodeType( if (diagramType === "bpmn" || nodeType.startsWith("bpmn:")) { return resolveBpmnNodeType(nodeType); } - // Future: er, orgchart, architecture, sequence, flowchart + if (diagramType === "er" || nodeType.startsWith("er:")) { + return resolveErNodeType(nodeType); + } + if (diagramType === "orgchart" || nodeType.startsWith("org:")) { + return resolveOrgchartNodeType(nodeType); + } + if (diagramType === "architecture" || nodeType.startsWith("arch:")) { + return resolveArchitectureNodeType(nodeType); + } + if (diagramType === "sequence" || nodeType.startsWith("seq:")) { + return resolveSequenceNodeType(nodeType); + } + // Future: flowchart return "default"; } @@ -30,6 +58,18 @@ function resolveFlowEdgeType( if (diagramType === "bpmn") { return resolveBpmnEdgeType(edgeType); } + if (diagramType === "er") { + return resolveErEdgeType(edgeType); + } + if (diagramType === "orgchart") { + return resolveOrgchartEdgeType(edgeType); + } + if (diagramType === "architecture") { + return resolveArchitectureEdgeType(edgeType); + } + if (diagramType === "sequence") { + return resolveSequenceEdgeType(edgeType); + } return "default"; } diff --git a/apps/web/src/modules/diagram/lib/sequence-layout.test.ts b/apps/web/src/modules/diagram/lib/sequence-layout.test.ts new file mode 100644 index 0000000..a174cdd --- /dev/null +++ b/apps/web/src/modules/diagram/lib/sequence-layout.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from "vitest"; +import type { Node, Edge } from "@xyflow/react"; +import { computeSequenceLayout } from "./sequence-layout"; +import { SEQ_LAYOUT, SEQ_SIZES } from "../types/sequence/constants"; + +function createParticipant(id: string, label: string): Node { + return { + id, + type: "seqParticipant", + position: { x: 0, y: 0 }, + data: { id, type: "seq:participant", label, lifeline: true }, + }; +} + +function createFragment(id: string, label: string, group: string, tag?: string): Node { + return { + id, + type: "seqFragment", + position: { x: 0, y: 0 }, + data: { id, type: "seq:fragment", label, group, ...(tag ? { tag } : {}) }, + }; +} + +function createMessage(id: string, source: string, target: string, type = "seqSync"): Edge { + return { + id, + source, + target, + type, + data: { id, from: source, to: target, type: type === "seqSync" ? "sync" : type === "seqAsync" ? "async" : type === "seqReturn" ? "return" : "sync" }, + }; +} + +describe("computeSequenceLayout", () => { + it("should position participants horizontally with correct spacing", () => { + const nodes = [ + createParticipant("client", "Client"), + createParticipant("server", "Server"), + createParticipant("db", "Database"), + ]; + const result = computeSequenceLayout(nodes, []); + + expect(result.nodes[0]!.position).toEqual({ x: 0, y: 0 }); + expect(result.nodes[1]!.position).toEqual({ x: SEQ_LAYOUT.participantSpacing, y: 0 }); + expect(result.nodes[2]!.position).toEqual({ x: 2 * SEQ_LAYOUT.participantSpacing, y: 0 }); + }); + + it("should compute lifeline height from message count", () => { + const nodes = [createParticipant("a", "A"), createParticipant("b", "B")]; + const edges = [ + createMessage("m1", "a", "b"), + createMessage("m2", "b", "a"), + createMessage("m3", "a", "b"), + ]; + const result = computeSequenceLayout(nodes, edges); + + const expectedHeight = + SEQ_LAYOUT.messageStartY + 3 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.lifelinePadding; + const participant = result.nodes[0]!; + expect((participant.data as Record).lifelineHeight).toBe(expectedHeight); + expect(participant.style).toEqual(expect.objectContaining({ height: expectedHeight })); + }); + + it("should enrich edges with order and messageY", () => { + const nodes = [createParticipant("a", "A"), createParticipant("b", "B")]; + const edges = [ + createMessage("m1", "a", "b"), + createMessage("m2", "b", "a"), + ]; + const result = computeSequenceLayout(nodes, edges); + + const e0 = result.edges[0]!.data as Record; + expect(e0.order).toBe(0); + expect(e0.messageY).toBe(SEQ_LAYOUT.messageStartY); + + const e1 = result.edges[1]!.data as Record; + expect(e1.order).toBe(1); + expect(e1.messageY).toBe(SEQ_LAYOUT.messageStartY + SEQ_LAYOUT.messageSpacing); + }); + + it("should position fragment around its messages", () => { + const nodes = [ + createParticipant("client", "Client"), + createParticipant("server", "Server"), + createFragment("alt-1", "alt", "m2,m3", "[valid]"), + ]; + const edges = [ + createMessage("m1", "client", "server"), + createMessage("m2", "server", "client"), + createMessage("m3", "client", "server"), + ]; + const result = computeSequenceLayout(nodes, edges); + + // Fragment should encompass messages m2 (index 1) and m3 (index 2) + const frag = result.nodes.find((n) => n.id === "alt-1")!; + const expectedTopY = + SEQ_LAYOUT.messageStartY + 1 * SEQ_LAYOUT.messageSpacing - SEQ_LAYOUT.fragmentPadding; + const expectedBottomY = + SEQ_LAYOUT.messageStartY + 2 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.fragmentPadding; + + expect(frag.position.y).toBe(expectedTopY); + expect((frag.style as { height: number }).height).toBe(expectedBottomY - expectedTopY); + + // Fragment X spans from leftmost to rightmost participant + width + padding + const leftX = 0 - SEQ_LAYOUT.fragmentPadding; + const rightX = SEQ_LAYOUT.participantSpacing + SEQ_SIZES.participant.w + SEQ_LAYOUT.fragmentPadding; + expect(frag.position.x).toBe(leftX); + expect((frag.style as { width: number }).width).toBe(rightX - leftX); + }); + + it("should handle empty edges", () => { + const nodes = [createParticipant("a", "A")]; + const result = computeSequenceLayout(nodes, []); + + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + const expectedHeight = + SEQ_LAYOUT.messageStartY + 0 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.lifelinePadding; + expect((result.nodes[0]!.data as Record).lifelineHeight).toBe(expectedHeight); + }); + + it("should handle fragment with no matching message IDs", () => { + const nodes = [ + createParticipant("a", "A"), + createFragment("frag-1", "loop", "nonexistent"), + ]; + const edges = [createMessage("m1", "a", "a")]; + const result = computeSequenceLayout(nodes, edges); + + // Fragment with no matching messages should keep original position + const frag = result.nodes.find((n) => n.id === "frag-1")!; + expect(frag.position).toEqual({ x: 0, y: 0 }); + }); + + it("should return participants before fragments in node order", () => { + const nodes = [ + createParticipant("a", "A"), + createFragment("f1", "opt", "m1"), + createParticipant("b", "B"), + ]; + const edges = [createMessage("m1", "a", "b")]; + const result = computeSequenceLayout(nodes, edges); + + // Participants come first, then fragments + expect(result.nodes[0]!.type).toBe("seqParticipant"); + expect(result.nodes[1]!.type).toBe("seqParticipant"); + expect(result.nodes[2]!.type).toBe("seqFragment"); + }); + + it("should NOT mutate input nodes", () => { + const nodes = [ + createParticipant("a", "A"), + createParticipant("b", "B"), + ]; + const originalPos0 = { ...nodes[0]!.position }; + const originalPos1 = { ...nodes[1]!.position }; + + computeSequenceLayout(nodes, [createMessage("m1", "a", "b")]); + + // Original nodes should be untouched + expect(nodes[0]!.position).toEqual(originalPos0); + expect(nodes[1]!.position).toEqual(originalPos1); + expect((nodes[0]!.data as Record).lifelineHeight).toBeUndefined(); + }); + + it("should NOT mutate input edges", () => { + const edges = [createMessage("m1", "a", "b")]; + const originalData = { ...edges[0]!.data }; + + computeSequenceLayout( + [createParticipant("a", "A"), createParticipant("b", "B")], + edges, + ); + + // Original edge data should not have order/messageY + expect((edges[0]!.data as Record).order).toBeUndefined(); + expect((edges[0]!.data as Record).messageY).toBeUndefined(); + }); + + it("should compute activation bars for sync/return message pairs", () => { + const nodes = [ + createParticipant("client", "Client"), + createParticipant("server", "Server"), + ]; + // sync from client→server (opens activation on server), return from server→client (closes it) + const edges = [ + createMessage("m1", "client", "server", "seqSync"), + createMessage("m2", "server", "client", "seqReturn"), + ]; + const result = computeSequenceLayout(nodes, edges); + + // Server should have one activation bar + const server = result.nodes.find((n) => n.id === "server")!; + const serverData = server.data as Record; + const activations = serverData.activations as { y: number; height: number }[]; + expect(activations).toHaveLength(1); + + // Activation starts at m1's Y, ends at m2's Y + const m1Y = SEQ_LAYOUT.messageStartY + 0 * SEQ_LAYOUT.messageSpacing; + const m2Y = SEQ_LAYOUT.messageStartY + 1 * SEQ_LAYOUT.messageSpacing; + expect(activations[0]!.y).toBe(m1Y); + expect(activations[0]!.height).toBe(m2Y - m1Y); + }); + + it("should close open activations at lifeline end", () => { + const nodes = [ + createParticipant("client", "Client"), + createParticipant("server", "Server"), + ]; + // sync without a matching return — activation stays open + const edges = [ + createMessage("m1", "client", "server", "seqSync"), + ]; + const result = computeSequenceLayout(nodes, edges); + + const server = result.nodes.find((n) => n.id === "server")!; + const activations = (server.data as Record).activations as { y: number; height: number }[]; + expect(activations).toHaveLength(1); + // Should extend to near the end of the lifeline + expect(activations[0]!.y).toBe(SEQ_LAYOUT.messageStartY); + const lifelineEnd = + SEQ_LAYOUT.messageStartY + + edges.length * SEQ_LAYOUT.messageSpacing; // lifelineHeight - padding + expect(activations[0]!.height).toBe(lifelineEnd - SEQ_LAYOUT.messageStartY); + }); + + it("should have empty activations for participants with no incoming sync messages", () => { + const nodes = [ + createParticipant("a", "A"), + createParticipant("b", "B"), + ]; + const edges: Edge[] = []; + const result = computeSequenceLayout(nodes, edges); + + for (const node of result.nodes) { + if (node.type === "seqParticipant") { + const activations = (node.data as Record).activations as { y: number; height: number }[]; + expect(activations).toEqual([]); + } + } + }); +}); diff --git a/apps/web/src/modules/diagram/lib/sequence-layout.ts b/apps/web/src/modules/diagram/lib/sequence-layout.ts new file mode 100644 index 0000000..a631e8f --- /dev/null +++ b/apps/web/src/modules/diagram/lib/sequence-layout.ts @@ -0,0 +1,165 @@ +import type { Node, Edge } from "@xyflow/react"; +import { SEQ_SIZES, SEQ_LAYOUT } from "../types/sequence/constants"; + +/** + * Custom layout for sequence diagrams. + * Positions participants horizontally, computes message Y from edge order, + * sizes participant nodes to full lifeline height, computes activation bars, + * and positions fragments around their message ranges. + * + * Returns new node/edge objects — does NOT mutate inputs. + * + * NOTE: This is synchronous — sequence diagrams are small (no Web Worker needed). + */ +export function computeSequenceLayout( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + const participants = nodes.filter((n) => n.type === "seqParticipant"); + const fragments = nodes.filter((n) => n.type === "seqFragment"); + + // 1. Position participants horizontally (immutable — create new objects) + const positionedParticipants = participants.map((p, i) => ({ + ...p, + position: { x: i * SEQ_LAYOUT.participantSpacing, y: 0 }, + })); + + // 2. Compute lifeline height from message count + const lifelineHeight = + SEQ_LAYOUT.messageStartY + + edges.length * SEQ_LAYOUT.messageSpacing + + SEQ_LAYOUT.lifelinePadding; + + // 3. Enrich edges with order and messageY (immutable) + const enrichedEdges = edges.map((edge, i) => ({ + ...edge, + data: { + ...edge.data, + order: i, + messageY: SEQ_LAYOUT.messageStartY + i * SEQ_LAYOUT.messageSpacing, + }, + })); + + // 4. Compute activation bars per participant + // Activation: starts when a sync message arrives (target), ends when a return is sent (source) + const activationsMap = new Map< + string, + { y: number; height: number }[] + >(); + for (const p of positionedParticipants) { + activationsMap.set(p.id, []); + } + + // Track open activations per participant: stack of start Y values + const openActivations = new Map(); + for (const p of positionedParticipants) { + openActivations.set(p.id, []); + } + + for (const edge of enrichedEdges) { + const edgeData = edge.data as Record; + const msgY = edgeData.messageY as number; + const originalData = (edge.data as Record | undefined); + const edgeType = (originalData?.type as string) ?? "sync"; + + if (edgeType === "sync" || edgeType === "async") { + // Open activation on the target participant when sync/async message received + const targetStack = openActivations.get(edge.target); + if (targetStack) { + targetStack.push(msgY); + } + } else if (edgeType === "return") { + // Close activation on the source participant when return is sent + const sourceStack = openActivations.get(edge.source); + if (sourceStack && sourceStack.length > 0) { + const startY = sourceStack.pop()!; + const acts = activationsMap.get(edge.source) ?? []; + acts.push({ y: startY, height: msgY - startY }); + activationsMap.set(edge.source, acts); + } + } + } + + // Close any remaining open activations at the end of the lifeline + for (const [participantId, stack] of openActivations) { + const acts = activationsMap.get(participantId) ?? []; + for (const startY of stack) { + acts.push({ + y: startY, + height: lifelineHeight - SEQ_LAYOUT.lifelinePadding - startY, + }); + } + activationsMap.set(participantId, acts); + } + + // 5. Set participant height and activation data (immutable) + const enrichedParticipants = positionedParticipants.map((p) => ({ + ...p, + style: { ...p.style, height: lifelineHeight }, + data: { + ...p.data, + lifelineHeight, + activations: activationsMap.get(p.id) ?? [], + }, + })); + + // 6. Build participant X lookup map + const participantXMap = new Map( + enrichedParticipants.map((p) => [p.id, p.position.x]), + ); + + // 7. Position fragment nodes around their messages (immutable) + const positionedFragments = fragments.map((frag) => { + const fragData = frag.data as Record; + const messageIds = ((fragData.group as string) || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const messageIndices = messageIds + .map((id) => enrichedEdges.findIndex((e) => e.id === id)) + .filter((i) => i >= 0); + + if (messageIndices.length === 0) { + return { ...frag }; + } + + const minIdx = Math.min(...messageIndices); + const maxIdx = Math.max(...messageIndices); + const topY = + SEQ_LAYOUT.messageStartY + + minIdx * SEQ_LAYOUT.messageSpacing - + SEQ_LAYOUT.fragmentPadding; + const bottomY = + SEQ_LAYOUT.messageStartY + + maxIdx * SEQ_LAYOUT.messageSpacing + + SEQ_LAYOUT.fragmentPadding; + + // Find leftmost and rightmost participants involved in fragment messages + const involvedEdges = messageIndices.map((i) => enrichedEdges[i]!); + const allParticipantIds = new Set( + involvedEdges.flatMap((e) => [e.source, e.target]), + ); + const xPositions = [...allParticipantIds].map( + (id) => participantXMap.get(id) ?? 0, + ); + const minX = Math.min(...xPositions) - SEQ_LAYOUT.fragmentPadding; + const maxX = + Math.max(...xPositions) + + SEQ_SIZES.participant.w + + SEQ_LAYOUT.fragmentPadding; + + return { + ...frag, + position: { x: minX, y: topY }, + style: { + width: maxX - minX, + height: bottomY - topY, + }, + }; + }); + + return { + nodes: [...enrichedParticipants, ...positionedFragments], + edges: enrichedEdges, + }; +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchConnectionEdge.tsx b/apps/web/src/modules/diagram/types/architecture/ArchConnectionEdge.tsx new file mode 100644 index 0000000..f77cff3 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchConnectionEdge.tsx @@ -0,0 +1,43 @@ +import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function ArchConnectionEdge(props: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + }); + + const edgeData = props.data as Record | undefined; + const isAsync = edgeData?.type === "async"; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchDatabaseNode.tsx b/apps/web/src/modules/diagram/types/architecture/ArchDatabaseNode.tsx new file mode 100644 index 0000000..20ff36c --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchDatabaseNode.tsx @@ -0,0 +1,25 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "./constants"; + +export function ArchDatabaseNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "🗄️"; + const metadata = d.tag; + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchExternalNode.tsx b/apps/web/src/modules/diagram/types/architecture/ArchExternalNode.tsx new file mode 100644 index 0000000..d797132 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchExternalNode.tsx @@ -0,0 +1,25 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "./constants"; + +export function ArchExternalNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "☁️"; + const metadata = d.tag; + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchLoadBalancerNode.tsx b/apps/web/src/modules/diagram/types/architecture/ArchLoadBalancerNode.tsx new file mode 100644 index 0000000..fb2a8e1 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchLoadBalancerNode.tsx @@ -0,0 +1,25 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "./constants"; + +export function ArchLoadBalancerNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "⚖️"; + const metadata = d.tag; + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchQueueNode.tsx b/apps/web/src/modules/diagram/types/architecture/ArchQueueNode.tsx new file mode 100644 index 0000000..a2b3860 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchQueueNode.tsx @@ -0,0 +1,25 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "./constants"; + +export function ArchQueueNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "📨"; + const metadata = d.tag; + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/ArchServiceNode.tsx b/apps/web/src/modules/diagram/types/architecture/ArchServiceNode.tsx new file mode 100644 index 0000000..d68aa72 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/ArchServiceNode.tsx @@ -0,0 +1,25 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "./constants"; + +export function ArchServiceNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "⚙️"; + const metadata = d.tag; + + return ( +
+
{icon}
+
+
{d.label}
+ {metadata &&
{metadata}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/architecture/constants.test.ts b/apps/web/src/modules/diagram/types/architecture/constants.test.ts new file mode 100644 index 0000000..d8180e1 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/constants.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + ARCH_SIZES, + getArchNodeSize, + resolveArchitectureNodeType, + resolveArchitectureEdgeType, +} from "./constants"; + +describe("resolveArchitectureNodeType", () => { + it("should resolve arch:service to archService", () => { + expect(resolveArchitectureNodeType("arch:service")).toBe("archService"); + }); + + it("should resolve arch:database to archDatabase", () => { + expect(resolveArchitectureNodeType("arch:database")).toBe("archDatabase"); + }); + + it("should resolve arch:queue to archQueue", () => { + expect(resolveArchitectureNodeType("arch:queue")).toBe("archQueue"); + }); + + it("should resolve arch:loadbalancer to archLoadBalancer", () => { + expect(resolveArchitectureNodeType("arch:loadbalancer")).toBe( + "archLoadBalancer", + ); + }); + + it("should resolve arch:external to archExternal", () => { + expect(resolveArchitectureNodeType("arch:external")).toBe("archExternal"); + }); + + it("should resolve bare type without prefix", () => { + expect(resolveArchitectureNodeType("database")).toBe("archDatabase"); + }); + + it("should default unknown types to archService", () => { + expect(resolveArchitectureNodeType("arch:unknown")).toBe("archService"); + expect(resolveArchitectureNodeType("something")).toBe("archService"); + }); +}); + +describe("resolveArchitectureEdgeType", () => { + it("should always return archConnection", () => { + expect(resolveArchitectureEdgeType("sync")).toBe("archConnection"); + expect(resolveArchitectureEdgeType("async")).toBe("archConnection"); + expect(resolveArchitectureEdgeType(undefined)).toBe("archConnection"); + }); +}); + +describe("getArchNodeSize", () => { + it("should return correct size for archService", () => { + expect(getArchNodeSize("archService")).toEqual(ARCH_SIZES.service); + }); + + it("should return correct size for archDatabase", () => { + expect(getArchNodeSize("archDatabase")).toEqual(ARCH_SIZES.database); + }); + + it("should return correct size for archQueue", () => { + expect(getArchNodeSize("archQueue")).toEqual(ARCH_SIZES.queue); + }); + + it("should return correct size for archLoadBalancer", () => { + expect(getArchNodeSize("archLoadBalancer")).toEqual( + ARCH_SIZES.loadbalancer, + ); + }); + + it("should return correct size for archExternal", () => { + expect(getArchNodeSize("archExternal")).toEqual(ARCH_SIZES.external); + }); + + it("should return null for non-architecture types", () => { + expect(getArchNodeSize("erEntity")).toBeNull(); + expect(getArchNodeSize("bpmnActivity")).toBeNull(); + expect(getArchNodeSize(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/diagram/types/architecture/constants.ts b/apps/web/src/modules/diagram/types/architecture/constants.ts new file mode 100644 index 0000000..8953515 --- /dev/null +++ b/apps/web/src/modules/diagram/types/architecture/constants.ts @@ -0,0 +1,53 @@ +/** Shared invisible handle style — extracted to avoid object allocation on every render. */ +export const HIDDEN_HANDLE = { opacity: 0 } as const; + +/** Architecture diagram node dimensions for ELK layout spacing. */ +export const ARCH_SIZES = { + service: { w: 200, h: 80 }, + database: { w: 160, h: 100 }, + queue: { w: 180, h: 70 }, + loadbalancer: { w: 120, h: 120 }, + external: { w: 180, h: 80 }, +} as const; + +const ARCH_TYPE_MAP: Record = { + archService: ARCH_SIZES.service, + archDatabase: ARCH_SIZES.database, + archQueue: ARCH_SIZES.queue, + archLoadBalancer: ARCH_SIZES.loadbalancer, + archExternal: ARCH_SIZES.external, +}; + +/** Get architecture node dimensions by @xyflow/react node type. Returns null if not an architecture type. */ +export function getArchNodeSize( + flowType: string | undefined, +): { w: number; h: number } | null { + if (!flowType) return null; + return ARCH_TYPE_MAP[flowType] ?? null; +} + +/** Map DiagramNode.type (with or without arch: prefix) to @xyflow/react node type string. */ +export function resolveArchitectureNodeType(type: string): string { + const bare = type.startsWith("arch:") ? type.slice(5) : type; + switch (bare) { + case "service": + return "archService"; + case "database": + return "archDatabase"; + case "queue": + return "archQueue"; + case "loadbalancer": + return "archLoadBalancer"; + case "external": + return "archExternal"; + default: + return "archService"; + } +} + +/** Map DiagramEdge.type to @xyflow/react edge type string for architecture diagrams. */ +export function resolveArchitectureEdgeType( + _type: string | undefined, +): string { + return "archConnection"; +} diff --git a/apps/web/src/modules/diagram/types/er/ErEntityNode.tsx b/apps/web/src/modules/diagram/types/er/ErEntityNode.tsx new file mode 100644 index 0000000..c68e352 --- /dev/null +++ b/apps/web/src/modules/diagram/types/er/ErEntityNode.tsx @@ -0,0 +1,47 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; + +import type { DiagramNode } from "../graph"; + +export function ErEntityNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const columns = d.columns ?? []; + + return ( +
+
{d.label}
+
+ {columns.length === 0 && ( +
No attributes
+ )} + {columns.map((col, i) => ( +
+ + {col.isPrimaryKey ? "🔑" : col.isForeignKey ? "→" : ""} + + {col.name} + {col.type} + + {col.isNullable ? "?" : ""} + {col.isUnique ? "U" : ""} + +
+ ))} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx b/apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx new file mode 100644 index 0000000..592bb6f --- /dev/null +++ b/apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx @@ -0,0 +1,102 @@ +import { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + Position, +} from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +/** Offset (px) from the endpoint along the edge segment for cardinality labels. */ +const CARD_OFFSET = 24; + +/** Compute cardinality label position offset along the actual edge segment direction. */ +function cardinalityOffset( + x: number, + y: number, + position: Position, +): { x: number; y: number } { + switch (position) { + case Position.Top: + return { x, y: y - CARD_OFFSET }; + case Position.Bottom: + return { x, y: y + CARD_OFFSET }; + case Position.Left: + return { x: x - CARD_OFFSET, y }; + case Position.Right: + return { x: x + CARD_OFFSET, y }; + } +} + +export function ErRelationshipEdge(props: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + }); + + const cardinality = ( + props.data as Record | undefined + )?.cardinality as string | undefined; + + const parts = cardinality?.split(":") ?? []; + const srcCard = parts[0] || undefined; + const tgtCard = parts[1] || undefined; + + const srcPos = cardinalityOffset( + props.sourceX, + props.sourceY, + props.sourcePosition, + ); + const tgtPos = cardinalityOffset( + props.targetX, + props.targetY, + props.targetPosition, + ); + + return ( + <> + + + {srcCard && ( +
+ {srcCard} +
+ )} + {tgtCard && ( +
+ {tgtCard} +
+ )} +
+ + ); +} diff --git a/apps/web/src/modules/diagram/types/er/constants.test.ts b/apps/web/src/modules/diagram/types/er/constants.test.ts new file mode 100644 index 0000000..b468fd5 --- /dev/null +++ b/apps/web/src/modules/diagram/types/er/constants.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { + ER_SIZES, + getErEntityHeight, + resolveErNodeType, + resolveErEdgeType, +} from "./constants"; +import type { Column } from "../graph"; + +describe("resolveErNodeType", () => { + it("should resolve er:entity to erEntity", () => { + expect(resolveErNodeType("er:entity")).toBe("erEntity"); + }); + + it("should resolve bare entity to erEntity", () => { + expect(resolveErNodeType("entity")).toBe("erEntity"); + }); + + it("should default unknown types to erEntity", () => { + expect(resolveErNodeType("er:unknown")).toBe("erEntity"); + expect(resolveErNodeType("something")).toBe("erEntity"); + }); +}); + +describe("resolveErEdgeType", () => { + it("should always resolve to erRelationship", () => { + expect(resolveErEdgeType("relationship")).toBe("erRelationship"); + expect(resolveErEdgeType(undefined)).toBe("erRelationship"); + expect(resolveErEdgeType("inheritance")).toBe("erRelationship"); + }); +}); + +describe("getErEntityHeight", () => { + it("should compute height with no columns (uses minRows=1)", () => { + const expected = + ER_SIZES.entity.headerH + + ER_SIZES.entity.minRows * ER_SIZES.entity.rowH + + ER_SIZES.entity.paddingY * 2; + expect(getErEntityHeight()).toBe(expected); + expect(getErEntityHeight(undefined)).toBe(expected); + }); + + it("should compute height with empty columns array (uses minRows=1)", () => { + const expected = + ER_SIZES.entity.headerH + + ER_SIZES.entity.minRows * ER_SIZES.entity.rowH + + ER_SIZES.entity.paddingY * 2; + expect(getErEntityHeight([])).toBe(expected); + }); + + it("should compute height based on column count", () => { + const columns: Column[] = [ + { name: "id", type: "uuid", isPrimaryKey: true }, + { name: "name", type: "text" }, + { name: "email", type: "varchar" }, + ]; + const expected = + ER_SIZES.entity.headerH + + 3 * ER_SIZES.entity.rowH + + ER_SIZES.entity.paddingY * 2; + expect(getErEntityHeight(columns)).toBe(expected); + }); + + it("should compute correct height for many columns", () => { + const columns: Column[] = Array.from({ length: 10 }, (_, i) => ({ + name: `col_${i}`, + type: "text", + })); + const expected = + ER_SIZES.entity.headerH + + 10 * ER_SIZES.entity.rowH + + ER_SIZES.entity.paddingY * 2; + expect(getErEntityHeight(columns)).toBe(expected); + }); +}); + +describe("ER_SIZES", () => { + it("should have entity dimensions defined", () => { + expect(ER_SIZES.entity.w).toBe(260); + expect(ER_SIZES.entity.headerH).toBe(36); + expect(ER_SIZES.entity.rowH).toBe(24); + expect(ER_SIZES.entity.paddingY).toBe(8); + expect(ER_SIZES.entity.minRows).toBe(1); + }); +}); diff --git a/apps/web/src/modules/diagram/types/er/constants.ts b/apps/web/src/modules/diagram/types/er/constants.ts new file mode 100644 index 0000000..7288713 --- /dev/null +++ b/apps/web/src/modules/diagram/types/er/constants.ts @@ -0,0 +1,40 @@ +import type { Column } from "../graph"; + +/** E-R entity layout dimensions for ELK spacing. */ +export const ER_SIZES = { + entity: { + w: 260, + headerH: 36, + rowH: 24, + paddingY: 8, + minRows: 1, + }, +} as const; + +/** Compute entity height based on column count for ELK layout. */ +export function getErEntityHeight(columns?: Column[]): number { + const rows = Math.max( + ER_SIZES.entity.minRows, + columns?.length ?? 0, + ); + return ( + ER_SIZES.entity.headerH + + rows * ER_SIZES.entity.rowH + + ER_SIZES.entity.paddingY * 2 + ); +} + +/** Map DiagramNode.type (with or without er: prefix) to @xyflow/react node type string. */ +export function resolveErNodeType(type: string): string { + const bare = type.startsWith("er:") ? type.slice(3) : type; + switch (bare) { + case "entity": + default: + return "erEntity"; + } +} + +/** Map DiagramEdge.type to @xyflow/react edge type string for E-R diagrams. */ +export function resolveErEdgeType(_type: string | undefined): string { + return "erRelationship"; +} diff --git a/apps/web/src/modules/diagram/types/orgchart/OrgchartHierarchyEdge.tsx b/apps/web/src/modules/diagram/types/orgchart/OrgchartHierarchyEdge.tsx new file mode 100644 index 0000000..50b4a8c --- /dev/null +++ b/apps/web/src/modules/diagram/types/orgchart/OrgchartHierarchyEdge.tsx @@ -0,0 +1,22 @@ +import { BaseEdge, getSmoothStepPath } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function OrgchartHierarchyEdge(props: EdgeProps) { + const [edgePath] = getSmoothStepPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + borderRadius: 8, + }); + + return ( + + ); +} diff --git a/apps/web/src/modules/diagram/types/orgchart/OrgchartPersonNode.tsx b/apps/web/src/modules/diagram/types/orgchart/OrgchartPersonNode.tsx new file mode 100644 index 0000000..1149d1e --- /dev/null +++ b/apps/web/src/modules/diagram/types/orgchart/OrgchartPersonNode.tsx @@ -0,0 +1,26 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; + +export function OrgchartPersonNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon ?? "\u{1F464}"; + const role = d.tag; + const department = d.group; + const accentColor = d.color || undefined; + + return ( +
+
{icon}
+
+
{d.label}
+ {role &&
{role}
} + {department &&
{department}
} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/orgchart/constants.test.ts b/apps/web/src/modules/diagram/types/orgchart/constants.test.ts new file mode 100644 index 0000000..71a1eae --- /dev/null +++ b/apps/web/src/modules/diagram/types/orgchart/constants.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { + OC_SIZES, + resolveOrgchartNodeType, + resolveOrgchartEdgeType, +} from "./constants"; + +describe("OC_SIZES", () => { + it("should have person dimensions", () => { + expect(OC_SIZES.person.w).toBe(280); + expect(OC_SIZES.person.h).toBe(80); + }); +}); + +describe("resolveOrgchartNodeType", () => { + it("should resolve org:person to orgchartPerson", () => { + expect(resolveOrgchartNodeType("org:person")).toBe("orgchartPerson"); + }); + + it("should resolve bare person to orgchartPerson", () => { + expect(resolveOrgchartNodeType("person")).toBe("orgchartPerson"); + }); + + it("should default unknown types to orgchartPerson", () => { + expect(resolveOrgchartNodeType("org:unknown")).toBe("orgchartPerson"); + expect(resolveOrgchartNodeType("foo")).toBe("orgchartPerson"); + }); +}); + +describe("resolveOrgchartEdgeType", () => { + it("should return orgchartHierarchy for any edge type", () => { + expect(resolveOrgchartEdgeType("hierarchy")).toBe("orgchartHierarchy"); + expect(resolveOrgchartEdgeType(undefined)).toBe("orgchartHierarchy"); + expect(resolveOrgchartEdgeType("other")).toBe("orgchartHierarchy"); + }); +}); diff --git a/apps/web/src/modules/diagram/types/orgchart/constants.ts b/apps/web/src/modules/diagram/types/orgchart/constants.ts new file mode 100644 index 0000000..0dff1c8 --- /dev/null +++ b/apps/web/src/modules/diagram/types/orgchart/constants.ts @@ -0,0 +1,22 @@ +/** Org chart node dimensions for ELK layout spacing. */ +export const OC_SIZES = { + person: { + w: 280, + h: 80, + }, +} as const; + +/** Map DiagramNode.type (with or without org: prefix) to @xyflow/react node type string. */ +export function resolveOrgchartNodeType(type: string): string { + const bare = type.startsWith("org:") ? type.slice(4) : type; + switch (bare) { + case "person": + default: + return "orgchartPerson"; + } +} + +/** Map DiagramEdge.type to @xyflow/react edge type string for org chart diagrams. */ +export function resolveOrgchartEdgeType(_type: string | undefined): string { + return "orgchartHierarchy"; +} diff --git a/apps/web/src/modules/diagram/types/orgchart/index.ts b/apps/web/src/modules/diagram/types/orgchart/index.ts new file mode 100644 index 0000000..c02135e --- /dev/null +++ b/apps/web/src/modules/diagram/types/orgchart/index.ts @@ -0,0 +1,7 @@ +export { OrgchartPersonNode } from "./OrgchartPersonNode"; +export { OrgchartHierarchyEdge } from "./OrgchartHierarchyEdge"; +export { + OC_SIZES, + resolveOrgchartNodeType, + resolveOrgchartEdgeType, +} from "./constants"; diff --git a/apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx b/apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx new file mode 100644 index 0000000..e17f32e --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx @@ -0,0 +1,81 @@ +import { EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; +import { SEQ_LAYOUT, SEQ_SIZES } from "./constants"; + +const SELF_MSG_WIDTH = 40; + +export function SeqAsyncEdge(props: EdgeProps) { + const edgeData = props.data as Record | undefined; + const order = (edgeData?.order as number) ?? 0; + const messageY = (edgeData?.messageY as number) ?? + SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing; + + const isSelfMessage = props.source === props.target; + + if (isSelfMessage) { + const x = props.sourceX + SEQ_SIZES.participant.w / 2; + const loopRight = x + SELF_MSG_WIDTH; + const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6; + const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); + } + + const isLeftToRight = props.sourceX < props.targetX; + const startX = isLeftToRight + ? props.sourceX + SEQ_SIZES.participant.w / 2 + : props.sourceX - SEQ_SIZES.participant.w / 2; + const endX = isLeftToRight + ? props.targetX - SEQ_SIZES.participant.w / 2 + : props.targetX + SEQ_SIZES.participant.w / 2; + + const path = `M ${startX} ${messageY} L ${endX} ${messageY}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/diagram/types/sequence/SeqFragmentNode.tsx b/apps/web/src/modules/diagram/types/sequence/SeqFragmentNode.tsx new file mode 100644 index 0000000..6aae44c --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/SeqFragmentNode.tsx @@ -0,0 +1,19 @@ +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; + +export function SeqFragmentNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const fragmentType = d.label; // "alt" | "loop" | "opt" + const guardCondition = d.tag; + + return ( +
+
+ {fragmentType} + {guardCondition && ( + {guardCondition} + )} +
+
+ ); +} diff --git a/apps/web/src/modules/diagram/types/sequence/SeqParticipantNode.tsx b/apps/web/src/modules/diagram/types/sequence/SeqParticipantNode.tsx new file mode 100644 index 0000000..d8d6b7b --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/SeqParticipantNode.tsx @@ -0,0 +1,40 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "../architecture/constants"; +import { SEQ_SIZES } from "./constants"; + +export function SeqParticipantNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { + label: string; + lifelineHeight?: number; + activations?: { y: number; height: number }[]; + }; + const icon = d.icon || "👤"; + const lifelineHeight = d.lifelineHeight ?? 400; + const activations = d.activations ?? []; + + return ( +
+
+ {icon} + {d.label} +
+
+ {activations.map((act, i) => ( +
+ ))} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/sequence/SeqReturnEdge.tsx b/apps/web/src/modules/diagram/types/sequence/SeqReturnEdge.tsx new file mode 100644 index 0000000..2f1f985 --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/SeqReturnEdge.tsx @@ -0,0 +1,81 @@ +import { EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; +import { SEQ_LAYOUT, SEQ_SIZES } from "./constants"; + +const SELF_MSG_WIDTH = 40; + +export function SeqReturnEdge(props: EdgeProps) { + const edgeData = props.data as Record | undefined; + const order = (edgeData?.order as number) ?? 0; + const messageY = (edgeData?.messageY as number) ?? + SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing; + + const isSelfMessage = props.source === props.target; + + if (isSelfMessage) { + const x = props.sourceX + SEQ_SIZES.participant.w / 2; + const loopRight = x + SELF_MSG_WIDTH; + const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6; + const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); + } + + const isLeftToRight = props.sourceX < props.targetX; + const startX = isLeftToRight + ? props.sourceX + SEQ_SIZES.participant.w / 2 + : props.sourceX - SEQ_SIZES.participant.w / 2; + const endX = isLeftToRight + ? props.targetX - SEQ_SIZES.participant.w / 2 + : props.targetX + SEQ_SIZES.participant.w / 2; + + const path = `M ${startX} ${messageY} L ${endX} ${messageY}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx b/apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx new file mode 100644 index 0000000..9c0b280 --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx @@ -0,0 +1,80 @@ +import { EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; +import { SEQ_LAYOUT, SEQ_SIZES } from "./constants"; + +const SELF_MSG_WIDTH = 40; + +export function SeqSyncEdge(props: EdgeProps) { + const edgeData = props.data as Record | undefined; + const order = (edgeData?.order as number) ?? 0; + const messageY = (edgeData?.messageY as number) ?? + SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing; + + const isSelfMessage = props.source === props.target; + + if (isSelfMessage) { + // Self-message: U-shaped loop on the right side of the participant + const x = props.sourceX + SEQ_SIZES.participant.w / 2; + const loopRight = x + SELF_MSG_WIDTH; + const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6; + const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); + } + + const isLeftToRight = props.sourceX < props.targetX; + const startX = isLeftToRight + ? props.sourceX + SEQ_SIZES.participant.w / 2 + : props.sourceX - SEQ_SIZES.participant.w / 2; + const endX = isLeftToRight + ? props.targetX - SEQ_SIZES.participant.w / 2 + : props.targetX + SEQ_SIZES.participant.w / 2; + + const path = `M ${startX} ${messageY} L ${endX} ${messageY}`; + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/diagram/types/sequence/constants.test.ts b/apps/web/src/modules/diagram/types/sequence/constants.test.ts new file mode 100644 index 0000000..abeae1f --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/constants.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { + SEQ_SIZES, + SEQ_LAYOUT, + resolveSequenceNodeType, + resolveSequenceEdgeType, + getSeqNodeSize, +} from "./constants"; + +describe("SEQ_SIZES", () => { + it("should have participant dimensions", () => { + expect(SEQ_SIZES.participant).toEqual({ w: 160, h: 60 }); + }); + + it("should have fragment dimensions (computed dynamically)", () => { + expect(SEQ_SIZES.fragment).toEqual({ w: 0, h: 0 }); + }); +}); + +describe("SEQ_LAYOUT", () => { + it("should have all layout constants", () => { + expect(SEQ_LAYOUT.participantSpacing).toBe(200); + expect(SEQ_LAYOUT.messageStartY).toBe(100); + expect(SEQ_LAYOUT.messageSpacing).toBe(50); + expect(SEQ_LAYOUT.lifelinePadding).toBe(40); + expect(SEQ_LAYOUT.activationWidth).toBe(12); + expect(SEQ_LAYOUT.fragmentPadding).toBe(16); + }); +}); + +describe("resolveSequenceNodeType", () => { + it("should resolve seq:participant to seqParticipant", () => { + expect(resolveSequenceNodeType("seq:participant")).toBe("seqParticipant"); + }); + + it("should resolve seq:fragment to seqFragment", () => { + expect(resolveSequenceNodeType("seq:fragment")).toBe("seqFragment"); + }); + + it("should resolve bare participant to seqParticipant", () => { + expect(resolveSequenceNodeType("participant")).toBe("seqParticipant"); + }); + + it("should resolve bare fragment to seqFragment", () => { + expect(resolveSequenceNodeType("fragment")).toBe("seqFragment"); + }); + + it("should default unknown types to seqParticipant", () => { + expect(resolveSequenceNodeType("unknown")).toBe("seqParticipant"); + }); +}); + +describe("resolveSequenceEdgeType", () => { + it("should resolve sync to seqSync", () => { + expect(resolveSequenceEdgeType("sync")).toBe("seqSync"); + }); + + it("should resolve async to seqAsync", () => { + expect(resolveSequenceEdgeType("async")).toBe("seqAsync"); + }); + + it("should resolve return to seqReturn", () => { + expect(resolveSequenceEdgeType("return")).toBe("seqReturn"); + }); + + it("should default undefined to seqSync", () => { + expect(resolveSequenceEdgeType(undefined)).toBe("seqSync"); + }); + + it("should default unknown types to seqSync", () => { + expect(resolveSequenceEdgeType("unknown")).toBe("seqSync"); + }); +}); + +describe("getSeqNodeSize", () => { + it("should return participant size for seqParticipant", () => { + expect(getSeqNodeSize("seqParticipant")).toEqual(SEQ_SIZES.participant); + }); + + it("should return null for non-sequence types", () => { + expect(getSeqNodeSize("default")).toBeNull(); + expect(getSeqNodeSize("bpmnActivity")).toBeNull(); + expect(getSeqNodeSize(undefined)).toBeNull(); + }); + + it("should return null for seqFragment (dynamically computed)", () => { + expect(getSeqNodeSize("seqFragment")).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/diagram/types/sequence/constants.ts b/apps/web/src/modules/diagram/types/sequence/constants.ts new file mode 100644 index 0000000..fe8ee44 --- /dev/null +++ b/apps/web/src/modules/diagram/types/sequence/constants.ts @@ -0,0 +1,49 @@ +export const SEQ_SIZES = { + participant: { w: 160, h: 60 }, + fragment: { w: 0, h: 0 }, // Computed dynamically from contained messages +} as const; + +export const SEQ_LAYOUT = { + participantSpacing: 200, + messageStartY: 100, + messageSpacing: 50, + lifelinePadding: 40, + activationWidth: 12, + fragmentPadding: 16, +} as const; + +export function resolveSequenceNodeType(type: string): string { + const bare = type.startsWith("seq:") ? type.slice(4) : type; + switch (bare) { + case "participant": + return "seqParticipant"; + case "fragment": + return "seqFragment"; + default: + return "seqParticipant"; + } +} + +export function resolveSequenceEdgeType(type: string | undefined): string { + switch (type) { + case "sync": + return "seqSync"; + case "async": + return "seqAsync"; + case "return": + return "seqReturn"; + default: + return "seqSync"; + } +} + +export function getSeqNodeSize( + flowType: string | undefined, +): { w: number; h: number } | null { + switch (flowType) { + case "seqParticipant": + return SEQ_SIZES.participant; + default: + return null; + } +}