feat: implement Stories 2.4-2.7 — E-R, Org Chart, Architecture, Sequence diagram type renderers

Adds four diagram type renderers completing the core diagram type suite:
- Story 2.4: E-R entity nodes with column tables, relationship edges with cardinality labels
- Story 2.5: Org chart person nodes with role/department tags, hierarchy edges
- Story 2.6: Architecture nodes (service, database, queue, load balancer, external), connection edges
- Story 2.7: Sequence participant nodes with lifelines + activation bars, fragment nodes,
  3 custom edge types (sync/async/return), custom time-ordered layout (not ELK)

Story 2.7 includes code review fixes: computeLayout returns LayoutResult so enriched
sequence edges flow through useAutoLayout, activation bar computation in layout,
immutable layout function, self-message U-shaped loop rendering, sequence node size
tests in buildElkGraph.

476 tests passing across 29 test files, zero regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-27 01:24:50 +00:00
parent 0a7838aa60
commit 1ff8ff8f06
38 changed files with 5620 additions and 22 deletions

View File

@@ -0,0 +1,603 @@
# Story 2.4: Entity-Relationship Diagram Type Renderer
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a user,
I want to create and view 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 (
<>
<BaseEdge id={props.id} path={edgePath} style={{ stroke: "var(--diagram-er)" }} ... />
<EdgeLabelRenderer>
{/* Source cardinality positioned near source */}
{srcCard && <div style={{ position: "absolute", transform: `translate(...)` }} className="er-cardinality">{srcCard}</div>}
{/* Target cardinality positioned near target */}
{tgtCard && <div style={{ position: "absolute", transform: `translate(...)` }} className="er-cardinality">{tgtCard}</div>}
</EdgeLabelRenderer>
</>
);
}
```
**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 (
<div className="er-entity">
<div className="er-entity-header">{d.label}</div>
<div className="er-entity-body">
{columns.length === 0 && (
<div className="er-entity-row er-entity-empty">No attributes</div>
)}
{columns.map((col, i) => (
<div key={col.name ?? i} className="er-entity-row">
<span className="er-entity-indicator">
{col.isPrimaryKey ? "🔑" : col.isForeignKey ? "→" : " "}
</span>
<span className="er-entity-col-name">{col.name}</span>
<span className="er-entity-col-type">{col.type}</span>
<span className="er-entity-constraint">
{col.isNullable ? "?" : ""}{col.isUnique ? "U" : ""}
</span>
</div>
))}
</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} id="left" style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} id="right" style={{ opacity: 0 }} />
</div>
);
}
```
**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 (
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
<marker id="er-arrow" viewBox="0 0 10 10" refX={10} refY={5}
markerWidth={8} markerHeight={8} orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 Z" fill="var(--diagram-er)" />
</marker>
</defs>
</svg>
);
}
```
**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`

View File

