feat: implement Story 2.8 — Flowchart diagram type renderer
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,876 @@
|
|||||||
|
# Story 2.8: Flowchart 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 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 (
|
||||||
|
<div className="flow-process">
|
||||||
|
<span className="flow-node-icon">{icon}</span>
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision node — Diamond shape via CSS:**
|
||||||
|
```typescript
|
||||||
|
export function FlowDecisionNode({ data }: NodeProps) {
|
||||||
|
const d = data as unknown as DiagramNode & { label: string };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-decision">
|
||||||
|
<div className="flow-decision-diamond">
|
||||||
|
<span className="flow-decision-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 (
|
||||||
|
<div className={`flow-terminal ${isStart ? "flow-terminal-start" : ""} ${isEnd ? "flow-terminal-end" : ""}`}>
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**I/O node — Parallelogram via CSS skew:**
|
||||||
|
```typescript
|
||||||
|
export function FlowIoNode({ data }: NodeProps) {
|
||||||
|
const d = data as unknown as DiagramNode & { label: string };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-io">
|
||||||
|
<div className="flow-io-skew">
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Subprocess node — Double-bordered rectangle:**
|
||||||
|
```typescript
|
||||||
|
export function FlowSubprocessNode({ data }: NodeProps) {
|
||||||
|
const d = data as unknown as DiagramNode & { label: string };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-subprocess">
|
||||||
|
<div className="flow-subprocess-inner">
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
id={props.id}
|
||||||
|
path={edgePath}
|
||||||
|
style={{ stroke: "var(--diagram-flowchart)", strokeWidth: 1.5 }}
|
||||||
|
markerEnd="url(#flow-arrow)"
|
||||||
|
/>
|
||||||
|
{props.label && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
className="flow-edge-label"
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
position: "absolute",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<marker
|
||||||
|
id="flow-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-flowchart, #e11d48)"
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 `<marker>` 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 `<path>` 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 `<defs>` — 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.
|
||||||
@@ -57,7 +57,7 @@ development_status:
|
|||||||
2-5-org-chart-diagram-type-renderer: done
|
2-5-org-chart-diagram-type-renderer: done
|
||||||
2-6-architecture-diagram-type-renderer: done
|
2-6-architecture-diagram-type-renderer: done
|
||||||
2-7-sequence-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
|
2-9-node-selection-and-manual-repositioning: backlog
|
||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
|
|||||||
@@ -650,6 +650,153 @@
|
|||||||
position: absolute;
|
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 ────────────────────────────────────────────────── */
|
/* ── Path Highlighting ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.react-flow__node.dimmed,
|
.react-flow__node.dimmed,
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ import { SeqFragmentNode } from "../../types/sequence/SeqFragmentNode";
|
|||||||
import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge";
|
import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge";
|
||||||
import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge";
|
import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge";
|
||||||
import { SeqReturnEdge } from "../../types/sequence/SeqReturnEdge";
|
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 = {
|
const nodeTypes = {
|
||||||
bpmnActivity: BpmnActivityNode,
|
bpmnActivity: BpmnActivityNode,
|
||||||
@@ -70,6 +76,11 @@ const nodeTypes = {
|
|||||||
archExternal: ArchExternalNode,
|
archExternal: ArchExternalNode,
|
||||||
seqParticipant: SeqParticipantNode,
|
seqParticipant: SeqParticipantNode,
|
||||||
seqFragment: SeqFragmentNode,
|
seqFragment: SeqFragmentNode,
|
||||||
|
flowProcess: FlowProcessNode,
|
||||||
|
flowDecision: FlowDecisionNode,
|
||||||
|
flowTerminal: FlowTerminalNode,
|
||||||
|
flowIo: FlowIoNode,
|
||||||
|
flowSubprocess: FlowSubprocessNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const edgeTypes = {
|
const edgeTypes = {
|
||||||
@@ -82,6 +93,7 @@ const edgeTypes = {
|
|||||||
seqSync: SeqSyncEdge,
|
seqSync: SeqSyncEdge,
|
||||||
seqAsync: SeqAsyncEdge,
|
seqAsync: SeqAsyncEdge,
|
||||||
seqReturn: SeqReturnEdge,
|
seqReturn: SeqReturnEdge,
|
||||||
|
flowEdge: FlowEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Container node types that should not participate in BFS highlighting */
|
/** Container node types that should not participate in BFS highlighting */
|
||||||
@@ -183,6 +195,21 @@ function MarkerDefs() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</marker>
|
</marker>
|
||||||
|
{/* Flowchart markers */}
|
||||||
|
<marker
|
||||||
|
id="flow-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-flowchart, #e11d48)"
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -347,6 +347,79 @@ describe("buildElkGraph", () => {
|
|||||||
expect(result.children?.[0]?.height).toBe(50);
|
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", () => {
|
it("should handle empty nodes and edges", () => {
|
||||||
const result = buildElkGraph([], []);
|
const result = buildElkGraph([], []);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getErEntityHeight } from "../types/er/constants";
|
|||||||
import { OC_SIZES } from "../types/orgchart/constants";
|
import { OC_SIZES } from "../types/orgchart/constants";
|
||||||
import { getArchNodeSize } from "../types/architecture/constants";
|
import { getArchNodeSize } from "../types/architecture/constants";
|
||||||
import { getSeqNodeSize } from "../types/sequence/constants";
|
import { getSeqNodeSize } from "../types/sequence/constants";
|
||||||
|
import { getFlowNodeSize } from "../types/flowchart/constants";
|
||||||
import { computeSequenceLayout } from "./sequence-layout";
|
import { computeSequenceLayout } from "./sequence-layout";
|
||||||
|
|
||||||
// ── Layout Options ──────────────────────────────────────────────────────────
|
// ── Layout Options ──────────────────────────────────────────────────────────
|
||||||
@@ -37,6 +38,46 @@ const DEFAULT_NODE_WIDTH = 150;
|
|||||||
const DEFAULT_NODE_HEIGHT = 50;
|
const DEFAULT_NODE_HEIGHT = 50;
|
||||||
export const SOFT_CAP_NODE_COUNT = 200;
|
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 ─────────────────────────────────────────────────────
|
// ── ELK Graph Building ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildElkGraph(
|
export function buildElkGraph(
|
||||||
@@ -60,37 +101,8 @@ export function buildElkGraph(
|
|||||||
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
||||||
},
|
},
|
||||||
children: nodes.map((node) => {
|
children: nodes.map((node) => {
|
||||||
const data = node.data as unknown as DiagramNode;
|
const { w, h } = getNodeDimensions(node);
|
||||||
// E-R entities: compute height from columns for correct ELK spacing
|
return { id: node.id, width: w, height: h };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
edges: edges.map(
|
edges: edges.map(
|
||||||
(edge) =>
|
(edge) =>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("graphNodeToFlowNode", () => {
|
|||||||
const result = graphNodeToFlowNode(node);
|
const result = graphNodeToFlowNode(node);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: "n1",
|
id: "n1",
|
||||||
type: "default",
|
type: "flowProcess",
|
||||||
position: { x: 100, y: 200 },
|
position: { x: 100, y: 200 },
|
||||||
data: { ...node, label: "Start" },
|
data: { ...node, label: "Start" },
|
||||||
});
|
});
|
||||||
@@ -146,8 +146,8 @@ describe("graphToFlow", () => {
|
|||||||
expect(result.nodes[3]!.type).toBe("archService");
|
expect(result.nodes[3]!.type).toBe("archService");
|
||||||
// seq: prefix resolves to sequence node type even without diagramType context
|
// seq: prefix resolves to sequence node type even without diagramType context
|
||||||
expect(result.nodes[4]!.type).toBe("seqParticipant");
|
expect(result.nodes[4]!.type).toBe("seqParticipant");
|
||||||
// Non-prefixed types without diagramType context stay default
|
// flow: prefix resolves to flowchart node type even without diagramType context
|
||||||
expect(result.nodes[5]!.type).toBe("default");
|
expect(result.nodes[5]!.type).toBe("flowProcess");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve architecture node types when diagramType is architecture", () => {
|
it("should resolve architecture node types when diagramType is architecture", () => {
|
||||||
@@ -440,6 +440,75 @@ describe("graphToFlow", () => {
|
|||||||
expect(result.edges).toHaveLength(2);
|
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)", () => {
|
it("should use flat layout for org chart diagrams (no container nodes)", () => {
|
||||||
const data: GraphData = {
|
const data: GraphData = {
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
resolveSequenceNodeType,
|
resolveSequenceNodeType,
|
||||||
resolveSequenceEdgeType,
|
resolveSequenceEdgeType,
|
||||||
} from "../types/sequence/constants";
|
} from "../types/sequence/constants";
|
||||||
|
import {
|
||||||
|
resolveFlowchartNodeType,
|
||||||
|
resolveFlowchartEdgeType,
|
||||||
|
} from "../types/flowchart/constants";
|
||||||
|
|
||||||
// ── Node Type Resolution ───────────────────────────────────────────────────
|
// ── Node Type Resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,7 +51,9 @@ function resolveFlowNodeType(
|
|||||||
if (diagramType === "sequence" || nodeType.startsWith("seq:")) {
|
if (diagramType === "sequence" || nodeType.startsWith("seq:")) {
|
||||||
return resolveSequenceNodeType(nodeType);
|
return resolveSequenceNodeType(nodeType);
|
||||||
}
|
}
|
||||||
// Future: flowchart
|
if (diagramType === "flowchart" || nodeType.startsWith("flow:")) {
|
||||||
|
return resolveFlowchartNodeType(nodeType);
|
||||||
|
}
|
||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +76,9 @@ function resolveFlowEdgeType(
|
|||||||
if (diagramType === "sequence") {
|
if (diagramType === "sequence") {
|
||||||
return resolveSequenceEdgeType(edgeType);
|
return resolveSequenceEdgeType(edgeType);
|
||||||
}
|
}
|
||||||
|
if (diagramType === "flowchart") {
|
||||||
|
return resolveFlowchartEdgeType(edgeType);
|
||||||
|
}
|
||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flow-decision">
|
||||||
|
<div
|
||||||
|
className="flow-decision-diamond"
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="flow-decision-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx
Normal file
37
apps/web/src/modules/diagram/types/flowchart/FlowEdge.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
id={props.id}
|
||||||
|
path={edgePath}
|
||||||
|
style={{ stroke: "var(--diagram-flowchart)", strokeWidth: 1.5 }}
|
||||||
|
markerEnd="url(#flow-arrow)"
|
||||||
|
/>
|
||||||
|
{props.label && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
className="flow-edge-label"
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
position: "absolute",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx
Normal file
37
apps/web/src/modules/diagram/types/flowchart/FlowIoNode.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flow-io">
|
||||||
|
<div
|
||||||
|
className="flow-io-skew"
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flow-process"
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
{d.icon && <span className="flow-node-icon">{d.icon}</span>}
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flow-subprocess"
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flow-subprocess-inner"
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`flow-terminal${isStart ? " flow-terminal-start" : ""}${isEnd ? " flow-terminal-end" : ""}`}
|
||||||
|
style={d.color ? { borderColor: d.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="flow-node-label">{d.label}</span>
|
||||||
|
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right"
|
||||||
|
style={HIDDEN_HANDLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/web/src/modules/diagram/types/flowchart/constants.ts
Normal file
45
apps/web/src/modules/diagram/types/flowchart/constants.ts
Normal file
@@ -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<string, { w: number; h: number }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user