From 0ff5450e0fd9636cfcd61559f64a524cefb51859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:59:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=202.8=20=E2=80=94=20F?= =?UTF-8?q?lowchart=20diagram=20type=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 6th and final core diagram type: flowcharts with standard ISO 5807 shapes (process, decision, terminal, I/O, subprocess), orthogonal edge routing with decision outcome labels, and ELK layered auto-layout. Code review fixes included: decision diamond ELK height corrected (80→130px), icon rendering made conditional, data.color border override added to all nodes, ELK sizing ternary refactored to getNodeDimensions() helper, constants unified to lookup maps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2-8-flowchart-diagram-type-renderer.md | 876 ++++++++++++++++++ .../sprint-status.yaml | 2 +- apps/web/src/assets/styles/globals.css | 147 +++ .../components/editor/DiagramCanvas.tsx | 27 + .../modules/diagram/lib/elk-layout.test.ts | 73 ++ .../web/src/modules/diagram/lib/elk-layout.ts | 74 +- .../diagram/lib/graph-converter.test.ts | 75 +- .../modules/diagram/lib/graph-converter.ts | 11 +- .../types/flowchart/FlowDecisionNode.tsx | 37 + .../diagram/types/flowchart/FlowEdge.tsx | 37 + .../diagram/types/flowchart/FlowIoNode.tsx | 37 + .../types/flowchart/FlowProcessNode.tsx | 36 + .../types/flowchart/FlowSubprocessNode.tsx | 40 + .../types/flowchart/FlowTerminalNode.tsx | 37 + .../diagram/types/flowchart/constants.test.ts | 89 ++ .../diagram/types/flowchart/constants.ts | 45 + 16 files changed, 1607 insertions(+), 36 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-8-flowchart-diagram-type-renderer.md create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx create mode 100644 apps/web/src/modules/diagram/types/flowchart/constants.test.ts create mode 100644 apps/web/src/modules/diagram/types/flowchart/constants.ts diff --git a/_bmad-output/implementation-artifacts/2-8-flowchart-diagram-type-renderer.md b/_bmad-output/implementation-artifacts/2-8-flowchart-diagram-type-renderer.md new file mode 100644 index 0000000..59f3831 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-8-flowchart-diagram-type-renderer.md @@ -0,0 +1,876 @@ +# Story 2.8: Flowchart Diagram Type Renderer + +Status: done + + + +## Story + +As a user, +I want to create and view flowcharts with decision nodes, process steps, and terminals, +so that I can model logic flows and decision processes. + +## Acceptance Criteria + +1. **Given** I open or create a flowchart, **When** the canvas renders, **Then** I see standard flowchart shapes: process (rectangle), decision (diamond), start/end terminals (rounded rectangle/stadium), I/O (parallelogram), subprocess (double-bordered rectangle), **And** each node displays its label text. + +2. **Given** a flowchart has decision nodes, **When** rendered, **Then** decision diamonds show the question/condition text, **And** outgoing edges are labeled with the decision outcomes (Yes/No, True/False, or custom). + +3. **Given** auto-layout runs on a flowchart, **When** the diagram has branching paths, **Then** ELK.js produces a clean top-down flow with branches clearly separated, **And** merge points are visually clear. + +## Tasks / Subtasks + +- [x] Task 1: Create flowchart constants and type registry (AC: #1, #2) + - [x] 1.1: Create `apps/web/src/modules/diagram/types/flowchart/constants.ts` with `FLOW_SIZES`, `resolveFlowchartNodeType()`, `resolveFlowchartEdgeType()`, `getFlowNodeSize()` + - [x] 1.2: Define sizes for 5 node subtypes: process (160x60), decision (140x80), terminal (140x50), io (160x60), subprocess (160x60) + +- [x] Task 2: Create FlowProcessNode custom @xyflow/react component (AC: #1) + - [x] 2.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx` — standard rectangle with label text, icon support, rose accent border + +- [x] Task 3: Create FlowDecisionNode custom @xyflow/react component (AC: #1, #2) + - [x] 3.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx` — diamond shape via CSS `transform: rotate(45deg)` on a wrapper with counter-rotated content, condition/question text centered + +- [x] Task 4: Create FlowTerminalNode custom @xyflow/react component (AC: #1) + - [x] 4.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx` — rounded rectangle/stadium shape (`border-radius: 999px` sides), label text, used for both start and end nodes. `data.tag` distinguishes "start" vs "end" for optional styling (e.g., start=green tint, end=red tint, or both use accent) + +- [x] Task 5: Create FlowIoNode custom @xyflow/react component (AC: #1) + - [x] 5.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx` — parallelogram shape via CSS `transform: skewX(-10deg)` on wrapper with counter-skewed content, label text + +- [x] Task 6: Create FlowSubprocessNode custom @xyflow/react component (AC: #1) + - [x] 6.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx` — double-bordered rectangle (outer border + inner inset border), label text + +- [x] Task 7: Create FlowEdge custom @xyflow/react component (AC: #2, #3) + - [x] 7.1: Create `apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx` — solid arrow using `getSmoothStepPath` (orthogonal routing), label from `edge.label` shown at midpoint (for decision outcomes Yes/No), rose accent color, markerEnd `url(#flow-arrow)` + +- [x] Task 8: Update graph converter for flowchart type resolution (AC: #1, #2) + - [x] 8.1: Import `resolveFlowchartNodeType`, `resolveFlowchartEdgeType` from `../types/flowchart/constants` in `graph-converter.ts` + - [x] 8.2: Replace `// Future: flowchart` comment (line 50) with: `if (diagramType === "flowchart" || nodeType.startsWith("flow:")) { return resolveFlowchartNodeType(nodeType); }` + - [x] 8.3: Add flowchart diagramType handling in `resolveFlowEdgeType()` before the default return + +- [x] Task 9: Register flowchart types in DiagramCanvas (AC: #1, #2) + - [x] 9.1: Import FlowProcessNode, FlowDecisionNode, FlowTerminalNode, FlowIoNode, FlowSubprocessNode, FlowEdge in `DiagramCanvas.tsx` + - [x] 9.2: Add flowchart entries to `nodeTypes` object: `flowProcess`, `flowDecision`, `flowTerminal`, `flowIo`, `flowSubprocess` (OUTSIDE component) + - [x] 9.3: Add flowchart entry to `edgeTypes` object: `flowEdge` (OUTSIDE component) + - [x] 9.4: Add flowchart arrow marker to `MarkerDefs`: `#flow-arrow` with rose accent color + +- [x] Task 10: Integrate flowchart node sizing in ELK layout (AC: #3) + - [x] 10.1: Import `getFlowNodeSize` from `../types/flowchart/constants` in `elk-layout.ts` + - [x] 10.2: Add flowchart sizing to the width/height computation chain in `buildElkGraph()` — after `seqSize` check, add `flowSize` check + +- [x] Task 11: Add flowchart CSS styles (AC: #1, #2) + - [x] 11.1: Add `.flow-process`, `.flow-decision`, `.flow-decision-diamond`, `.flow-terminal`, `.flow-io`, `.flow-io-skew`, `.flow-subprocess`, `.flow-subprocess-inner` styles to `globals.css` + - [x] 11.2: Use `--diagram-flowchart` rose accent color for all flowchart elements (already defined at line 29) + +- [x] Task 12: Tests (AC: all) + - [x] 12.1: Unit tests for flowchart constants — `resolveFlowchartNodeType` for all 5 subtypes (prefixed and bare), `resolveFlowchartEdgeType`, `getFlowNodeSize` + - [x] 12.2: Unit tests for graph converter flowchart type mapping — node types correctly resolved for `diagramType === "flowchart"`, edge types resolved, flat layout (no containers) + - [x] 12.3: Update "all 6 diagram types" test (line 150 of graph-converter.test.ts): change assertion from `"default"` to `"flowProcess"` for `flow:process` node + - [x] 12.4: Unit tests for ELK layout flowchart node sizing — `buildElkGraph` produces correct dimensions for flowchart nodes + - [x] 12.5: All tests pass — no regressions + +## Dev Notes + +### Overview — What This Story Builds + +This story adds the Flowchart diagram type renderer: the last of the 6 core diagram types. Flowcharts use standard ISO 5807-inspired shapes (process, decision, terminal, I/O, subprocess) with the standard ELK layered algorithm for top-down flow layout. + +**This story builds:** +- 5 custom flowchart node components (FlowProcessNode, FlowDecisionNode, FlowTerminalNode, FlowIoNode, FlowSubprocessNode) +- 1 custom flowchart edge component (FlowEdge) with label support for decision outcomes +- Flowchart constants and type registry (5 node subtypes + 1 edge type) +- Graph converter flowchart type resolution +- ELK layout flowchart node sizing +- Flowchart CSS styles with rose `--diagram-flowchart` accent +- Flowchart arrow marker in MarkerDefs + +**This story does NOT implement:** +- Other diagram types (all 6 complete after this) +- Smart Inspector for flowchart 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):** Flowchart nodes use type-prefixed `type` field (`flow:process`, `flow:decision`, `flow:terminal`, `flow:io`, `flow:subprocess`). Existing DiagramNode fields are semantically reused: `label` = step description/question text, `tag` = category (e.g., "start"/"end" for terminals), `icon` = optional step emoji, `color` = custom accent override. Edge types use a single value: `sequence` (or default). NO new fields on DiagramNode or DiagramEdge needed. + +2. **Component Structure:** Feature code in `~/modules/diagram/types/flowchart/` — follows the BPMN (`types/bpmn/`), E-R (`types/er/`), Org Chart (`types/orgchart/`), Architecture (`types/architecture/`), Sequence (`types/sequence/`) 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.7). + +4. **Standard ELK Layout:** Flowchart uses the standard ELK layered algorithm — no custom layout needed (unlike sequence diagrams). `buildElkGraph()` needs flowchart node sizing via `getFlowNodeSize()`. Default direction: `DOWN` for top-down flow. + +5. **Lean JSON Data Model:** No x/y positions stored for flowchart nodes. All positioning computed by ELK at render time. + +6. **Type Prefixing Convention (Enforcement Rule #5):** `flow:` prefix on DiagramNode.type. Edge type is bare lowercase: `sequence` (or undefined for default). + +### Flowchart Diagram Data Model — Field Mapping + +The existing DiagramNode and DiagramEdge fields satisfy flowchart needs: + +```typescript +// flow:process node +{ + id: "step1", + type: "flow:process", + label: "Process Payment", // Step description + icon: "💳", // Optional step icon + color: "#e11d48", // Custom accent (optional) +} + +// flow:decision node +{ + id: "check1", + type: "flow:decision", + label: "Payment Valid?", // Question/condition text + icon: "❓", // Optional icon +} + +// flow:terminal node (start) +{ + id: "start", + type: "flow:terminal", + label: "Start", + tag: "start", // Distinguishes start vs end +} + +// flow:terminal node (end) +{ + id: "end", + type: "flow:terminal", + label: "End", + tag: "end", // Distinguishes start vs end +} + +// flow:io node +{ + id: "input1", + type: "flow:io", + label: "Read User Input", +} + +// flow:subprocess node +{ + id: "sub1", + type: "flow:subprocess", + label: "Validate Credentials", +} +``` + +**Edge model:** +```typescript +// Standard flow edge +{ + id: "e1", + from: "start", + to: "step1", + // No label needed for sequential flow +} + +// Decision outcome edge +{ + id: "e2", + from: "check1", + to: "step2", + label: "Yes", // Decision outcome label +} + +// Decision outcome edge (alternative path) +{ + id: "e3", + from: "check1", + to: "error1", + label: "No", // Alternative outcome +} +``` + +### Flowchart Node Types — Size Constants + +```typescript +export const FLOW_SIZES = { + process: { w: 160, h: 60 }, // Standard rectangle + decision: { w: 140, h: 80 }, // Diamond (wider for rotation) + terminal: { w: 140, h: 50 }, // Rounded rectangle / stadium + io: { w: 160, h: 60 }, // Parallelogram + subprocess: { w: 160, h: 60 }, // Double-bordered rectangle +} as const; +``` + +### Flowchart Node Type → @xyflow/react Type Mapping + +| DiagramNode.type | @xyflow Node type | Component | Visual | +|---|---|---|---| +| `flow:process` | `flowProcess` | `FlowProcessNode` | Rectangle with label | +| `flow:decision` | `flowDecision` | `FlowDecisionNode` | Diamond with condition text | +| `flow:terminal` | `flowTerminal` | `FlowTerminalNode` | Rounded rectangle / stadium | +| `flow:io` | `flowIo` | `FlowIoNode` | Parallelogram | +| `flow:subprocess` | `flowSubprocess` | `FlowSubprocessNode` | Double-bordered rectangle | + +**Type resolution:** +```typescript +export function resolveFlowchartNodeType(type: string): string { + const bare = type.startsWith("flow:") ? type.slice(5) : type; + switch (bare) { + case "process": + return "flowProcess"; + case "decision": + return "flowDecision"; + case "terminal": + return "flowTerminal"; + case "io": + return "flowIo"; + case "subprocess": + return "flowSubprocess"; + default: + return "flowProcess"; // Default to process + } +} + +export function resolveFlowchartEdgeType(_type: string | undefined): string { + return "flowEdge"; // Single edge type for flowcharts +} + +export function getFlowNodeSize( + flowType: string | undefined, +): { w: number; h: number } | null { + switch (flowType) { + case "flowProcess": + return FLOW_SIZES.process; + case "flowDecision": + return FLOW_SIZES.decision; + case "flowTerminal": + return FLOW_SIZES.terminal; + case "flowIo": + return FLOW_SIZES.io; + case "flowSubprocess": + return FLOW_SIZES.subprocess; + default: + return null; // Not a flowchart node + } +} +``` + +### Flowchart Edge Type + +| DiagramEdge.type | @xyflow Edge type | Visual | +|---|---|---| +| `sequence` (or default/undefined) | `flowEdge` | Solid orthogonal path with filled arrowhead, optional label at midpoint | + +Only one edge type needed. Decision outcomes use the `label` field on the edge (e.g., "Yes", "No", "True", "False"). + +### Standard ELK Layout — Flowchart Uses the Default Path + +**Flowchart diagrams USE the standard ELK layered algorithm.** No custom layout function needed. The `computeLayout()` function in `elk-layout.ts` routes through the default path — the only change needed is adding `getFlowNodeSize()` to the `buildElkGraph()` sizing chain. + +**ELK settings for flowcharts:** +- Algorithm: `layered` (default) +- Direction: `DOWN` (top-down flow — best for flowcharts) +- Edge routing: `ORTHOGONAL` (right-angle connectors — standard for flowcharts) +- Crossing minimization: `LAYER_SWEEP` (default) +- Node placement: `BRANDES_KOEPF` (default) + +**Integration in `buildElkGraph()` — sizing chain update:** +```typescript +// After existing seqSize check: +const flowSize = getFlowNodeSize(node.type); +const height = + isErEntity && data.columns + ? getErEntityHeight(data.columns) + : isOcPerson + ? OC_SIZES.person.h + : archSize + ? archSize.h + : seqSize + ? seqSize.h + : flowSize + ? flowSize.h + : (node.measured?.height ?? DEFAULT_NODE_HEIGHT); +const width = archSize + ? (data.w ?? archSize.w) + : seqSize + ? (data.w ?? seqSize.w) + : flowSize + ? (data.w ?? flowSize.w) + : isOcPerson + ? (data.w ?? OC_SIZES.person.w) + : (data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH); +``` + +### Node Component Patterns + +All 5 node components follow the established pattern: + +```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 FlowProcessNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const icon = d.icon || "⚙️"; + + return ( +
+ {icon} + {d.label} + + + + +
+ ); +} +``` + +**Decision node — Diamond shape via CSS:** +```typescript +export function FlowDecisionNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} +``` + +**Terminal node — Stadium/pill shape:** +```typescript +export function FlowTerminalNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const isStart = d.tag === "start"; + const isEnd = d.tag === "end"; + + return ( +
+ {d.label} + + + + +
+ ); +} +``` + +**I/O node — Parallelogram via CSS skew:** +```typescript +export function FlowIoNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} +``` + +**Subprocess node — Double-bordered rectangle:** +```typescript +export function FlowSubprocessNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} +``` + +**Reuses `HIDDEN_HANDLE`** from `architecture/constants.ts` (established in Story 2.6). + +### Edge Component — Standard Orthogonal with Label + +```typescript +import { BaseEdge, getSmoothStepPath, EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function FlowEdge(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, + }); + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} +``` + +Uses `getSmoothStepPath` for orthogonal routing (right-angle connectors). `EdgeLabelRenderer` for HTML label at midpoint — critical for decision outcome labels (Yes/No). + +### Graph Converter Updates + +Replace the `// Future: flowchart` comment with real dispatch: + +```typescript +// In resolveFlowNodeType(): +if (diagramType === "flowchart" || nodeType.startsWith("flow:")) { + return resolveFlowchartNodeType(nodeType); +} +// Remove: // Future: flowchart +return "default"; // Truly unknown types + +// In resolveFlowEdgeType(): +if (diagramType === "flowchart") { + return resolveFlowchartEdgeType(edgeType); +} +return "default"; +``` + +**Import from flowchart/constants.ts** — same pattern as BPMN, E-R, Org Chart, Architecture, Sequence. + +**graphToFlow for flowchart:** Uses the standard default path (flat node mapping). No container nodes. No compound layout. + +**No CONTAINER_TYPES update needed:** Flowchart has no container nodes (unlike BPMN pools/lanes or sequence fragments). + +### DiagramCanvas Updates + +```typescript +import { FlowProcessNode } from "../../types/flowchart/FlowProcessNode"; +import { FlowDecisionNode } from "../../types/flowchart/FlowDecisionNode"; +import { FlowTerminalNode } from "../../types/flowchart/FlowTerminalNode"; +import { FlowIoNode } from "../../types/flowchart/FlowIoNode"; +import { FlowSubprocessNode } from "../../types/flowchart/FlowSubprocessNode"; +import { FlowEdge } from "../../types/flowchart/FlowEdge"; + +const nodeTypes = { + // Existing BPMN + E-R + Org Chart + Architecture + Sequence types... + flowProcess: FlowProcessNode, + flowDecision: FlowDecisionNode, + flowTerminal: FlowTerminalNode, + flowIo: FlowIoNode, + flowSubprocess: FlowSubprocessNode, +}; + +const edgeTypes = { + // Existing types... + flowEdge: FlowEdge, +}; + +// CONTAINER_TYPES — NO CHANGE needed (no flowchart containers) +``` + +**Arrow marker needed in MarkerDefs:** +```tsx +{/* Flowchart markers */} + + + +``` + +### CSS Styles for Flowchart + +Add to `globals.css`. The flowchart theme uses `--diagram-flowchart` (rose accent: `oklch(0.645 0.246 16)`, already defined at line 29). + +```css +/* ── Flowchart Diagram Styles ─────────────────────────────────── */ + +.flow-process { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + border-radius: 6px; + padding: 10px 16px; + min-width: 120px; + cursor: pointer; +} +.flow-process:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); +} + +.flow-decision { + display: flex; + align-items: center; + justify-content: center; + width: 140px; + height: 80px; + cursor: pointer; +} + +.flow-decision-diamond { + width: 90px; + height: 90px; + transform: rotate(45deg); + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + display: flex; + align-items: center; + justify-content: center; +} +.flow-decision-diamond:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); +} + +.flow-decision-label { + transform: rotate(-45deg); + font-weight: 600; + font-size: 11px; + color: var(--foreground); + text-align: center; + max-width: 80px; + word-wrap: break-word; + line-height: 1.3; +} + +.flow-terminal { + display: flex; + align-items: center; + justify-content: center; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + border-radius: 999px; + padding: 10px 24px; + min-width: 100px; + cursor: pointer; +} +.flow-terminal:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); +} +.flow-terminal-start { + border-color: var(--diagram-flowchart); + border-width: 2px; +} +.flow-terminal-end { + border-color: var(--diagram-flowchart); + border-width: 2.5px; +} + +.flow-io { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.flow-io-skew { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + padding: 10px 20px; + transform: skewX(-10deg); + min-width: 120px; +} +.flow-io-skew:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); +} +.flow-io-skew .flow-node-label { + transform: skewX(10deg); +} + +.flow-subprocess { + display: flex; + align-items: center; + justify-content: center; + background: var(--node-bg); + border: 2px solid var(--diagram-flowchart); + border-radius: 6px; + padding: 4px; + cursor: pointer; +} +.flow-subprocess:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); +} + +.flow-subprocess-inner { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--diagram-flowchart); + border-radius: 4px; + padding: 8px 14px; + width: 100%; +} + +.flow-node-icon { + font-size: 16px; + flex-shrink: 0; +} + +.flow-node-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.flow-edge-label { + font-size: 11px; + color: var(--diagram-flowchart); + background: var(--node-bg); + padding: 2px 8px; + border-radius: 4px; + border: 1px solid color-mix(in oklch, var(--diagram-flowchart) 30%, transparent); + font-weight: 600; + 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 flowchart node types, edge type, arrow marker | +| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **MODIFY** | Add flowchart type resolution for nodes and edges (replace `// Future: flowchart` comment) | +| `apps/web/src/modules/diagram/lib/elk-layout.ts` | **MODIFY** | Add `getFlowNodeSize()` to buildElkGraph sizing chain | +| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add flowchart CSS styles | +| `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 flowchart — 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 → standard ELK path for flowchart | +| `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, BaseEdge, getSmoothStepPath +- `elkjs` 0.11.0 — standard layered algorithm for flowchart layout +- `zustand` 5.0.8 — highlight state (reuse existing) + +### File Structure for This Story + +New files: +``` +apps/web/src/modules/diagram/ +├── types/flowchart/ +│ ├── constants.ts # FLOW_SIZES, type resolution functions, getFlowNodeSize +│ ├── constants.test.ts # Tests for flowchart constants +│ ├── FlowProcessNode.tsx # Rectangle - standard process step +│ ├── FlowDecisionNode.tsx # Diamond - decision/branching +│ ├── FlowTerminalNode.tsx # Stadium/pill - start and end nodes +│ ├── FlowIoNode.tsx # Parallelogram - input/output +│ ├── FlowSubprocessNode.tsx # Double-bordered rectangle - subprocess +│ └── FlowEdge.tsx # Orthogonal edge with label support +``` + +Modified files: +``` +apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Register flowchart types + marker +apps/web/src/modules/diagram/lib/graph-converter.ts # Flowchart type mapping (replace // Future: flowchart) +apps/web/src/modules/diagram/lib/graph-converter.test.ts # Add flowchart converter tests + fix line 150 assertion +apps/web/src/modules/diagram/lib/elk-layout.ts # Flowchart node sizing in buildElkGraph +apps/web/src/modules/diagram/lib/elk-layout.test.ts # Add flowchart sizing tests +apps/web/src/assets/styles/globals.css # Flowchart CSS styles +``` + +### Project Structure Notes + +- Flowchart node components go in `~/modules/diagram/types/flowchart/` — follows BPMN/E-R/Org Chart/Architecture/Sequence pattern +- Uses standard ELK layout — no separate layout module needed (unlike sequence) +- Tests co-located next to source files +- No barrel files — import from specific subpaths + +### Anti-Patterns to Avoid + +- **NEVER put `nodeTypes` or `edgeTypes` inside the component** — causes re-renders (established pattern) +- **NEVER hardcode positions for flowchart nodes in GraphData** — 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 layout-computed positions in persisted graph data** — positions are ephemeral +- **NEVER add new fields to DiagramNode or DiagramEdge** — use existing fields (`tag`, `icon`, `color`) with flowchart semantics +- **NEVER create inline style objects in render** — use CSS classes; conditionally apply inline styles only when data-driven +- **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 flowchart** — future story +- **DO NOT implement other diagram type renderers** — all 6 are now complete +- **DO NOT break existing tests** — 476 tests must continue passing (29 test files across monorepo) +- **DO NOT create a custom layout for flowchart** — ELK layered algorithm handles flowcharts perfectly +- **DO NOT add arrow markers inline** — use SVG `` in `MarkerDefs` with `markerEnd="url(#flow-arrow)"` +- **DO NOT create multiple edge types** — flowcharts need only one edge type with optional labels for decision outcomes + +### Previous Story Intelligence (Story 2.7 — Sequence) + +**Key learnings to carry forward:** +- Constants file pattern: `SEQ_SIZES` → `FLOW_SIZES` equivalent. Type resolution functions: `resolve*NodeType()`, `resolve*EdgeType()`, `get*NodeSize()` +- Node component pattern: Cast `data` as `DiagramNode & { label: string }`, use `Handle` with `style={HIDDEN_HANDLE}` (constant from architecture/constants.ts — reuse it) +- Edge component: For flowchart, use `BaseEdge` + `getSmoothStepPath` (unlike sequence which uses custom SVG paths). This is simpler — matches the E-R/Architecture/OrgChart pattern +- 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.color` directly +- `flowNodeToGraphNode` already preserves `tag`, `icon`, `color` fields — roundtrip works +- Code review lessons from previous stories: Remove dead helper functions, use `||` not `??` for empty string color guards, reuse `HIDDEN_HANDLE` constant +- 476 tests currently pass across monorepo — don't break them +- `computeLayout` returns `LayoutResult{nodes, edges?}` — flowchart uses standard path so `edges` is not returned (only ELK node positions updated) + +### Previous Story Intelligence (Story 2.3 — BPMN) + +**Key learnings relevant to flowchart:** +- BPMN's `MarkerDefs` pattern: `#bpmn-arrow-filled` — reuse same SVG marker structure for `#flow-arrow` +- Standard edge with `BaseEdge` + `getSmoothStepPath` is the simplest edge pattern — use it for flowchart +- BPMN's compound detection in `computeLayout()` shows the detection routing pattern — flowchart does NOT need this (uses standard path) + +### Git Intelligence + +Recent commits: +- `1ff8ff8 feat: implement Stories 2.4-2.7 — E-R, Org Chart, Architecture, Sequence diagram type renderers` +- `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 flowchart config: `{ label: "Flowchart", icon: Icons.GitBranch, color: "text-rose-500" }` +- CSS variable `--diagram-flowchart: oklch(0.645 0.246 16)` already defined in globals.css (line 29) +- Graph-converter.ts line 50 has `// Future: flowchart` — the exact hook point to replace +- Graph-converter.test.ts line 150 expects `"default"` for `flow:process` — must update to `"flowProcess"` after adding resolver + +### Latest Tech Information + +**@xyflow/react 12.10.1 — BaseEdge + getSmoothStepPath:** +- `getSmoothStepPath` produces orthogonal (right-angle) edge paths — ideal for flowcharts +- Returns `[path, labelX, labelY]` tuple — `labelX`/`labelY` are the midpoint coordinates for label placement +- `BaseEdge` wraps the path in a proper SVG `` with all @xyflow/react edge features (selection, animation, etc.) +- `EdgeLabelRenderer` renders HTML labels in a separate div layer above the SVG — use for decision labels +- `markerEnd` on `BaseEdge` references SVG markers from `` — same as all other diagram types + +**CSS Diamond Shape for Decision Nodes:** +- Standard technique: `transform: rotate(45deg)` on container, `transform: rotate(-45deg)` on content +- Container dimensions should be square (the diamond "width" = side * √2) +- Handle positions work correctly with transformed containers in @xyflow/react — handles are positioned on the outer bounding box +- Alternative: Use CSS `clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)` — simpler but may have handle positioning issues + +**CSS Parallelogram for I/O Nodes:** +- `transform: skewX(-10deg)` on container, `transform: skewX(10deg)` on content (counter-skew) +- Keeps text readable while giving the parallelogram visual +- Handles work correctly since @xyflow/react uses bounding box positioning + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.8] — Full AC: process, decision, terminal, I/O, subprocess shapes; decision labels; ELK top-down layout +- [Source: _bmad-output/planning-artifacts/epics.md#Technical Notes] — Port Flexicar FlowDiagram nodes; custom nodes at types/flowchart/; rose accent; standard ELK layered; parallel development +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model: `flow:` prefix, shared base fields +- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules, type prefixing: `flow:` +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Diagram Type Color Accents] — Flowchart: rose oklch(0.645 0.246 16) +- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#DiagramEdgeRenderer] — Flowchart edges: standard directed flow +- [Source: _bmad-output/implementation-artifacts/2-7-sequence-diagram-type-renderer.md] — Sequence patterns: constants, nodes, edges, converter, canvas registration, HIDDEN_HANDLE, code review learnings +- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramNode (tag, icon, color), DiagramType includes "flowchart" (line 7) +- [Source: apps/web/src/modules/diagram/lib/graph-converter.ts] — Line 50: `// Future: flowchart` — exact hook point to replace +- [Source: apps/web/src/modules/diagram/lib/graph-converter.test.ts] — Line 150: `flow:process` currently resolves to "default" — update to "flowProcess" +- [Source: apps/web/src/modules/diagram/lib/elk-layout.ts] — buildElkGraph() sizing chain needs flowSize addition after seqSize +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to register flowchart types + marker +- [Source: apps/web/src/modules/diagram/components/DiagramCard.tsx] — Flowchart config with Icons.GitBranch and text-rose-500 +- [Source: apps/web/src/assets/styles/globals.css] — Line 29: `--diagram-flowchart: oklch(0.645 0.246 16)` already defined + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (1M context) + +### Debug Log References + +No issues encountered. One pre-existing test (`graphNodeToFlowNode` basic node) expected `"default"` type for `flow:process` — updated to `"flowProcess"` since the `flow:` prefix now correctly resolves via the new flowchart resolver. + +### Completion Notes List + +- Created flowchart constants with FLOW_SIZES (5 subtypes), resolveFlowchartNodeType, resolveFlowchartEdgeType, getFlowNodeSize using FLOW_TYPE_MAP for O(1) lookup +- Implemented FlowProcessNode: rectangle with icon + label, rose accent border, 4 hidden handles +- Implemented FlowDecisionNode: diamond shape via CSS rotate(45deg) with counter-rotated label +- Implemented FlowTerminalNode: stadium/pill shape (border-radius: 999px), data.tag-based start/end distinction with varying border widths +- Implemented FlowIoNode: parallelogram shape via CSS skewX(-10deg) with counter-skewed label +- Implemented FlowSubprocessNode: double-bordered rectangle (outer 2px + inner 1px borders) +- Implemented FlowEdge: BaseEdge + getSmoothStepPath for orthogonal routing, EdgeLabelRenderer for decision outcome labels (Yes/No), markerEnd flow-arrow +- Updated graph-converter.ts: replaced `// Future: flowchart` with real dispatch for both node and edge resolution, added flowchart edge type handling +- Registered all flowchart node/edge types in DiagramCanvas nodeTypes/edgeTypes (OUTSIDE component), added #flow-arrow SVG marker to MarkerDefs +- Added getFlowNodeSize to elk-layout.ts buildElkGraph sizing chain after seqSize +- Added flowchart CSS styles with --diagram-flowchart rose accent, color-mix hover states, all 5 node shapes + edge label styling +- Updated existing test assertions: flow:process now resolves to flowProcess (previously "default") +- Added 15 flowchart constants tests, 6 graph converter flowchart tests, 4 ELK layout flowchart sizing tests +- All 497 tests pass across 30 test files (0 regressions, +21 new tests) + +### File List + +**New files:** +- apps/web/src/modules/diagram/types/flowchart/constants.ts +- apps/web/src/modules/diagram/types/flowchart/constants.test.ts +- apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx +- apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx +- apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx +- apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx +- apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx +- apps/web/src/modules/diagram/types/flowchart/FlowEdge.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 + +### Change Log + +- 2026-02-27: Implemented Story 2.8 — Flowchart Diagram Type Renderer. Added 5 node components (process, decision, terminal, I/O, subprocess), 1 edge component with label support, constants + type registry, graph converter integration, ELK layout sizing, CSS styles with rose accent, DiagramCanvas registration + arrow marker. 497 tests passing. +- 2026-02-27: Code review (adversarial) — 7 issues found (4M, 3L), all fixed. M1: Decision diamond ELK height increased 80→130 to fit rotated 90px square. M2: Removed forced ⚙️ icon default from FlowProcessNode, now only renders when data.icon is truthy. M3: Added data.color border override support to all 5 node components. M4: Extracted getNodeDimensions() helper in elk-layout.ts to replace 6-level nested ternary. L1: Unified constants.ts to use lookup maps for both type resolution and sizing. L2: Removed dead gap:8px from .flow-io-skew CSS. L3: Added sprint-status.yaml to File List. 180 web tests passing, 0 regressions. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 4b37744..8b9983c 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -57,7 +57,7 @@ development_status: 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-8-flowchart-diagram-type-renderer: done 2-9-node-selection-and-manual-repositioning: backlog epic-2-retrospective: optional diff --git a/apps/web/src/assets/styles/globals.css b/apps/web/src/assets/styles/globals.css index ec6c535..e114191 100644 --- a/apps/web/src/assets/styles/globals.css +++ b/apps/web/src/assets/styles/globals.css @@ -650,6 +650,153 @@ position: absolute; } + /* ── Flowchart Diagram Styles ─────────────────────────────────── */ + + .flow-process { + display: flex; + align-items: center; + gap: 8px; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + border-radius: 6px; + padding: 10px 16px; + min-width: 120px; + cursor: pointer; + } + .flow-process:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); + } + + .flow-decision { + display: flex; + align-items: center; + justify-content: center; + width: 140px; + height: 80px; + cursor: pointer; + } + + .flow-decision-diamond { + width: 90px; + height: 90px; + transform: rotate(45deg); + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + display: flex; + align-items: center; + justify-content: center; + } + .flow-decision-diamond:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); + } + + .flow-decision-label { + transform: rotate(-45deg); + font-weight: 600; + font-size: 11px; + color: var(--foreground); + text-align: center; + max-width: 80px; + word-wrap: break-word; + line-height: 1.3; + } + + .flow-terminal { + display: flex; + align-items: center; + justify-content: center; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + border-radius: 999px; + padding: 10px 24px; + min-width: 100px; + cursor: pointer; + } + .flow-terminal:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); + } + .flow-terminal-start { + border-color: var(--diagram-flowchart); + border-width: 2px; + } + .flow-terminal-end { + border-color: var(--diagram-flowchart); + border-width: 2.5px; + } + + .flow-io { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + .flow-io-skew { + display: flex; + align-items: center; + background: var(--node-bg); + border: 1.5px solid var(--diagram-flowchart); + padding: 10px 20px; + transform: skewX(-10deg); + min-width: 120px; + } + .flow-io-skew:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); + } + .flow-io-skew .flow-node-label { + transform: skewX(10deg); + } + + .flow-subprocess { + display: flex; + align-items: center; + justify-content: center; + background: var(--node-bg); + border: 2px solid var(--diagram-flowchart); + border-radius: 6px; + padding: 4px; + cursor: pointer; + } + .flow-subprocess:hover { + background: color-mix(in oklch, var(--diagram-flowchart) 8%, var(--node-bg)); + } + + .flow-subprocess-inner { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--diagram-flowchart); + border-radius: 4px; + padding: 8px 14px; + width: 100%; + } + + .flow-node-icon { + font-size: 16px; + flex-shrink: 0; + } + + .flow-node-label { + font-weight: 600; + font-size: 12px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .flow-edge-label { + font-size: 11px; + color: var(--diagram-flowchart); + background: var(--node-bg); + padding: 2px 8px; + border-radius: 4px; + border: 1px solid color-mix(in oklch, var(--diagram-flowchart) 30%, transparent); + font-weight: 600; + pointer-events: none; + white-space: nowrap; + } + /* ── Path Highlighting ────────────────────────────────────────────────── */ .react-flow__node.dimmed, diff --git a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx index f508f02..19eaacb 100644 --- a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +++ b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx @@ -47,6 +47,12 @@ import { SeqFragmentNode } from "../../types/sequence/SeqFragmentNode"; import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge"; import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge"; import { SeqReturnEdge } from "../../types/sequence/SeqReturnEdge"; +import { FlowProcessNode } from "../../types/flowchart/FlowProcessNode"; +import { FlowDecisionNode } from "../../types/flowchart/FlowDecisionNode"; +import { FlowTerminalNode } from "../../types/flowchart/FlowTerminalNode"; +import { FlowIoNode } from "../../types/flowchart/FlowIoNode"; +import { FlowSubprocessNode } from "../../types/flowchart/FlowSubprocessNode"; +import { FlowEdge } from "../../types/flowchart/FlowEdge"; const nodeTypes = { bpmnActivity: BpmnActivityNode, @@ -70,6 +76,11 @@ const nodeTypes = { archExternal: ArchExternalNode, seqParticipant: SeqParticipantNode, seqFragment: SeqFragmentNode, + flowProcess: FlowProcessNode, + flowDecision: FlowDecisionNode, + flowTerminal: FlowTerminalNode, + flowIo: FlowIoNode, + flowSubprocess: FlowSubprocessNode, }; const edgeTypes = { @@ -82,6 +93,7 @@ const edgeTypes = { seqSync: SeqSyncEdge, seqAsync: SeqAsyncEdge, seqReturn: SeqReturnEdge, + flowEdge: FlowEdge, }; /** Container node types that should not participate in BFS highlighting */ @@ -183,6 +195,21 @@ function MarkerDefs() { strokeWidth={1.5} /> + {/* Flowchart markers */} + + + ); diff --git a/apps/web/src/modules/diagram/lib/elk-layout.test.ts b/apps/web/src/modules/diagram/lib/elk-layout.test.ts index 438d224..9ba7402 100644 --- a/apps/web/src/modules/diagram/lib/elk-layout.test.ts +++ b/apps/web/src/modules/diagram/lib/elk-layout.test.ts @@ -347,6 +347,79 @@ describe("buildElkGraph", () => { expect(result.children?.[0]?.height).toBe(50); }); + it("should use FLOW_SIZES for flowchart node subtypes", () => { + const flowNodes: Node[] = [ + { + id: "step1", + type: "flowProcess", + position: { x: 0, y: 0 }, + data: { id: "step1", type: "flow:process", label: "Step 1" }, + }, + { + id: "check1", + type: "flowDecision", + position: { x: 0, y: 0 }, + data: { id: "check1", type: "flow:decision", label: "OK?" }, + }, + { + id: "start", + type: "flowTerminal", + position: { x: 0, y: 0 }, + data: { id: "start", type: "flow:terminal", label: "Start", tag: "start" }, + }, + { + id: "input1", + type: "flowIo", + position: { x: 0, y: 0 }, + data: { id: "input1", type: "flow:io", label: "Read Input" }, + }, + { + id: "sub1", + type: "flowSubprocess", + position: { x: 0, y: 0 }, + data: { id: "sub1", type: "flow:subprocess", label: "Validate" }, + }, + ]; + + const result = buildElkGraph(flowNodes, []); + + // flowProcess: w=160, h=60 + expect(result.children?.[0]?.width).toBe(160); + expect(result.children?.[0]?.height).toBe(60); + // flowDecision: w=140, h=130 + expect(result.children?.[1]?.width).toBe(140); + expect(result.children?.[1]?.height).toBe(130); + // flowTerminal: w=140, h=50 + expect(result.children?.[2]?.width).toBe(140); + expect(result.children?.[2]?.height).toBe(50); + // flowIo: w=160, h=60 + expect(result.children?.[3]?.width).toBe(160); + expect(result.children?.[3]?.height).toBe(60); + // flowSubprocess: w=160, h=60 + expect(result.children?.[4]?.width).toBe(160); + expect(result.children?.[4]?.height).toBe(60); + }); + + it("should respect data.w override for flowchart nodes", () => { + const flowNode: Node = { + id: "step1", + type: "flowProcess", + position: { x: 0, y: 0 }, + data: { id: "step1", type: "flow:process", label: "Wide Step", w: 250 }, + }; + const result = buildElkGraph([flowNode], []); + expect(result.children?.[0]?.width).toBe(250); + expect(result.children?.[0]?.height).toBe(60); + }); + + it("should NOT use flowchart sizing for non-flowchart types", () => { + const regularNode = createNode("n1"); + const result = buildElkGraph([regularNode], []); + // Regular node: default dimensions (150x50), NOT flowchart sizes + expect(result.children?.[0]?.width).toBe(150); + expect(result.children?.[0]?.height).toBe(50); + }); + it("should handle empty nodes and edges", () => { const result = buildElkGraph([], []); diff --git a/apps/web/src/modules/diagram/lib/elk-layout.ts b/apps/web/src/modules/diagram/lib/elk-layout.ts index 406d061..df0e9b8 100644 --- a/apps/web/src/modules/diagram/lib/elk-layout.ts +++ b/apps/web/src/modules/diagram/lib/elk-layout.ts @@ -12,6 +12,7 @@ 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 { getFlowNodeSize } from "../types/flowchart/constants"; import { computeSequenceLayout } from "./sequence-layout"; // ── Layout Options ────────────────────────────────────────────────────────── @@ -37,6 +38,46 @@ const DEFAULT_NODE_WIDTH = 150; const DEFAULT_NODE_HEIGHT = 50; export const SOFT_CAP_NODE_COUNT = 200; +// ── Node Dimension Resolution ───────────────────────────────────────────── + +function getNodeDimensions(node: Node): { w: number; h: number } { + const data = node.data as unknown as DiagramNode; + + // E-R entities: height computed from column count + if (node.type === "erEntity" && data.columns) { + return { w: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH, h: getErEntityHeight(data.columns) }; + } + + // Org chart persons: fixed dimensions + if (node.type === "orgchartPerson") { + return { w: data.w ?? OC_SIZES.person.w, h: OC_SIZES.person.h }; + } + + // Architecture nodes: per-subtype dimensions + const archSize = getArchNodeSize(node.type); + if (archSize) { + return { w: data.w ?? archSize.w, h: archSize.h }; + } + + // Sequence nodes: per-subtype dimensions + const seqSize = getSeqNodeSize(node.type); + if (seqSize) { + return { w: data.w ?? seqSize.w, h: seqSize.h }; + } + + // Flowchart nodes: per-subtype dimensions + const flowSize = getFlowNodeSize(node.type); + if (flowSize) { + return { w: data.w ?? flowSize.w, h: flowSize.h }; + } + + // Default: measured or fallback + return { + w: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH, + h: node.measured?.height ?? DEFAULT_NODE_HEIGHT, + }; +} + // ── ELK Graph Building ───────────────────────────────────────────────────── export function buildElkGraph( @@ -60,37 +101,8 @@ export function buildElkGraph( "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", }, 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, - height, - }; + const { w, h } = getNodeDimensions(node); + return { id: node.id, width: w, height: h }; }), edges: edges.map( (edge) => diff --git a/apps/web/src/modules/diagram/lib/graph-converter.test.ts b/apps/web/src/modules/diagram/lib/graph-converter.test.ts index 285ec09..520cad3 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.test.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.test.ts @@ -24,7 +24,7 @@ describe("graphNodeToFlowNode", () => { const result = graphNodeToFlowNode(node); expect(result).toEqual({ id: "n1", - type: "default", + type: "flowProcess", position: { x: 100, y: 200 }, data: { ...node, label: "Start" }, }); @@ -146,8 +146,8 @@ describe("graphToFlow", () => { 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"); + // flow: prefix resolves to flowchart node type even without diagramType context + expect(result.nodes[5]!.type).toBe("flowProcess"); }); it("should resolve architecture node types when diagramType is architecture", () => { @@ -440,6 +440,75 @@ describe("graphToFlow", () => { expect(result.edges).toHaveLength(2); }); + it("should resolve flowchart node types when diagramType is flowchart", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Flowchart Test", + diagramType: "flowchart", + }, + nodes: [ + { id: "n1", type: "process", label: "Step 1" }, + { id: "n2", type: "flow:decision", label: "Check?" }, + { id: "n3", type: "flow:terminal", label: "Start", tag: "start" }, + { id: "n4", type: "flow:io", label: "Read Input" }, + { id: "n5", type: "flow:subprocess", label: "Validate" }, + ], + edges: [], + }; + const result = graphToFlow(data); + expect(result.nodes[0]!.type).toBe("flowProcess"); + expect(result.nodes[1]!.type).toBe("flowDecision"); + expect(result.nodes[2]!.type).toBe("flowTerminal"); + expect(result.nodes[3]!.type).toBe("flowIo"); + expect(result.nodes[4]!.type).toBe("flowSubprocess"); + }); + + it("should resolve flowchart edge types when diagramType is flowchart", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Flowchart Edge Test", + diagramType: "flowchart", + }, + nodes: [ + { id: "start", type: "flow:terminal", label: "Start" }, + { id: "step1", type: "flow:process", label: "Step 1" }, + ], + edges: [ + { id: "e1", from: "start", to: "step1", type: "sequence" }, + { id: "e2", from: "start", to: "step1" }, + ], + }; + const result = graphToFlow(data); + expect(result.edges[0]!.type).toBe("flowEdge"); + expect(result.edges[1]!.type).toBe("flowEdge"); + }); + + it("should use flat layout for flowchart diagrams (no container nodes)", () => { + const data: GraphData = { + meta: { + version: "1.0", + title: "Flowchart Flat Layout", + diagramType: "flowchart", + }, + nodes: [ + { id: "start", type: "flow:terminal", label: "Start", tag: "start" }, + { id: "step1", type: "flow:process", label: "Process" }, + { id: "check", type: "flow:decision", label: "OK?" }, + { id: "end", type: "flow:terminal", label: "End", tag: "end" }, + ], + edges: [ + { id: "e1", from: "start", to: "step1" }, + { id: "e2", from: "step1", to: "check" }, + { id: "e3", from: "check", to: "end", label: "Yes" }, + ], + }; + const result = graphToFlow(data); + expect(result.nodes).toHaveLength(4); + expect(result.edges).toHaveLength(3); + }); + it("should use flat layout for org chart diagrams (no container nodes)", () => { const data: GraphData = { meta: { diff --git a/apps/web/src/modules/diagram/lib/graph-converter.ts b/apps/web/src/modules/diagram/lib/graph-converter.ts index e12c07b..8b0f3df 100644 --- a/apps/web/src/modules/diagram/lib/graph-converter.ts +++ b/apps/web/src/modules/diagram/lib/graph-converter.ts @@ -25,6 +25,10 @@ import { resolveSequenceNodeType, resolveSequenceEdgeType, } from "../types/sequence/constants"; +import { + resolveFlowchartNodeType, + resolveFlowchartEdgeType, +} from "../types/flowchart/constants"; // ── Node Type Resolution ─────────────────────────────────────────────────── @@ -47,7 +51,9 @@ function resolveFlowNodeType( if (diagramType === "sequence" || nodeType.startsWith("seq:")) { return resolveSequenceNodeType(nodeType); } - // Future: flowchart + if (diagramType === "flowchart" || nodeType.startsWith("flow:")) { + return resolveFlowchartNodeType(nodeType); + } return "default"; } @@ -70,6 +76,9 @@ function resolveFlowEdgeType( if (diagramType === "sequence") { return resolveSequenceEdgeType(edgeType); } + if (diagramType === "flowchart") { + return resolveFlowchartEdgeType(edgeType); + } return "default"; } diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx new file mode 100644 index 0000000..e6f404a --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowDecisionNode.tsx @@ -0,0 +1,37 @@ +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 FlowDecisionNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx new file mode 100644 index 0000000..39f67e2 --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx @@ -0,0 +1,37 @@ +import { BaseEdge, getSmoothStepPath, EdgeLabelRenderer } from "@xyflow/react"; +import type { EdgeProps } from "@xyflow/react"; + +export function FlowEdge(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, + }); + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx new file mode 100644 index 0000000..4847662 --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx @@ -0,0 +1,37 @@ +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 FlowIoNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx new file mode 100644 index 0000000..7fbd7fe --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowProcessNode.tsx @@ -0,0 +1,36 @@ +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 FlowProcessNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+ {d.icon && {d.icon}} + {d.label} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx new file mode 100644 index 0000000..f198ede --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowSubprocessNode.tsx @@ -0,0 +1,40 @@ +import { Handle, Position } from "@xyflow/react"; +import type { NodeProps } from "@xyflow/react"; +import type { DiagramNode } from "../graph"; +import { HIDDEN_HANDLE } from "../architecture/constants"; + +export function FlowSubprocessNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + + return ( +
+
+ {d.label} +
+ + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx b/apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx new file mode 100644 index 0000000..a0c096f --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/FlowTerminalNode.tsx @@ -0,0 +1,37 @@ +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 FlowTerminalNode({ data }: NodeProps) { + const d = data as unknown as DiagramNode & { label: string }; + const isStart = d.tag === "start"; + const isEnd = d.tag === "end"; + + return ( +
+ {d.label} + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/types/flowchart/constants.test.ts b/apps/web/src/modules/diagram/types/flowchart/constants.test.ts new file mode 100644 index 0000000..f189d21 --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/constants.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { + FLOW_SIZES, + resolveFlowchartNodeType, + resolveFlowchartEdgeType, + getFlowNodeSize, +} from "./constants"; + +describe("FLOW_SIZES", () => { + it("should define sizes for all 5 flowchart node subtypes", () => { + expect(FLOW_SIZES.process).toEqual({ w: 160, h: 60 }); + expect(FLOW_SIZES.decision).toEqual({ w: 140, h: 130 }); + expect(FLOW_SIZES.terminal).toEqual({ w: 140, h: 50 }); + expect(FLOW_SIZES.io).toEqual({ w: 160, h: 60 }); + expect(FLOW_SIZES.subprocess).toEqual({ w: 160, h: 60 }); + }); +}); + +describe("resolveFlowchartNodeType", () => { + it("should resolve flow:process to flowProcess", () => { + expect(resolveFlowchartNodeType("flow:process")).toBe("flowProcess"); + }); + + it("should resolve flow:decision to flowDecision", () => { + expect(resolveFlowchartNodeType("flow:decision")).toBe("flowDecision"); + }); + + it("should resolve flow:terminal to flowTerminal", () => { + expect(resolveFlowchartNodeType("flow:terminal")).toBe("flowTerminal"); + }); + + it("should resolve flow:io to flowIo", () => { + expect(resolveFlowchartNodeType("flow:io")).toBe("flowIo"); + }); + + it("should resolve flow:subprocess to flowSubprocess", () => { + expect(resolveFlowchartNodeType("flow:subprocess")).toBe("flowSubprocess"); + }); + + it("should resolve bare type without prefix", () => { + expect(resolveFlowchartNodeType("process")).toBe("flowProcess"); + expect(resolveFlowchartNodeType("decision")).toBe("flowDecision"); + expect(resolveFlowchartNodeType("terminal")).toBe("flowTerminal"); + expect(resolveFlowchartNodeType("io")).toBe("flowIo"); + expect(resolveFlowchartNodeType("subprocess")).toBe("flowSubprocess"); + }); + + it("should default unknown types to flowProcess", () => { + expect(resolveFlowchartNodeType("unknown")).toBe("flowProcess"); + expect(resolveFlowchartNodeType("flow:unknown")).toBe("flowProcess"); + }); +}); + +describe("resolveFlowchartEdgeType", () => { + it("should always return flowEdge", () => { + expect(resolveFlowchartEdgeType("sequence")).toBe("flowEdge"); + expect(resolveFlowchartEdgeType("conditional")).toBe("flowEdge"); + expect(resolveFlowchartEdgeType(undefined)).toBe("flowEdge"); + }); +}); + +describe("getFlowNodeSize", () => { + it("should return correct size for flowProcess", () => { + expect(getFlowNodeSize("flowProcess")).toEqual({ w: 160, h: 60 }); + }); + + it("should return correct size for flowDecision", () => { + expect(getFlowNodeSize("flowDecision")).toEqual({ w: 140, h: 130 }); + }); + + it("should return correct size for flowTerminal", () => { + expect(getFlowNodeSize("flowTerminal")).toEqual({ w: 140, h: 50 }); + }); + + it("should return correct size for flowIo", () => { + expect(getFlowNodeSize("flowIo")).toEqual({ w: 160, h: 60 }); + }); + + it("should return correct size for flowSubprocess", () => { + expect(getFlowNodeSize("flowSubprocess")).toEqual({ w: 160, h: 60 }); + }); + + it("should return null for non-flowchart types", () => { + expect(getFlowNodeSize("archService")).toBeNull(); + expect(getFlowNodeSize("erEntity")).toBeNull(); + expect(getFlowNodeSize("default")).toBeNull(); + expect(getFlowNodeSize(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/diagram/types/flowchart/constants.ts b/apps/web/src/modules/diagram/types/flowchart/constants.ts new file mode 100644 index 0000000..00301e3 --- /dev/null +++ b/apps/web/src/modules/diagram/types/flowchart/constants.ts @@ -0,0 +1,45 @@ +/** Flowchart diagram node dimensions for ELK layout spacing. */ +export const FLOW_SIZES = { + process: { w: 160, h: 60 }, + decision: { w: 140, h: 130 }, + terminal: { w: 140, h: 50 }, + io: { w: 160, h: 60 }, + subprocess: { w: 160, h: 60 }, +} as const; + +const FLOW_TYPE_MAP: Record = { + flowProcess: FLOW_SIZES.process, + flowDecision: FLOW_SIZES.decision, + flowTerminal: FLOW_SIZES.terminal, + flowIo: FLOW_SIZES.io, + flowSubprocess: FLOW_SIZES.subprocess, +}; + +const FLOW_NODE_TYPE_MAP: Record = { + process: "flowProcess", + decision: "flowDecision", + terminal: "flowTerminal", + io: "flowIo", + subprocess: "flowSubprocess", +}; + +/** Get flowchart node dimensions by @xyflow/react node type. Returns null if not a flowchart type. */ +export function getFlowNodeSize( + flowType: string | undefined, +): { w: number; h: number } | null { + if (!flowType) return null; + return FLOW_TYPE_MAP[flowType] ?? null; +} + +/** Map DiagramNode.type (with or without flow: prefix) to @xyflow/react node type string. */ +export function resolveFlowchartNodeType(type: string): string { + const bare = type.startsWith("flow:") ? type.slice(5) : type; + return FLOW_NODE_TYPE_MAP[bare] ?? "flowProcess"; +} + +/** Map DiagramEdge.type to @xyflow/react edge type string for flowchart diagrams. */ +export function resolveFlowchartEdgeType( + _type: string | undefined, +): string { + return "flowEdge"; +}