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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([], []);
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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