@@ -0,0 +1,603 @@
# Story 2.5: Org Chart Diagram Type Renderer
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a user,
I want to create and view 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 (
<BaseEdge
id={props.id}
path={edgePath}
style={{ stroke: "var(--diagram-orgchart)", strokeWidth: 2 }}
/>
);
}
```
**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 (
<div className="oc-person" style={{ borderLeftColor: accentColor }}>
<div className="oc-person-avatar">{icon}</div>
<div className="oc-person-info">
<div className="oc-person-name">{d.label}</div>
{role && <div className="oc-person-role">{role}</div>}
{department && <div className="oc-person-dept">{department}</div>}
</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} id="left" style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} id="right" style={{ opacity: 0 }} />
</div>
);
}
```
**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`

View File

@@ -0,0 +1,827 @@
# Story 2.6: Architecture Diagram Type Renderer
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a user,
I want to create and view 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<string, unknown> | undefined;
const isAsync = edgeData?.type === "async";
return (
<>
<BaseEdge
id={props.id}
path={edgePath}
style={{
stroke: "var(--diagram-architecture)",
strokeWidth: 1.5,
strokeDasharray: isAsync ? "6 3" : undefined,
}}
markerEnd="url(#arch-arrow)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="arch-edge-label"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
```
**Arrow marker needed** — architecture connections show direction of communication. Add to `MarkerDefs`:
```tsx
<marker
id="arch-arrow"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--diagram-architecture, #71717a)"
/>
</marker>
```
### 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 (
<div className="arch-service">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} id="left" style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} id="right" style={{ opacity: 0 }} />
</div>
);
}
```
**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<string, { w: number; h: number }> = {
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 `<marker>` 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

View File

@@ -0,0 +1,875 @@
# Story 2.7: Sequence Diagram Type Renderer
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a user,
I want to create and view 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<string, unknown>).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<string, unknown>;
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<string, unknown> | 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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1.5}
fill="none"
markerEnd="url(#seq-arrow-filled)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label"
style={{
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
position: "absolute",
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
```
**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 (
<div className="seq-participant" style={{ height: lifelineHeight }}>
{/* Actor box at top */}
<div className="seq-participant-box">
<span className="seq-participant-icon">{icon}</span>
<span className="seq-participant-label">{d.label}</span>
</div>
{/* Dashed lifeline */}
<div
className="seq-lifeline"
style={{ height: lifelineHeight - SEQ_SIZES.participant.h }}
/>
{/* Activation bars */}
{activations.map((act, i) => (
<div
key={i}
className="seq-activation"
style={{ top: act.y, height: act.height }}
/>
))}
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}
```
**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 (
<div className="seq-fragment">
<div className="seq-fragment-header">
<span className="seq-fragment-type">{fragmentType}</span>
{guardCondition && (
<span className="seq-fragment-guard">{guardCondition}</span>
)}
</div>
</div>
);
}
```
**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 */}
<marker
id="seq-arrow-filled"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--diagram-sequence, #f59e0b)"
/>
</marker>
<marker
id="seq-arrow-open"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10"
fill="none"
stroke="var(--diagram-sequence, #f59e0b)"
strokeWidth={1.5}
/>
</marker>
```
### 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 `<marker>` 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 `<svg>` layer of the ReactFlow pane
- Custom paths can be drawn directly with `<path>` 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 `<path>` references SVG markers defined in `<defs>` — 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.

View File

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

View File

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

View File

@@ -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 (
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
{/* BPMN markers */}
<marker
id="bpmn-arrow-filled"
viewBox="0 0 10 10"
@@ -91,6 +122,67 @@ function BpmnMarkerDefs() {
strokeWidth={1.5}
/>
</marker>
{/* E-R markers */}
<marker
id="er-arrow"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--diagram-er, #7c3aed)"
/>
</marker>
{/* Architecture markers */}
<marker
id="arch-arrow"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--diagram-architecture, #71717a)"
/>
</marker>
{/* Sequence markers */}
<marker
id="seq-arrow-filled"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 Z"
fill="var(--diagram-sequence, #f59e0b)"
/>
</marker>
<marker
id="seq-arrow-open"
viewBox="0 0 10 10"
refX={10}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10"
fill="none"
stroke="var(--diagram-sequence, #f59e0b)"
strokeWidth={1.5}
/>
</marker>
</defs>
</svg>
);
@@ -168,7 +260,7 @@ function CanvasInner() {
return (
<div className="w-full h-full">
<BpmnMarkerDefs />
<MarkerDefs />
<ReactFlow
nodes={nodes}
edges={edges}

View File

@@ -11,7 +11,7 @@ import {
SOFT_CAP_NODE_COUNT,
} from "../lib/elk-layout";
import type { ElkLayoutOptions } from "../lib/elk-layout";
import type { ElkLayoutOptions, LayoutResult } from "../lib/elk-layout";
const DEBOUNCE_MS = 300;
const LAYOUT_ANIMATION_MS = 200;
@@ -24,6 +24,7 @@ export function useAutoLayout() {
const nodeCount = useGraphStore((s) => 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(

View File

@@ -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([], []);

View File

@@ -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<ElkLayoutOptions> = {},
): Promise<Node[]> {
): Promise<LayoutResult> {
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"));

View File

@@ -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: {

View File

@@ -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";
}

View File

@@ -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<string, unknown>).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<string, unknown>;
expect(e0.order).toBe(0);
expect(e0.messageY).toBe(SEQ_LAYOUT.messageStartY);
const e1 = result.edges[1]!.data as Record<string, unknown>;
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<string, unknown>).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<string, unknown>).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<string, unknown>).order).toBeUndefined();
expect((edges[0]!.data as Record<string, unknown>).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<string, unknown>;
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<string, unknown>).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<string, unknown>).activations as { y: number; height: number }[];
expect(activations).toEqual([]);
}
}
});
});

View File

@@ -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<string, number[]>();
for (const p of positionedParticipants) {
openActivations.set(p.id, []);
}
for (const edge of enrichedEdges) {
const edgeData = edge.data as Record<string, unknown>;
const msgY = edgeData.messageY as number;
const originalData = (edge.data as Record<string, unknown> | 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<string, unknown>;
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,
};
}

View File

@@ -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<string, unknown> | undefined;
const isAsync = edgeData?.type === "async";
return (
<>
<BaseEdge
id={props.id}
path={edgePath}
style={{
stroke: "var(--diagram-architecture)",
strokeWidth: 1.5,
strokeDasharray: isAsync ? "6 3" : undefined,
}}
markerEnd="url(#arch-arrow)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="arch-edge-label"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -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 (
<div className="arch-database">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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 (
<div className="arch-external">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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 (
<div className="arch-lb">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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 (
<div className="arch-queue">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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 (
<div className="arch-service">
<div className="arch-node-icon">{icon}</div>
<div className="arch-node-info">
<div className="arch-node-label">{d.label}</div>
{metadata && <div className="arch-node-meta">{metadata}</div>}
</div>
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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();
});
});

View File

@@ -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<string, { w: number; h: number }> = {
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";
}

View File

@@ -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 (
<div className="er-entity">
<div className="er-entity-header">{d.label}</div>
<div className="er-entity-body">
{columns.length === 0 && (
<div className="er-entity-row er-entity-empty">No attributes</div>
)}
{columns.map((col, i) => (
<div key={col.name} className="er-entity-row">
<span className="er-entity-indicator">
{col.isPrimaryKey ? "🔑" : col.isForeignKey ? "→" : ""}
</span>
<span className="er-entity-col-name">{col.name}</span>
<span className="er-entity-col-type">{col.type}</span>
<span className="er-entity-constraint">
{col.isNullable ? "?" : ""}
{col.isUnique ? "U" : ""}
</span>
</div>
))}
</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle
type="target"
position={Position.Left}
id="left"
style={{ opacity: 0 }}
/>
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle
type="source"
position={Position.Right}
id="right"
style={{ opacity: 0 }}
/>
</div>
);
}

View File

@@ -0,0 +1,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<string, unknown> | 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 (
<>
<BaseEdge
id={props.id}
path={edgePath}
markerEnd="url(#er-arrow)"
style={{ stroke: "var(--diagram-er)", strokeWidth: 1.5 }}
label={props.label}
labelStyle={{ fill: "var(--foreground)", fontSize: 11 }}
labelBgStyle={{
fill: "var(--node-bg)",
fillOpacity: 0.9,
}}
labelShowBg
/>
<EdgeLabelRenderer>
{srcCard && (
<div
className="er-cardinality nodrag nopan"
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${srcPos.x}px, ${srcPos.y}px)`,
pointerEvents: "none",
}}
>
{srcCard}
</div>
)}
{tgtCard && (
<div
className="er-cardinality nodrag nopan"
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${tgtPos.x}px, ${tgtPos.y}px)`,
pointerEvents: "none",
}}
>
{tgtCard}
</div>
)}
</EdgeLabelRenderer>
</>
);
}

View File

@@ -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);
});
});

View File

@@ -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";
}

View File

@@ -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 (
<BaseEdge
id={props.id}
path={edgePath}
style={{ stroke: "var(--diagram-orgchart)", strokeWidth: 2 }}
/>
);
}

View File

@@ -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 (
<div className="oc-person" style={accentColor ? { borderLeftColor: accentColor } : undefined}>
<div className="oc-person-avatar">{icon}</div>
<div className="oc-person-info">
<div className="oc-person-name">{d.label}</div>
{role && <div className="oc-person-role">{role}</div>}
{department && <div className="oc-person-dept">{department}</div>}
</div>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} id="left" style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} id="right" style={{ opacity: 0 }} />
</div>
);
}

View File

@@ -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");
});
});

View File

@@ -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";
}

View File

@@ -0,0 +1,7 @@
export { OrgchartPersonNode } from "./OrgchartPersonNode";
export { OrgchartHierarchyEdge } from "./OrgchartHierarchyEdge";
export {
OC_SIZES,
resolveOrgchartNodeType,
resolveOrgchartEdgeType,
} from "./constants";

View File

@@ -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<string, unknown> | 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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1.5}
strokeDasharray="6 3"
fill="none"
markerEnd="url(#seq-arrow-open)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1.5}
strokeDasharray="6 3"
fill="none"
markerEnd="url(#seq-arrow-open)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -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 (
<div className="seq-fragment">
<div className="seq-fragment-header">
<span className="seq-fragment-type">{fragmentType}</span>
{guardCondition && (
<span className="seq-fragment-guard">{guardCondition}</span>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="seq-participant" style={{ height: lifelineHeight }}>
<div className="seq-participant-box">
<span className="seq-participant-icon">{icon}</span>
<span className="seq-participant-label">{d.label}</span>
</div>
<div
className="seq-lifeline"
style={{ height: lifelineHeight - SEQ_SIZES.participant.h }}
/>
{activations.map((act, i) => (
<div
key={i}
className="seq-activation"
style={{ top: act.y, height: act.height }}
/>
))}
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
</div>
);
}

View File

@@ -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<string, unknown> | 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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1}
strokeDasharray="4 4"
fill="none"
markerEnd="url(#seq-arrow-filled)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1}
strokeDasharray="4 4"
fill="none"
markerEnd="url(#seq-arrow-filled)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -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<string, unknown> | 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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1.5}
fill="none"
markerEnd="url(#seq-arrow-filled)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
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 (
<>
<path
d={path}
stroke="var(--diagram-sequence)"
strokeWidth={1.5}
fill="none"
markerEnd="url(#seq-arrow-filled)"
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="seq-edge-label seq-edge-label-positioned"
style={{
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -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();
});
});

View File

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