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:
Alejandro Gutiérrez
2026-02-27 01:59:49 +00:00
parent 1ff8ff8f06
commit 0ff5450e0f
16 changed files with 1607 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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>
)}
</>
);
}

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

View File

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

View File

@@ -0,0 +1,40 @@
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { DiagramNode } from "../graph";
import { HIDDEN_HANDLE } from "../architecture/constants";
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>
);
}

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

View File

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

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