feat: implement Stories 2.4-2.7 — E-R, Org Chart, Architecture, Sequence diagram type renderers
Adds four diagram type renderers completing the core diagram type suite: - Story 2.4: E-R entity nodes with column tables, relationship edges with cardinality labels - Story 2.5: Org chart person nodes with role/department tags, hierarchy edges - Story 2.6: Architecture nodes (service, database, queue, load balancer, external), connection edges - Story 2.7: Sequence participant nodes with lifelines + activation bars, fragment nodes, 3 custom edge types (sync/async/return), custom time-ordered layout (not ELK) Story 2.7 includes code review fixes: computeLayout returns LayoutResult so enriched sequence edges flow through useAutoLayout, activation bar computation in layout, immutable layout function, self-message U-shaped loop rendering, sequence node size tests in buildElkGraph. 476 tests passing across 29 test files, zero regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -233,6 +233,423 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── E-R Entity Styles ──────────────────────────────────────────────── */
|
||||
|
||||
.er-entity {
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-er);
|
||||
border-radius: 6px;
|
||||
min-width: 220px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.er-entity-header {
|
||||
background: var(--diagram-er);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.er-entity-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.er-entity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.er-entity-row:hover {
|
||||
background: color-mix(in oklch, var(--diagram-er) 5%, transparent);
|
||||
}
|
||||
|
||||
.er-entity-indicator {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.er-entity-col-name {
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.er-entity-col-type {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.er-entity-constraint {
|
||||
color: var(--diagram-er);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.er-entity-empty {
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.er-cardinality {
|
||||
background: var(--node-bg);
|
||||
border: 1px solid var(--diagram-er);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--diagram-er);
|
||||
}
|
||||
|
||||
/* ── Org Chart Person Styles ─────────────────────────────────────────── */
|
||||
|
||||
.oc-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-orgchart);
|
||||
border-left: 4px solid var(--diagram-orgchart);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oc-person:hover {
|
||||
background: color-mix(in oklch, var(--diagram-orgchart) 5%, var(--node-bg));
|
||||
}
|
||||
|
||||
.oc-person-avatar {
|
||||
font-size: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in oklch, var(--diagram-orgchart) 10%, transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.oc-person-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.oc-person-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.oc-person-role {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.oc-person-dept {
|
||||
font-size: 10px;
|
||||
color: var(--diagram-orgchart);
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Architecture Diagram Styles ────────────────────────────────────── */
|
||||
|
||||
.arch-node-icon {
|
||||
font-size: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arch-node-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.arch-node-label {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.arch-node-meta {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.arch-edge-label {
|
||||
font-size: 10px;
|
||||
color: var(--diagram-architecture);
|
||||
background: var(--node-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--diagram-architecture);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.arch-service {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-architecture);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arch-service:hover {
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
.arch-database {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-architecture);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 20px 14px 10px;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arch-database::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
height: 20px;
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 15%, var(--node-bg));
|
||||
border: 1.5px solid var(--diagram-architecture);
|
||||
border-radius: 50% / 100%;
|
||||
}
|
||||
|
||||
.arch-database:hover {
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
.arch-queue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-architecture);
|
||||
padding: 10px 20px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
transform: skewX(-8deg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arch-queue > * {
|
||||
transform: skewX(8deg);
|
||||
}
|
||||
|
||||
.arch-queue:hover {
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
.arch-lb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-architecture);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transform: rotate(45deg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arch-lb > * {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.arch-lb:hover {
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
.arch-external {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--node-bg);
|
||||
border: 2px dashed var(--diagram-architecture);
|
||||
border-radius: 20px;
|
||||
padding: 10px 16px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arch-external:hover {
|
||||
background: color-mix(in oklch, var(--diagram-architecture) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
/* ── Sequence Diagram Styles ────────────────────────────────────────── */
|
||||
|
||||
.seq-participant {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.seq-participant-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--node-bg);
|
||||
border: 1.5px solid var(--diagram-sequence);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.seq-participant-box:hover {
|
||||
background: color-mix(in oklch, var(--diagram-sequence) 8%, var(--node-bg));
|
||||
}
|
||||
|
||||
.seq-participant-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.seq-participant-label {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.seq-lifeline {
|
||||
width: 0;
|
||||
border-left: 2px dashed var(--diagram-sequence);
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.seq-activation {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: color-mix(in oklch, var(--diagram-sequence) 20%, var(--node-bg));
|
||||
border: 1.5px solid var(--diagram-sequence);
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.seq-fragment {
|
||||
background: color-mix(in oklch, var(--diagram-sequence) 4%, transparent);
|
||||
border: 1.5px solid var(--diagram-sequence);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seq-fragment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--diagram-sequence);
|
||||
border-right: 1px solid var(--diagram-sequence);
|
||||
border-bottom-right-radius: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.seq-fragment-type {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
color: var(--diagram-sequence);
|
||||
text-transform: uppercase;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.seq-fragment-guard {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.seq-edge-label {
|
||||
font-size: 11px;
|
||||
color: var(--diagram-sequence);
|
||||
background: var(--node-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seq-edge-label-positioned {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ── Path Highlighting ────────────────────────────────────────────────── */
|
||||
|
||||
.react-flow__node.dimmed,
|
||||
@@ -242,7 +659,7 @@
|
||||
}
|
||||
|
||||
.react-flow__node.highlighted {
|
||||
filter: drop-shadow(0 0 6px var(--diagram-bpmn));
|
||||
filter: drop-shadow(0 0 6px var(--node-selected));
|
||||
transition: filter 200ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,21 @@ import {
|
||||
BpmnMessageEdge,
|
||||
BpmnAssociationEdge,
|
||||
} from "../../types/bpmn";
|
||||
import { ErEntityNode } from "../../types/er/ErEntityNode";
|
||||
import { ErRelationshipEdge } from "../../types/er/ErRelationshipEdge";
|
||||
import { OrgchartPersonNode } from "../../types/orgchart/OrgchartPersonNode";
|
||||
import { OrgchartHierarchyEdge } from "../../types/orgchart/OrgchartHierarchyEdge";
|
||||
import { ArchServiceNode } from "../../types/architecture/ArchServiceNode";
|
||||
import { ArchDatabaseNode } from "../../types/architecture/ArchDatabaseNode";
|
||||
import { ArchQueueNode } from "../../types/architecture/ArchQueueNode";
|
||||
import { ArchLoadBalancerNode } from "../../types/architecture/ArchLoadBalancerNode";
|
||||
import { ArchExternalNode } from "../../types/architecture/ArchExternalNode";
|
||||
import { ArchConnectionEdge } from "../../types/architecture/ArchConnectionEdge";
|
||||
import { SeqParticipantNode } from "../../types/sequence/SeqParticipantNode";
|
||||
import { SeqFragmentNode } from "../../types/sequence/SeqFragmentNode";
|
||||
import { SeqSyncEdge } from "../../types/sequence/SeqSyncEdge";
|
||||
import { SeqAsyncEdge } from "../../types/sequence/SeqAsyncEdge";
|
||||
import { SeqReturnEdge } from "../../types/sequence/SeqReturnEdge";
|
||||
|
||||
const nodeTypes = {
|
||||
bpmnActivity: BpmnActivityNode,
|
||||
@@ -46,21 +61,37 @@ const nodeTypes = {
|
||||
bpmnPool: BpmnPoolNode,
|
||||
bpmnLane: BpmnLaneNode,
|
||||
bpmnGroup: BpmnGroupNode,
|
||||
erEntity: ErEntityNode,
|
||||
orgchartPerson: OrgchartPersonNode,
|
||||
archService: ArchServiceNode,
|
||||
archDatabase: ArchDatabaseNode,
|
||||
archQueue: ArchQueueNode,
|
||||
archLoadBalancer: ArchLoadBalancerNode,
|
||||
archExternal: ArchExternalNode,
|
||||
seqParticipant: SeqParticipantNode,
|
||||
seqFragment: SeqFragmentNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
bpmnSequence: BpmnSequenceEdge,
|
||||
bpmnMessage: BpmnMessageEdge,
|
||||
bpmnAssociation: BpmnAssociationEdge,
|
||||
erRelationship: ErRelationshipEdge,
|
||||
orgchartHierarchy: OrgchartHierarchyEdge,
|
||||
archConnection: ArchConnectionEdge,
|
||||
seqSync: SeqSyncEdge,
|
||||
seqAsync: SeqAsyncEdge,
|
||||
seqReturn: SeqReturnEdge,
|
||||
};
|
||||
|
||||
/** Container node types that should not participate in BFS highlighting */
|
||||
const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup"]);
|
||||
const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup", "seqFragment"]);
|
||||
|
||||
function BpmnMarkerDefs() {
|
||||
function MarkerDefs() {
|
||||
return (
|
||||
<svg style={{ position: "absolute", width: 0, height: 0 }}>
|
||||
<defs>
|
||||
{/* BPMN markers */}
|
||||
<marker
|
||||
id="bpmn-arrow-filled"
|
||||
viewBox="0 0 10 10"
|
||||
@@ -91,6 +122,67 @@ function BpmnMarkerDefs() {
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</marker>
|
||||
{/* E-R markers */}
|
||||
<marker
|
||||
id="er-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX={10}
|
||||
refY={5}
|
||||
markerWidth={8}
|
||||
markerHeight={8}
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path
|
||||
d="M 0 0 L 10 5 L 0 10 Z"
|
||||
fill="var(--diagram-er, #7c3aed)"
|
||||
/>
|
||||
</marker>
|
||||
{/* Architecture markers */}
|
||||
<marker
|
||||
id="arch-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX={10}
|
||||
refY={5}
|
||||
markerWidth={8}
|
||||
markerHeight={8}
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path
|
||||
d="M 0 0 L 10 5 L 0 10 Z"
|
||||
fill="var(--diagram-architecture, #71717a)"
|
||||
/>
|
||||
</marker>
|
||||
{/* Sequence markers */}
|
||||
<marker
|
||||
id="seq-arrow-filled"
|
||||
viewBox="0 0 10 10"
|
||||
refX={10}
|
||||
refY={5}
|
||||
markerWidth={8}
|
||||
markerHeight={8}
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path
|
||||
d="M 0 0 L 10 5 L 0 10 Z"
|
||||
fill="var(--diagram-sequence, #f59e0b)"
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id="seq-arrow-open"
|
||||
viewBox="0 0 10 10"
|
||||
refX={10}
|
||||
refY={5}
|
||||
markerWidth={8}
|
||||
markerHeight={8}
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path
|
||||
d="M 0 0 L 10 5 L 0 10"
|
||||
fill="none"
|
||||
stroke="var(--diagram-sequence, #f59e0b)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -168,7 +260,7 @@ function CanvasInner() {
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<BpmnMarkerDefs />
|
||||
<MarkerDefs />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SOFT_CAP_NODE_COUNT,
|
||||
} from "../lib/elk-layout";
|
||||
|
||||
import type { ElkLayoutOptions } from "../lib/elk-layout";
|
||||
import type { ElkLayoutOptions, LayoutResult } from "../lib/elk-layout";
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
const LAYOUT_ANIMATION_MS = 200;
|
||||
@@ -24,6 +24,7 @@ export function useAutoLayout() {
|
||||
|
||||
const nodeCount = useGraphStore((s) => s.nodeCount);
|
||||
const setNodes = useGraphStore((s) => s.setNodes);
|
||||
const setEdges = useGraphStore((s) => s.setEdges);
|
||||
const layoutDirection = useGraphStore((s) => s.layoutDirection);
|
||||
const edgeRouting = useGraphStore((s) => s.edgeRouting);
|
||||
const isLayouting = useGraphStore((s) => s.isLayouting);
|
||||
@@ -53,7 +54,7 @@ export function useAutoLayout() {
|
||||
flowNodes.forEach((el) => el.classList.add("layouting"));
|
||||
|
||||
try {
|
||||
const layoutedNodes = await computeLayout(
|
||||
const result: LayoutResult = await computeLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
{
|
||||
@@ -67,7 +68,10 @@ export function useAutoLayout() {
|
||||
},
|
||||
);
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setNodes(result.nodes);
|
||||
if (result.edges) {
|
||||
setEdges(result.edges);
|
||||
}
|
||||
|
||||
// Fit view after layout, with a small delay to let transition run
|
||||
setTimeout(() => {
|
||||
@@ -96,7 +100,7 @@ export function useAutoLayout() {
|
||||
}, LAYOUT_ANIMATION_MS);
|
||||
}
|
||||
},
|
||||
[setNodes, setIsLayouting, fitView],
|
||||
[setNodes, setEdges, setIsLayouting, fitView],
|
||||
);
|
||||
|
||||
const triggerLayout = useCallback(
|
||||
|
||||
@@ -106,6 +106,247 @@ describe("buildElkGraph", () => {
|
||||
expect(elkEdge.targets).toEqual(["n2"]);
|
||||
});
|
||||
|
||||
it("should compute E-R entity height from columns only for erEntity nodes", () => {
|
||||
const erNode: Node = {
|
||||
id: "users",
|
||||
type: "erEntity",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "users",
|
||||
type: "er:entity",
|
||||
label: "Users",
|
||||
columns: [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "name", type: "text" },
|
||||
{ name: "email", type: "varchar" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const regularNode = createNode("n1");
|
||||
const result = buildElkGraph([erNode, regularNode], []);
|
||||
|
||||
// E-R entity: headerH(36) + 3*rowH(24) + paddingY(8)*2 = 124
|
||||
expect(result.children?.[0]?.height).toBe(124);
|
||||
// Regular node: default height (50)
|
||||
expect(result.children?.[1]?.height).toBe(50);
|
||||
});
|
||||
|
||||
it("should build correct ELK graph for M:N junction table scenario (AC #3)", () => {
|
||||
const students: Node = {
|
||||
id: "students",
|
||||
type: "erEntity",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "students",
|
||||
type: "er:entity",
|
||||
label: "Students",
|
||||
columns: [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "name", type: "text" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const courses: Node = {
|
||||
id: "courses",
|
||||
type: "erEntity",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "courses",
|
||||
type: "er:entity",
|
||||
label: "Courses",
|
||||
columns: [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "title", type: "text" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const enrollments: Node = {
|
||||
id: "enrollments",
|
||||
type: "erEntity",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "enrollments",
|
||||
type: "er:entity",
|
||||
label: "Enrollments",
|
||||
columns: [
|
||||
{ name: "student_id", type: "uuid", isForeignKey: true },
|
||||
{ name: "course_id", type: "uuid", isForeignKey: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "students", target: "enrollments", type: "erRelationship", data: {} },
|
||||
{ id: "e2", source: "courses", target: "enrollments", type: "erRelationship", data: {} },
|
||||
];
|
||||
|
||||
const result = buildElkGraph([students, courses, enrollments], edges);
|
||||
|
||||
// All 3 entities as flat children (no hierarchy)
|
||||
expect(result.children).toHaveLength(3);
|
||||
// Junction table has correct height: headerH(36) + 2*rowH(24) + paddingY(8)*2 = 100
|
||||
const junctionChild = result.children?.find((c) => c.id === "enrollments");
|
||||
expect(junctionChild?.height).toBe(100);
|
||||
// 2 edges connecting to junction table
|
||||
expect(result.edges).toHaveLength(2);
|
||||
// No nested children (flat layout, not compound)
|
||||
expect(result.children?.every((c) => !("children" in c && (c as { children?: unknown[] }).children?.length))).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT use E-R height for non-erEntity nodes with columns property", () => {
|
||||
const nodeWithColumns: Node = {
|
||||
id: "n1",
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "n1",
|
||||
type: "flow:process",
|
||||
label: "Not E-R",
|
||||
columns: [{ name: "a", type: "text" }],
|
||||
},
|
||||
};
|
||||
const result = buildElkGraph([nodeWithColumns], []);
|
||||
// Should use default height (50), NOT E-R computed height
|
||||
expect(result.children?.[0]?.height).toBe(50);
|
||||
});
|
||||
|
||||
it("should use OC_SIZES for orgchartPerson nodes", () => {
|
||||
const ocNode: Node = {
|
||||
id: "ceo",
|
||||
type: "orgchartPerson",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "ceo",
|
||||
type: "org:person",
|
||||
label: "Alice",
|
||||
tag: "CEO",
|
||||
},
|
||||
};
|
||||
const regularNode = createNode("n1");
|
||||
const result = buildElkGraph([ocNode, regularNode], []);
|
||||
|
||||
// Org chart person: fixed w=280, h=80
|
||||
expect(result.children?.[0]?.width).toBe(280);
|
||||
expect(result.children?.[0]?.height).toBe(80);
|
||||
// Regular node: default dimensions
|
||||
expect(result.children?.[1]?.width).toBe(150);
|
||||
expect(result.children?.[1]?.height).toBe(50);
|
||||
});
|
||||
|
||||
it("should respect data.w override for orgchartPerson nodes", () => {
|
||||
const ocNode: Node = {
|
||||
id: "ceo",
|
||||
type: "orgchartPerson",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id: "ceo",
|
||||
type: "org:person",
|
||||
label: "Alice",
|
||||
tag: "CEO",
|
||||
w: 320,
|
||||
},
|
||||
};
|
||||
const result = buildElkGraph([ocNode], []);
|
||||
expect(result.children?.[0]?.width).toBe(320);
|
||||
expect(result.children?.[0]?.height).toBe(80);
|
||||
});
|
||||
|
||||
it("should use ARCH_SIZES for architecture node subtypes", () => {
|
||||
const archNodes: Node[] = [
|
||||
{
|
||||
id: "api",
|
||||
type: "archService",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "api", type: "arch:service", label: "API Gateway" },
|
||||
},
|
||||
{
|
||||
id: "db",
|
||||
type: "archDatabase",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "db", type: "arch:database", label: "PostgreSQL" },
|
||||
},
|
||||
{
|
||||
id: "q",
|
||||
type: "archQueue",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "q", type: "arch:queue", label: "SQS" },
|
||||
},
|
||||
{
|
||||
id: "lb",
|
||||
type: "archLoadBalancer",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "lb", type: "arch:loadbalancer", label: "ALB" },
|
||||
},
|
||||
{
|
||||
id: "ext",
|
||||
type: "archExternal",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "ext", type: "arch:external", label: "Stripe" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildElkGraph(archNodes, []);
|
||||
|
||||
// archService: w=200, h=80
|
||||
expect(result.children?.[0]?.width).toBe(200);
|
||||
expect(result.children?.[0]?.height).toBe(80);
|
||||
// archDatabase: w=160, h=100
|
||||
expect(result.children?.[1]?.width).toBe(160);
|
||||
expect(result.children?.[1]?.height).toBe(100);
|
||||
// archQueue: w=180, h=70
|
||||
expect(result.children?.[2]?.width).toBe(180);
|
||||
expect(result.children?.[2]?.height).toBe(70);
|
||||
// archLoadBalancer: w=120, h=120
|
||||
expect(result.children?.[3]?.width).toBe(120);
|
||||
expect(result.children?.[3]?.height).toBe(120);
|
||||
// archExternal: w=180, h=80
|
||||
expect(result.children?.[4]?.width).toBe(180);
|
||||
expect(result.children?.[4]?.height).toBe(80);
|
||||
});
|
||||
|
||||
it("should respect data.w override for architecture nodes", () => {
|
||||
const archNode: Node = {
|
||||
id: "api",
|
||||
type: "archService",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "api", type: "arch:service", label: "Wide Service", w: 300 },
|
||||
};
|
||||
const result = buildElkGraph([archNode], []);
|
||||
expect(result.children?.[0]?.width).toBe(300);
|
||||
expect(result.children?.[0]?.height).toBe(80);
|
||||
});
|
||||
|
||||
it("should use SEQ_SIZES for seqParticipant nodes", () => {
|
||||
const seqNode: Node = {
|
||||
id: "client",
|
||||
type: "seqParticipant",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "client", type: "seq:participant", label: "Client", lifeline: true },
|
||||
};
|
||||
const regularNode = createNode("n1");
|
||||
const result = buildElkGraph([seqNode, regularNode], []);
|
||||
|
||||
// seqParticipant: w=160, h=60
|
||||
expect(result.children?.[0]?.width).toBe(160);
|
||||
expect(result.children?.[0]?.height).toBe(60);
|
||||
// Regular node: default dimensions
|
||||
expect(result.children?.[1]?.width).toBe(150);
|
||||
expect(result.children?.[1]?.height).toBe(50);
|
||||
});
|
||||
|
||||
it("should return null size for seqFragment (dynamically computed)", () => {
|
||||
const fragNode: Node = {
|
||||
id: "alt-1",
|
||||
type: "seqFragment",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id: "alt-1", type: "seq:fragment", label: "alt" },
|
||||
};
|
||||
const result = buildElkGraph([fragNode], []);
|
||||
|
||||
// seqFragment returns null from getSeqNodeSize → falls through to default
|
||||
expect(result.children?.[0]?.width).toBe(150);
|
||||
expect(result.children?.[0]?.height).toBe(50);
|
||||
});
|
||||
|
||||
it("should handle empty nodes and edges", () => {
|
||||
const result = buildElkGraph([], []);
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
resolveBpmnPositions,
|
||||
applyBpmnPositions,
|
||||
} from "./bpmn-layout";
|
||||
import { getErEntityHeight } from "../types/er/constants";
|
||||
import { OC_SIZES } from "../types/orgchart/constants";
|
||||
import { getArchNodeSize } from "../types/architecture/constants";
|
||||
import { getSeqNodeSize } from "../types/sequence/constants";
|
||||
import { computeSequenceLayout } from "./sequence-layout";
|
||||
|
||||
// ── Layout Options ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,10 +61,35 @@ export function buildElkGraph(
|
||||
},
|
||||
children: nodes.map((node) => {
|
||||
const data = node.data as unknown as DiagramNode;
|
||||
// E-R entities: compute height from columns for correct ELK spacing
|
||||
const isErEntity = node.type === "erEntity";
|
||||
// Org chart persons: fixed dimensions
|
||||
const isOcPerson = node.type === "orgchartPerson";
|
||||
// Architecture nodes: per-subtype dimensions
|
||||
const archSize = getArchNodeSize(node.type);
|
||||
// Sequence nodes: per-subtype dimensions
|
||||
const seqSize = getSeqNodeSize(node.type);
|
||||
const height =
|
||||
isErEntity && data.columns
|
||||
? getErEntityHeight(data.columns)
|
||||
: isOcPerson
|
||||
? OC_SIZES.person.h
|
||||
: archSize
|
||||
? archSize.h
|
||||
: seqSize
|
||||
? seqSize.h
|
||||
: (node.measured?.height ?? DEFAULT_NODE_HEIGHT);
|
||||
const width = archSize
|
||||
? (data.w ?? archSize.w)
|
||||
: seqSize
|
||||
? (data.w ?? seqSize.w)
|
||||
: isOcPerson
|
||||
? (data.w ?? OC_SIZES.person.w)
|
||||
: (data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH);
|
||||
return {
|
||||
id: node.id,
|
||||
width: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}),
|
||||
edges: edges.map(
|
||||
@@ -179,14 +209,19 @@ function flowToBpmnGraphData(
|
||||
|
||||
// ── Main Layout Function ────────────────────────────────────────────────────
|
||||
|
||||
export interface LayoutResult {
|
||||
nodes: Node[];
|
||||
edges?: Edge[];
|
||||
}
|
||||
|
||||
export function computeLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: Partial<ElkLayoutOptions> = {},
|
||||
): Promise<Node[]> {
|
||||
): Promise<LayoutResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (nodes.length === 0) {
|
||||
resolve(nodes);
|
||||
resolve({ nodes });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,6 +231,14 @@ export function computeLayout(
|
||||
pendingReject = null;
|
||||
}
|
||||
|
||||
// Detect sequence diagram — uses custom synchronous layout, not ELK
|
||||
const isSequence = nodes.some((n) => n.type === "seqParticipant");
|
||||
if (isSequence) {
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
resolve({ nodes: result.nodes, edges: result.edges });
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect BPMN compound layout (pools present in @xyflow nodes)
|
||||
const isCompound = nodes.some((n) => n.type === "bpmnPool");
|
||||
|
||||
@@ -229,9 +272,9 @@ export function computeLayout(
|
||||
if (event.data.type === "result" && event.data.graph) {
|
||||
if (isCompound) {
|
||||
const positions = resolveBpmnPositions(event.data.graph);
|
||||
resolve(applyBpmnPositions(positions, nodes));
|
||||
resolve({ nodes: applyBpmnPositions(positions, nodes) });
|
||||
} else {
|
||||
resolve(resolvePositions(event.data.graph, nodes));
|
||||
resolve({ nodes: resolvePositions(event.data.graph, nodes) });
|
||||
}
|
||||
} else {
|
||||
reject(new Error(event.data.message ?? "ELK layout failed"));
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("graphToFlow", () => {
|
||||
const diagramTypes = [
|
||||
"bpmn:activity",
|
||||
"er:entity",
|
||||
"org:unit",
|
||||
"org:person",
|
||||
"arch:service",
|
||||
"seq:participant",
|
||||
"flow:process",
|
||||
@@ -138,11 +138,332 @@ describe("graphToFlow", () => {
|
||||
expect(result.nodes).toHaveLength(6);
|
||||
// bpmn: prefix resolves to BPMN node type even without diagramType context
|
||||
expect(result.nodes[0]!.type).toBe("bpmnActivity");
|
||||
// Non-BPMN types without diagramType context stay default
|
||||
expect(result.nodes[1]!.type).toBe("default");
|
||||
// er: prefix resolves to E-R node type even without diagramType context
|
||||
expect(result.nodes[1]!.type).toBe("erEntity");
|
||||
// org: prefix resolves to org chart node type even without diagramType context
|
||||
expect(result.nodes[2]!.type).toBe("orgchartPerson");
|
||||
// arch: prefix resolves to architecture node type even without diagramType context
|
||||
expect(result.nodes[3]!.type).toBe("archService");
|
||||
// seq: prefix resolves to sequence node type even without diagramType context
|
||||
expect(result.nodes[4]!.type).toBe("seqParticipant");
|
||||
// Non-prefixed types without diagramType context stay default
|
||||
expect(result.nodes[5]!.type).toBe("default");
|
||||
});
|
||||
|
||||
it("should resolve architecture node types when diagramType is architecture", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Architecture Test",
|
||||
diagramType: "architecture",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "n1", type: "service", label: "API" },
|
||||
{ id: "n2", type: "arch:database", label: "DB" },
|
||||
{ id: "n3", type: "arch:queue", label: "Queue" },
|
||||
{ id: "n4", type: "arch:loadbalancer", label: "LB" },
|
||||
{ id: "n5", type: "arch:external", label: "Stripe" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes[0]!.type).toBe("archService");
|
||||
expect(result.nodes[1]!.type).toBe("archDatabase");
|
||||
expect(result.nodes[2]!.type).toBe("archQueue");
|
||||
expect(result.nodes[3]!.type).toBe("archLoadBalancer");
|
||||
expect(result.nodes[4]!.type).toBe("archExternal");
|
||||
});
|
||||
|
||||
it("should resolve architecture edge types when diagramType is architecture", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Architecture Edge Test",
|
||||
diagramType: "architecture",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "svc", type: "arch:service", label: "API" },
|
||||
{ id: "db", type: "arch:database", label: "DB" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "svc", to: "db", type: "sync", label: "PostgreSQL" },
|
||||
{ id: "e2", from: "svc", to: "db", type: "async", label: "AMQP" },
|
||||
{ id: "e3", from: "svc", to: "db" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.edges[0]!.type).toBe("archConnection");
|
||||
expect(result.edges[1]!.type).toBe("archConnection");
|
||||
expect(result.edges[2]!.type).toBe("archConnection");
|
||||
});
|
||||
|
||||
it("should use flat layout for architecture diagrams (no container nodes)", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Architecture Flat Layout",
|
||||
diagramType: "architecture",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "lb", type: "arch:loadbalancer", label: "LB" },
|
||||
{ id: "svc1", type: "arch:service", label: "Service 1" },
|
||||
{ id: "svc2", type: "arch:service", label: "Service 2" },
|
||||
{ id: "db", type: "arch:database", label: "DB" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "lb", to: "svc1", type: "sync", label: "HTTP" },
|
||||
{ id: "e2", from: "lb", to: "svc2", type: "sync", label: "HTTP" },
|
||||
{ id: "e3", from: "svc1", to: "db", type: "sync", label: "PostgreSQL" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes).toHaveLength(4);
|
||||
expect(result.edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should resolve E-R node types when diagramType is er", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "E-R Test",
|
||||
diagramType: "er",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "n1", type: "entity", label: "Users" },
|
||||
{ id: "n2", type: "er:entity", label: "Orders" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes[0]!.type).toBe("erEntity");
|
||||
expect(result.nodes[1]!.type).toBe("erEntity");
|
||||
});
|
||||
|
||||
it("should resolve E-R edge types when diagramType is er", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "E-R Edge Test",
|
||||
diagramType: "er",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "users", type: "er:entity", label: "Users" },
|
||||
{ id: "orders", type: "er:entity", label: "Orders" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "users", to: "orders", type: "relationship", cardinality: "1:N" },
|
||||
{ id: "e2", from: "users", to: "orders" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.edges[0]!.type).toBe("erRelationship");
|
||||
expect(result.edges[1]!.type).toBe("erRelationship");
|
||||
expect(result.edges[0]!.data?.cardinality).toBe("1:N");
|
||||
});
|
||||
|
||||
it("should use flat layout for E-R diagrams (no container nodes)", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "E-R Flat Layout",
|
||||
diagramType: "er",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "users", type: "er:entity", label: "Users", columns: [{ name: "id", type: "uuid", isPrimaryKey: true }] },
|
||||
{ id: "orders", type: "er:entity", label: "Orders" },
|
||||
],
|
||||
edges: [{ id: "e1", from: "users", to: "orders", cardinality: "1:N" }],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
// E-R uses flat layout — no pool/lane/group container nodes
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
expect(result.nodes.every((n) => n.type === "erEntity")).toBe(true);
|
||||
});
|
||||
|
||||
it("should produce correct flat structure for E-R M:N with junction table (AC #3)", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "M:N Junction Table",
|
||||
diagramType: "er",
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "students",
|
||||
type: "er:entity",
|
||||
label: "Students",
|
||||
columns: [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "name", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "courses",
|
||||
type: "er:entity",
|
||||
label: "Courses",
|
||||
columns: [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "title", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "enrollments",
|
||||
type: "er:entity",
|
||||
label: "Enrollments",
|
||||
columns: [
|
||||
{ name: "student_id", type: "uuid", isForeignKey: true },
|
||||
{ name: "course_id", type: "uuid", isForeignKey: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "students", to: "enrollments", cardinality: "1:N" },
|
||||
{ id: "e2", from: "courses", to: "enrollments", cardinality: "1:N" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
// All 3 entities are flat nodes (no container hierarchy)
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
expect(result.nodes.every((n) => n.type === "erEntity")).toBe(true);
|
||||
// Junction table has edges connecting it to both parent entities
|
||||
expect(result.edges).toHaveLength(2);
|
||||
expect(result.edges[0]!.type).toBe("erRelationship");
|
||||
expect(result.edges[1]!.type).toBe("erRelationship");
|
||||
// Junction table node preserves FK columns for ELK height computation
|
||||
const junctionNode = result.nodes.find((n) => n.id === "enrollments");
|
||||
expect((junctionNode!.data as unknown as DiagramNode).columns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should resolve org chart node types when diagramType is orgchart", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Org Chart Test",
|
||||
diagramType: "orgchart",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "n1", type: "person", label: "Alice", tag: "CEO" },
|
||||
{ id: "n2", type: "org:person", label: "Bob", tag: "CTO" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes[0]!.type).toBe("orgchartPerson");
|
||||
expect(result.nodes[1]!.type).toBe("orgchartPerson");
|
||||
});
|
||||
|
||||
it("should resolve org chart edge types when diagramType is orgchart", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Org Chart Edge Test",
|
||||
diagramType: "orgchart",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "ceo", type: "org:person", label: "Alice", tag: "CEO" },
|
||||
{ id: "cto", type: "org:person", label: "Bob", tag: "CTO" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "ceo", to: "cto", type: "hierarchy" },
|
||||
{ id: "e2", from: "ceo", to: "cto" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.edges[0]!.type).toBe("orgchartHierarchy");
|
||||
expect(result.edges[1]!.type).toBe("orgchartHierarchy");
|
||||
});
|
||||
|
||||
it("should resolve sequence node types when diagramType is sequence", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Sequence Test",
|
||||
diagramType: "sequence",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "n1", type: "participant", label: "Client" },
|
||||
{ id: "n2", type: "seq:participant", label: "Server" },
|
||||
{ id: "n3", type: "seq:fragment", label: "alt" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes[0]!.type).toBe("seqParticipant");
|
||||
expect(result.nodes[1]!.type).toBe("seqParticipant");
|
||||
expect(result.nodes[2]!.type).toBe("seqFragment");
|
||||
});
|
||||
|
||||
it("should resolve sequence edge types when diagramType is sequence", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Sequence Edge Test",
|
||||
diagramType: "sequence",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "client", type: "seq:participant", label: "Client" },
|
||||
{ id: "server", type: "seq:participant", label: "Server" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "client", to: "server", type: "sync", label: "POST /login" },
|
||||
{ id: "e2", from: "server", to: "client", type: "async", label: "enqueue" },
|
||||
{ id: "e3", from: "server", to: "client", type: "return", label: "200 OK" },
|
||||
{ id: "e4", from: "client", to: "server" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.edges[0]!.type).toBe("seqSync");
|
||||
expect(result.edges[1]!.type).toBe("seqAsync");
|
||||
expect(result.edges[2]!.type).toBe("seqReturn");
|
||||
expect(result.edges[3]!.type).toBe("seqSync"); // default
|
||||
});
|
||||
|
||||
it("should use flat layout for sequence diagrams (no container nodes)", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Sequence Flat Layout",
|
||||
diagramType: "sequence",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "client", type: "seq:participant", label: "Client", lifeline: true },
|
||||
{ id: "server", type: "seq:participant", label: "Server", lifeline: true },
|
||||
{ id: "alt-1", type: "seq:fragment", label: "alt", tag: "[valid]", group: "m2" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "m1", from: "client", to: "server", type: "sync", label: "request" },
|
||||
{ id: "m2", from: "server", to: "client", type: "return", label: "response" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
expect(result.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should use flat layout for org chart diagrams (no container nodes)", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
title: "Org Chart Flat Layout",
|
||||
diagramType: "orgchart",
|
||||
},
|
||||
nodes: [
|
||||
{ id: "ceo", type: "org:person", label: "Alice", tag: "CEO", group: "Executive" },
|
||||
{ id: "cto", type: "org:person", label: "Bob", tag: "CTO", group: "Technology" },
|
||||
{ id: "dev", type: "org:person", label: "Carol", tag: "Developer", group: "Technology" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", from: "ceo", to: "cto" },
|
||||
{ id: "e2", from: "cto", to: "dev" },
|
||||
],
|
||||
};
|
||||
const result = graphToFlow(data);
|
||||
// Org chart uses flat layout — no pool/lane/group container nodes
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
expect(result.nodes.every((n) => n.type === "orgchartPerson")).toBe(true);
|
||||
expect(result.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should resolve BPMN node types when diagramType is bpmn", () => {
|
||||
const data: GraphData = {
|
||||
meta: {
|
||||
|
||||
@@ -9,6 +9,22 @@ import {
|
||||
resolveBpmnNodeType,
|
||||
resolveBpmnEdgeType,
|
||||
} from "../types/bpmn/constants";
|
||||
import {
|
||||
resolveErNodeType,
|
||||
resolveErEdgeType,
|
||||
} from "../types/er/constants";
|
||||
import {
|
||||
resolveOrgchartNodeType,
|
||||
resolveOrgchartEdgeType,
|
||||
} from "../types/orgchart/constants";
|
||||
import {
|
||||
resolveArchitectureNodeType,
|
||||
resolveArchitectureEdgeType,
|
||||
} from "../types/architecture/constants";
|
||||
import {
|
||||
resolveSequenceNodeType,
|
||||
resolveSequenceEdgeType,
|
||||
} from "../types/sequence/constants";
|
||||
|
||||
// ── Node Type Resolution ───────────────────────────────────────────────────
|
||||
|
||||
@@ -19,7 +35,19 @@ function resolveFlowNodeType(
|
||||
if (diagramType === "bpmn" || nodeType.startsWith("bpmn:")) {
|
||||
return resolveBpmnNodeType(nodeType);
|
||||
}
|
||||
// Future: er, orgchart, architecture, sequence, flowchart
|
||||
if (diagramType === "er" || nodeType.startsWith("er:")) {
|
||||
return resolveErNodeType(nodeType);
|
||||
}
|
||||
if (diagramType === "orgchart" || nodeType.startsWith("org:")) {
|
||||
return resolveOrgchartNodeType(nodeType);
|
||||
}
|
||||
if (diagramType === "architecture" || nodeType.startsWith("arch:")) {
|
||||
return resolveArchitectureNodeType(nodeType);
|
||||
}
|
||||
if (diagramType === "sequence" || nodeType.startsWith("seq:")) {
|
||||
return resolveSequenceNodeType(nodeType);
|
||||
}
|
||||
// Future: flowchart
|
||||
return "default";
|
||||
}
|
||||
|
||||
@@ -30,6 +58,18 @@ function resolveFlowEdgeType(
|
||||
if (diagramType === "bpmn") {
|
||||
return resolveBpmnEdgeType(edgeType);
|
||||
}
|
||||
if (diagramType === "er") {
|
||||
return resolveErEdgeType(edgeType);
|
||||
}
|
||||
if (diagramType === "orgchart") {
|
||||
return resolveOrgchartEdgeType(edgeType);
|
||||
}
|
||||
if (diagramType === "architecture") {
|
||||
return resolveArchitectureEdgeType(edgeType);
|
||||
}
|
||||
if (diagramType === "sequence") {
|
||||
return resolveSequenceEdgeType(edgeType);
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
|
||||
242
apps/web/src/modules/diagram/lib/sequence-layout.test.ts
Normal file
242
apps/web/src/modules/diagram/lib/sequence-layout.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import { computeSequenceLayout } from "./sequence-layout";
|
||||
import { SEQ_LAYOUT, SEQ_SIZES } from "../types/sequence/constants";
|
||||
|
||||
function createParticipant(id: string, label: string): Node {
|
||||
return {
|
||||
id,
|
||||
type: "seqParticipant",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id, type: "seq:participant", label, lifeline: true },
|
||||
};
|
||||
}
|
||||
|
||||
function createFragment(id: string, label: string, group: string, tag?: string): Node {
|
||||
return {
|
||||
id,
|
||||
type: "seqFragment",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id, type: "seq:fragment", label, group, ...(tag ? { tag } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(id: string, source: string, target: string, type = "seqSync"): Edge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type,
|
||||
data: { id, from: source, to: target, type: type === "seqSync" ? "sync" : type === "seqAsync" ? "async" : type === "seqReturn" ? "return" : "sync" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeSequenceLayout", () => {
|
||||
it("should position participants horizontally with correct spacing", () => {
|
||||
const nodes = [
|
||||
createParticipant("client", "Client"),
|
||||
createParticipant("server", "Server"),
|
||||
createParticipant("db", "Database"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, []);
|
||||
|
||||
expect(result.nodes[0]!.position).toEqual({ x: 0, y: 0 });
|
||||
expect(result.nodes[1]!.position).toEqual({ x: SEQ_LAYOUT.participantSpacing, y: 0 });
|
||||
expect(result.nodes[2]!.position).toEqual({ x: 2 * SEQ_LAYOUT.participantSpacing, y: 0 });
|
||||
});
|
||||
|
||||
it("should compute lifeline height from message count", () => {
|
||||
const nodes = [createParticipant("a", "A"), createParticipant("b", "B")];
|
||||
const edges = [
|
||||
createMessage("m1", "a", "b"),
|
||||
createMessage("m2", "b", "a"),
|
||||
createMessage("m3", "a", "b"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
const expectedHeight =
|
||||
SEQ_LAYOUT.messageStartY + 3 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.lifelinePadding;
|
||||
const participant = result.nodes[0]!;
|
||||
expect((participant.data as Record<string, unknown>).lifelineHeight).toBe(expectedHeight);
|
||||
expect(participant.style).toEqual(expect.objectContaining({ height: expectedHeight }));
|
||||
});
|
||||
|
||||
it("should enrich edges with order and messageY", () => {
|
||||
const nodes = [createParticipant("a", "A"), createParticipant("b", "B")];
|
||||
const edges = [
|
||||
createMessage("m1", "a", "b"),
|
||||
createMessage("m2", "b", "a"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
const e0 = result.edges[0]!.data as Record<string, unknown>;
|
||||
expect(e0.order).toBe(0);
|
||||
expect(e0.messageY).toBe(SEQ_LAYOUT.messageStartY);
|
||||
|
||||
const e1 = result.edges[1]!.data as Record<string, unknown>;
|
||||
expect(e1.order).toBe(1);
|
||||
expect(e1.messageY).toBe(SEQ_LAYOUT.messageStartY + SEQ_LAYOUT.messageSpacing);
|
||||
});
|
||||
|
||||
it("should position fragment around its messages", () => {
|
||||
const nodes = [
|
||||
createParticipant("client", "Client"),
|
||||
createParticipant("server", "Server"),
|
||||
createFragment("alt-1", "alt", "m2,m3", "[valid]"),
|
||||
];
|
||||
const edges = [
|
||||
createMessage("m1", "client", "server"),
|
||||
createMessage("m2", "server", "client"),
|
||||
createMessage("m3", "client", "server"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
// Fragment should encompass messages m2 (index 1) and m3 (index 2)
|
||||
const frag = result.nodes.find((n) => n.id === "alt-1")!;
|
||||
const expectedTopY =
|
||||
SEQ_LAYOUT.messageStartY + 1 * SEQ_LAYOUT.messageSpacing - SEQ_LAYOUT.fragmentPadding;
|
||||
const expectedBottomY =
|
||||
SEQ_LAYOUT.messageStartY + 2 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.fragmentPadding;
|
||||
|
||||
expect(frag.position.y).toBe(expectedTopY);
|
||||
expect((frag.style as { height: number }).height).toBe(expectedBottomY - expectedTopY);
|
||||
|
||||
// Fragment X spans from leftmost to rightmost participant + width + padding
|
||||
const leftX = 0 - SEQ_LAYOUT.fragmentPadding;
|
||||
const rightX = SEQ_LAYOUT.participantSpacing + SEQ_SIZES.participant.w + SEQ_LAYOUT.fragmentPadding;
|
||||
expect(frag.position.x).toBe(leftX);
|
||||
expect((frag.style as { width: number }).width).toBe(rightX - leftX);
|
||||
});
|
||||
|
||||
it("should handle empty edges", () => {
|
||||
const nodes = [createParticipant("a", "A")];
|
||||
const result = computeSequenceLayout(nodes, []);
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
const expectedHeight =
|
||||
SEQ_LAYOUT.messageStartY + 0 * SEQ_LAYOUT.messageSpacing + SEQ_LAYOUT.lifelinePadding;
|
||||
expect((result.nodes[0]!.data as Record<string, unknown>).lifelineHeight).toBe(expectedHeight);
|
||||
});
|
||||
|
||||
it("should handle fragment with no matching message IDs", () => {
|
||||
const nodes = [
|
||||
createParticipant("a", "A"),
|
||||
createFragment("frag-1", "loop", "nonexistent"),
|
||||
];
|
||||
const edges = [createMessage("m1", "a", "a")];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
// Fragment with no matching messages should keep original position
|
||||
const frag = result.nodes.find((n) => n.id === "frag-1")!;
|
||||
expect(frag.position).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("should return participants before fragments in node order", () => {
|
||||
const nodes = [
|
||||
createParticipant("a", "A"),
|
||||
createFragment("f1", "opt", "m1"),
|
||||
createParticipant("b", "B"),
|
||||
];
|
||||
const edges = [createMessage("m1", "a", "b")];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
// Participants come first, then fragments
|
||||
expect(result.nodes[0]!.type).toBe("seqParticipant");
|
||||
expect(result.nodes[1]!.type).toBe("seqParticipant");
|
||||
expect(result.nodes[2]!.type).toBe("seqFragment");
|
||||
});
|
||||
|
||||
it("should NOT mutate input nodes", () => {
|
||||
const nodes = [
|
||||
createParticipant("a", "A"),
|
||||
createParticipant("b", "B"),
|
||||
];
|
||||
const originalPos0 = { ...nodes[0]!.position };
|
||||
const originalPos1 = { ...nodes[1]!.position };
|
||||
|
||||
computeSequenceLayout(nodes, [createMessage("m1", "a", "b")]);
|
||||
|
||||
// Original nodes should be untouched
|
||||
expect(nodes[0]!.position).toEqual(originalPos0);
|
||||
expect(nodes[1]!.position).toEqual(originalPos1);
|
||||
expect((nodes[0]!.data as Record<string, unknown>).lifelineHeight).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should NOT mutate input edges", () => {
|
||||
const edges = [createMessage("m1", "a", "b")];
|
||||
const originalData = { ...edges[0]!.data };
|
||||
|
||||
computeSequenceLayout(
|
||||
[createParticipant("a", "A"), createParticipant("b", "B")],
|
||||
edges,
|
||||
);
|
||||
|
||||
// Original edge data should not have order/messageY
|
||||
expect((edges[0]!.data as Record<string, unknown>).order).toBeUndefined();
|
||||
expect((edges[0]!.data as Record<string, unknown>).messageY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should compute activation bars for sync/return message pairs", () => {
|
||||
const nodes = [
|
||||
createParticipant("client", "Client"),
|
||||
createParticipant("server", "Server"),
|
||||
];
|
||||
// sync from client→server (opens activation on server), return from server→client (closes it)
|
||||
const edges = [
|
||||
createMessage("m1", "client", "server", "seqSync"),
|
||||
createMessage("m2", "server", "client", "seqReturn"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
// Server should have one activation bar
|
||||
const server = result.nodes.find((n) => n.id === "server")!;
|
||||
const serverData = server.data as Record<string, unknown>;
|
||||
const activations = serverData.activations as { y: number; height: number }[];
|
||||
expect(activations).toHaveLength(1);
|
||||
|
||||
// Activation starts at m1's Y, ends at m2's Y
|
||||
const m1Y = SEQ_LAYOUT.messageStartY + 0 * SEQ_LAYOUT.messageSpacing;
|
||||
const m2Y = SEQ_LAYOUT.messageStartY + 1 * SEQ_LAYOUT.messageSpacing;
|
||||
expect(activations[0]!.y).toBe(m1Y);
|
||||
expect(activations[0]!.height).toBe(m2Y - m1Y);
|
||||
});
|
||||
|
||||
it("should close open activations at lifeline end", () => {
|
||||
const nodes = [
|
||||
createParticipant("client", "Client"),
|
||||
createParticipant("server", "Server"),
|
||||
];
|
||||
// sync without a matching return — activation stays open
|
||||
const edges = [
|
||||
createMessage("m1", "client", "server", "seqSync"),
|
||||
];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
const server = result.nodes.find((n) => n.id === "server")!;
|
||||
const activations = (server.data as Record<string, unknown>).activations as { y: number; height: number }[];
|
||||
expect(activations).toHaveLength(1);
|
||||
// Should extend to near the end of the lifeline
|
||||
expect(activations[0]!.y).toBe(SEQ_LAYOUT.messageStartY);
|
||||
const lifelineEnd =
|
||||
SEQ_LAYOUT.messageStartY +
|
||||
edges.length * SEQ_LAYOUT.messageSpacing; // lifelineHeight - padding
|
||||
expect(activations[0]!.height).toBe(lifelineEnd - SEQ_LAYOUT.messageStartY);
|
||||
});
|
||||
|
||||
it("should have empty activations for participants with no incoming sync messages", () => {
|
||||
const nodes = [
|
||||
createParticipant("a", "A"),
|
||||
createParticipant("b", "B"),
|
||||
];
|
||||
const edges: Edge[] = [];
|
||||
const result = computeSequenceLayout(nodes, edges);
|
||||
|
||||
for (const node of result.nodes) {
|
||||
if (node.type === "seqParticipant") {
|
||||
const activations = (node.data as Record<string, unknown>).activations as { y: number; height: number }[];
|
||||
expect(activations).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
165
apps/web/src/modules/diagram/lib/sequence-layout.ts
Normal file
165
apps/web/src/modules/diagram/lib/sequence-layout.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import { SEQ_SIZES, SEQ_LAYOUT } from "../types/sequence/constants";
|
||||
|
||||
/**
|
||||
* Custom layout for sequence diagrams.
|
||||
* Positions participants horizontally, computes message Y from edge order,
|
||||
* sizes participant nodes to full lifeline height, computes activation bars,
|
||||
* and positions fragments around their message ranges.
|
||||
*
|
||||
* Returns new node/edge objects — does NOT mutate inputs.
|
||||
*
|
||||
* NOTE: This is synchronous — sequence diagrams are small (no Web Worker needed).
|
||||
*/
|
||||
export function computeSequenceLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const participants = nodes.filter((n) => n.type === "seqParticipant");
|
||||
const fragments = nodes.filter((n) => n.type === "seqFragment");
|
||||
|
||||
// 1. Position participants horizontally (immutable — create new objects)
|
||||
const positionedParticipants = participants.map((p, i) => ({
|
||||
...p,
|
||||
position: { x: i * SEQ_LAYOUT.participantSpacing, y: 0 },
|
||||
}));
|
||||
|
||||
// 2. Compute lifeline height from message count
|
||||
const lifelineHeight =
|
||||
SEQ_LAYOUT.messageStartY +
|
||||
edges.length * SEQ_LAYOUT.messageSpacing +
|
||||
SEQ_LAYOUT.lifelinePadding;
|
||||
|
||||
// 3. Enrich edges with order and messageY (immutable)
|
||||
const enrichedEdges = edges.map((edge, i) => ({
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
order: i,
|
||||
messageY: SEQ_LAYOUT.messageStartY + i * SEQ_LAYOUT.messageSpacing,
|
||||
},
|
||||
}));
|
||||
|
||||
// 4. Compute activation bars per participant
|
||||
// Activation: starts when a sync message arrives (target), ends when a return is sent (source)
|
||||
const activationsMap = new Map<
|
||||
string,
|
||||
{ y: number; height: number }[]
|
||||
>();
|
||||
for (const p of positionedParticipants) {
|
||||
activationsMap.set(p.id, []);
|
||||
}
|
||||
|
||||
// Track open activations per participant: stack of start Y values
|
||||
const openActivations = new Map<string, number[]>();
|
||||
for (const p of positionedParticipants) {
|
||||
openActivations.set(p.id, []);
|
||||
}
|
||||
|
||||
for (const edge of enrichedEdges) {
|
||||
const edgeData = edge.data as Record<string, unknown>;
|
||||
const msgY = edgeData.messageY as number;
|
||||
const originalData = (edge.data as Record<string, unknown> | undefined);
|
||||
const edgeType = (originalData?.type as string) ?? "sync";
|
||||
|
||||
if (edgeType === "sync" || edgeType === "async") {
|
||||
// Open activation on the target participant when sync/async message received
|
||||
const targetStack = openActivations.get(edge.target);
|
||||
if (targetStack) {
|
||||
targetStack.push(msgY);
|
||||
}
|
||||
} else if (edgeType === "return") {
|
||||
// Close activation on the source participant when return is sent
|
||||
const sourceStack = openActivations.get(edge.source);
|
||||
if (sourceStack && sourceStack.length > 0) {
|
||||
const startY = sourceStack.pop()!;
|
||||
const acts = activationsMap.get(edge.source) ?? [];
|
||||
acts.push({ y: startY, height: msgY - startY });
|
||||
activationsMap.set(edge.source, acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open activations at the end of the lifeline
|
||||
for (const [participantId, stack] of openActivations) {
|
||||
const acts = activationsMap.get(participantId) ?? [];
|
||||
for (const startY of stack) {
|
||||
acts.push({
|
||||
y: startY,
|
||||
height: lifelineHeight - SEQ_LAYOUT.lifelinePadding - startY,
|
||||
});
|
||||
}
|
||||
activationsMap.set(participantId, acts);
|
||||
}
|
||||
|
||||
// 5. Set participant height and activation data (immutable)
|
||||
const enrichedParticipants = positionedParticipants.map((p) => ({
|
||||
...p,
|
||||
style: { ...p.style, height: lifelineHeight },
|
||||
data: {
|
||||
...p.data,
|
||||
lifelineHeight,
|
||||
activations: activationsMap.get(p.id) ?? [],
|
||||
},
|
||||
}));
|
||||
|
||||
// 6. Build participant X lookup map
|
||||
const participantXMap = new Map(
|
||||
enrichedParticipants.map((p) => [p.id, p.position.x]),
|
||||
);
|
||||
|
||||
// 7. Position fragment nodes around their messages (immutable)
|
||||
const positionedFragments = fragments.map((frag) => {
|
||||
const fragData = frag.data as Record<string, unknown>;
|
||||
const messageIds = ((fragData.group as string) || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const messageIndices = messageIds
|
||||
.map((id) => enrichedEdges.findIndex((e) => e.id === id))
|
||||
.filter((i) => i >= 0);
|
||||
|
||||
if (messageIndices.length === 0) {
|
||||
return { ...frag };
|
||||
}
|
||||
|
||||
const minIdx = Math.min(...messageIndices);
|
||||
const maxIdx = Math.max(...messageIndices);
|
||||
const topY =
|
||||
SEQ_LAYOUT.messageStartY +
|
||||
minIdx * SEQ_LAYOUT.messageSpacing -
|
||||
SEQ_LAYOUT.fragmentPadding;
|
||||
const bottomY =
|
||||
SEQ_LAYOUT.messageStartY +
|
||||
maxIdx * SEQ_LAYOUT.messageSpacing +
|
||||
SEQ_LAYOUT.fragmentPadding;
|
||||
|
||||
// Find leftmost and rightmost participants involved in fragment messages
|
||||
const involvedEdges = messageIndices.map((i) => enrichedEdges[i]!);
|
||||
const allParticipantIds = new Set(
|
||||
involvedEdges.flatMap((e) => [e.source, e.target]),
|
||||
);
|
||||
const xPositions = [...allParticipantIds].map(
|
||||
(id) => participantXMap.get(id) ?? 0,
|
||||
);
|
||||
const minX = Math.min(...xPositions) - SEQ_LAYOUT.fragmentPadding;
|
||||
const maxX =
|
||||
Math.max(...xPositions) +
|
||||
SEQ_SIZES.participant.w +
|
||||
SEQ_LAYOUT.fragmentPadding;
|
||||
|
||||
return {
|
||||
...frag,
|
||||
position: { x: minX, y: topY },
|
||||
style: {
|
||||
width: maxX - minX,
|
||||
height: bottomY - topY,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: [...enrichedParticipants, ...positionedFragments],
|
||||
edges: enrichedEdges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
|
||||
export function ArchConnectionEdge(props: EdgeProps) {
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetPosition: props.targetPosition,
|
||||
});
|
||||
|
||||
const edgeData = props.data as Record<string, unknown> | undefined;
|
||||
const isAsync = edgeData?.type === "async";
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={props.id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
stroke: "var(--diagram-architecture)",
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: isAsync ? "6 3" : undefined,
|
||||
}}
|
||||
markerEnd="url(#arch-arrow)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="arch-edge-label"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "./constants";
|
||||
|
||||
export function ArchDatabaseNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon || "🗄️";
|
||||
const metadata = d.tag;
|
||||
|
||||
return (
|
||||
<div className="arch-database">
|
||||
<div className="arch-node-icon">{icon}</div>
|
||||
<div className="arch-node-info">
|
||||
<div className="arch-node-label">{d.label}</div>
|
||||
{metadata && <div className="arch-node-meta">{metadata}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "./constants";
|
||||
|
||||
export function ArchExternalNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon || "☁️";
|
||||
const metadata = d.tag;
|
||||
|
||||
return (
|
||||
<div className="arch-external">
|
||||
<div className="arch-node-icon">{icon}</div>
|
||||
<div className="arch-node-info">
|
||||
<div className="arch-node-label">{d.label}</div>
|
||||
{metadata && <div className="arch-node-meta">{metadata}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "./constants";
|
||||
|
||||
export function ArchLoadBalancerNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon || "⚖️";
|
||||
const metadata = d.tag;
|
||||
|
||||
return (
|
||||
<div className="arch-lb">
|
||||
<div className="arch-node-icon">{icon}</div>
|
||||
<div className="arch-node-info">
|
||||
<div className="arch-node-label">{d.label}</div>
|
||||
{metadata && <div className="arch-node-meta">{metadata}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "./constants";
|
||||
|
||||
export function ArchQueueNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon || "📨";
|
||||
const metadata = d.tag;
|
||||
|
||||
return (
|
||||
<div className="arch-queue">
|
||||
<div className="arch-node-icon">{icon}</div>
|
||||
<div className="arch-node-info">
|
||||
<div className="arch-node-label">{d.label}</div>
|
||||
{metadata && <div className="arch-node-meta">{metadata}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "./constants";
|
||||
|
||||
export function ArchServiceNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon || "⚙️";
|
||||
const metadata = d.tag;
|
||||
|
||||
return (
|
||||
<div className="arch-service">
|
||||
<div className="arch-node-icon">{icon}</div>
|
||||
<div className="arch-node-info">
|
||||
<div className="arch-node-label">{d.label}</div>
|
||||
{metadata && <div className="arch-node-meta">{metadata}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ARCH_SIZES,
|
||||
getArchNodeSize,
|
||||
resolveArchitectureNodeType,
|
||||
resolveArchitectureEdgeType,
|
||||
} from "./constants";
|
||||
|
||||
describe("resolveArchitectureNodeType", () => {
|
||||
it("should resolve arch:service to archService", () => {
|
||||
expect(resolveArchitectureNodeType("arch:service")).toBe("archService");
|
||||
});
|
||||
|
||||
it("should resolve arch:database to archDatabase", () => {
|
||||
expect(resolveArchitectureNodeType("arch:database")).toBe("archDatabase");
|
||||
});
|
||||
|
||||
it("should resolve arch:queue to archQueue", () => {
|
||||
expect(resolveArchitectureNodeType("arch:queue")).toBe("archQueue");
|
||||
});
|
||||
|
||||
it("should resolve arch:loadbalancer to archLoadBalancer", () => {
|
||||
expect(resolveArchitectureNodeType("arch:loadbalancer")).toBe(
|
||||
"archLoadBalancer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve arch:external to archExternal", () => {
|
||||
expect(resolveArchitectureNodeType("arch:external")).toBe("archExternal");
|
||||
});
|
||||
|
||||
it("should resolve bare type without prefix", () => {
|
||||
expect(resolveArchitectureNodeType("database")).toBe("archDatabase");
|
||||
});
|
||||
|
||||
it("should default unknown types to archService", () => {
|
||||
expect(resolveArchitectureNodeType("arch:unknown")).toBe("archService");
|
||||
expect(resolveArchitectureNodeType("something")).toBe("archService");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveArchitectureEdgeType", () => {
|
||||
it("should always return archConnection", () => {
|
||||
expect(resolveArchitectureEdgeType("sync")).toBe("archConnection");
|
||||
expect(resolveArchitectureEdgeType("async")).toBe("archConnection");
|
||||
expect(resolveArchitectureEdgeType(undefined)).toBe("archConnection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArchNodeSize", () => {
|
||||
it("should return correct size for archService", () => {
|
||||
expect(getArchNodeSize("archService")).toEqual(ARCH_SIZES.service);
|
||||
});
|
||||
|
||||
it("should return correct size for archDatabase", () => {
|
||||
expect(getArchNodeSize("archDatabase")).toEqual(ARCH_SIZES.database);
|
||||
});
|
||||
|
||||
it("should return correct size for archQueue", () => {
|
||||
expect(getArchNodeSize("archQueue")).toEqual(ARCH_SIZES.queue);
|
||||
});
|
||||
|
||||
it("should return correct size for archLoadBalancer", () => {
|
||||
expect(getArchNodeSize("archLoadBalancer")).toEqual(
|
||||
ARCH_SIZES.loadbalancer,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return correct size for archExternal", () => {
|
||||
expect(getArchNodeSize("archExternal")).toEqual(ARCH_SIZES.external);
|
||||
});
|
||||
|
||||
it("should return null for non-architecture types", () => {
|
||||
expect(getArchNodeSize("erEntity")).toBeNull();
|
||||
expect(getArchNodeSize("bpmnActivity")).toBeNull();
|
||||
expect(getArchNodeSize(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
53
apps/web/src/modules/diagram/types/architecture/constants.ts
Normal file
53
apps/web/src/modules/diagram/types/architecture/constants.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Shared invisible handle style — extracted to avoid object allocation on every render. */
|
||||
export const HIDDEN_HANDLE = { opacity: 0 } as const;
|
||||
|
||||
/** Architecture diagram node dimensions for ELK layout spacing. */
|
||||
export const ARCH_SIZES = {
|
||||
service: { w: 200, h: 80 },
|
||||
database: { w: 160, h: 100 },
|
||||
queue: { w: 180, h: 70 },
|
||||
loadbalancer: { w: 120, h: 120 },
|
||||
external: { w: 180, h: 80 },
|
||||
} as const;
|
||||
|
||||
const ARCH_TYPE_MAP: Record<string, { w: number; h: number }> = {
|
||||
archService: ARCH_SIZES.service,
|
||||
archDatabase: ARCH_SIZES.database,
|
||||
archQueue: ARCH_SIZES.queue,
|
||||
archLoadBalancer: ARCH_SIZES.loadbalancer,
|
||||
archExternal: ARCH_SIZES.external,
|
||||
};
|
||||
|
||||
/** Get architecture node dimensions by @xyflow/react node type. Returns null if not an architecture type. */
|
||||
export function getArchNodeSize(
|
||||
flowType: string | undefined,
|
||||
): { w: number; h: number } | null {
|
||||
if (!flowType) return null;
|
||||
return ARCH_TYPE_MAP[flowType] ?? null;
|
||||
}
|
||||
|
||||
/** Map DiagramNode.type (with or without arch: prefix) to @xyflow/react node type string. */
|
||||
export function resolveArchitectureNodeType(type: string): string {
|
||||
const bare = type.startsWith("arch:") ? type.slice(5) : type;
|
||||
switch (bare) {
|
||||
case "service":
|
||||
return "archService";
|
||||
case "database":
|
||||
return "archDatabase";
|
||||
case "queue":
|
||||
return "archQueue";
|
||||
case "loadbalancer":
|
||||
return "archLoadBalancer";
|
||||
case "external":
|
||||
return "archExternal";
|
||||
default:
|
||||
return "archService";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map DiagramEdge.type to @xyflow/react edge type string for architecture diagrams. */
|
||||
export function resolveArchitectureEdgeType(
|
||||
_type: string | undefined,
|
||||
): string {
|
||||
return "archConnection";
|
||||
}
|
||||
47
apps/web/src/modules/diagram/types/er/ErEntityNode.tsx
Normal file
47
apps/web/src/modules/diagram/types/er/ErEntityNode.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { DiagramNode } from "../graph";
|
||||
|
||||
export function ErEntityNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const columns = d.columns ?? [];
|
||||
|
||||
return (
|
||||
<div className="er-entity">
|
||||
<div className="er-entity-header">{d.label}</div>
|
||||
<div className="er-entity-body">
|
||||
{columns.length === 0 && (
|
||||
<div className="er-entity-row er-entity-empty">No attributes</div>
|
||||
)}
|
||||
{columns.map((col, i) => (
|
||||
<div key={col.name} className="er-entity-row">
|
||||
<span className="er-entity-indicator">
|
||||
{col.isPrimaryKey ? "🔑" : col.isForeignKey ? "→" : ""}
|
||||
</span>
|
||||
<span className="er-entity-col-name">{col.name}</span>
|
||||
<span className="er-entity-col-type">{col.type}</span>
|
||||
<span className="er-entity-constraint">
|
||||
{col.isNullable ? "?" : ""}
|
||||
{col.isUnique ? "U" : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx
Normal file
102
apps/web/src/modules/diagram/types/er/ErRelationshipEdge.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getSmoothStepPath,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
|
||||
/** Offset (px) from the endpoint along the edge segment for cardinality labels. */
|
||||
const CARD_OFFSET = 24;
|
||||
|
||||
/** Compute cardinality label position offset along the actual edge segment direction. */
|
||||
function cardinalityOffset(
|
||||
x: number,
|
||||
y: number,
|
||||
position: Position,
|
||||
): { x: number; y: number } {
|
||||
switch (position) {
|
||||
case Position.Top:
|
||||
return { x, y: y - CARD_OFFSET };
|
||||
case Position.Bottom:
|
||||
return { x, y: y + CARD_OFFSET };
|
||||
case Position.Left:
|
||||
return { x: x - CARD_OFFSET, y };
|
||||
case Position.Right:
|
||||
return { x: x + CARD_OFFSET, y };
|
||||
}
|
||||
}
|
||||
|
||||
export function ErRelationshipEdge(props: EdgeProps) {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetPosition: props.targetPosition,
|
||||
});
|
||||
|
||||
const cardinality = (
|
||||
props.data as Record<string, unknown> | undefined
|
||||
)?.cardinality as string | undefined;
|
||||
|
||||
const parts = cardinality?.split(":") ?? [];
|
||||
const srcCard = parts[0] || undefined;
|
||||
const tgtCard = parts[1] || undefined;
|
||||
|
||||
const srcPos = cardinalityOffset(
|
||||
props.sourceX,
|
||||
props.sourceY,
|
||||
props.sourcePosition,
|
||||
);
|
||||
const tgtPos = cardinalityOffset(
|
||||
props.targetX,
|
||||
props.targetY,
|
||||
props.targetPosition,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={props.id}
|
||||
path={edgePath}
|
||||
markerEnd="url(#er-arrow)"
|
||||
style={{ stroke: "var(--diagram-er)", strokeWidth: 1.5 }}
|
||||
label={props.label}
|
||||
labelStyle={{ fill: "var(--foreground)", fontSize: 11 }}
|
||||
labelBgStyle={{
|
||||
fill: "var(--node-bg)",
|
||||
fillOpacity: 0.9,
|
||||
}}
|
||||
labelShowBg
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{srcCard && (
|
||||
<div
|
||||
className="er-cardinality nodrag nopan"
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${srcPos.x}px, ${srcPos.y}px)`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{srcCard}
|
||||
</div>
|
||||
)}
|
||||
{tgtCard && (
|
||||
<div
|
||||
className="er-cardinality nodrag nopan"
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${tgtPos.x}px, ${tgtPos.y}px)`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{tgtCard}
|
||||
</div>
|
||||
)}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/modules/diagram/types/er/constants.test.ts
Normal file
85
apps/web/src/modules/diagram/types/er/constants.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
ER_SIZES,
|
||||
getErEntityHeight,
|
||||
resolveErNodeType,
|
||||
resolveErEdgeType,
|
||||
} from "./constants";
|
||||
import type { Column } from "../graph";
|
||||
|
||||
describe("resolveErNodeType", () => {
|
||||
it("should resolve er:entity to erEntity", () => {
|
||||
expect(resolveErNodeType("er:entity")).toBe("erEntity");
|
||||
});
|
||||
|
||||
it("should resolve bare entity to erEntity", () => {
|
||||
expect(resolveErNodeType("entity")).toBe("erEntity");
|
||||
});
|
||||
|
||||
it("should default unknown types to erEntity", () => {
|
||||
expect(resolveErNodeType("er:unknown")).toBe("erEntity");
|
||||
expect(resolveErNodeType("something")).toBe("erEntity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveErEdgeType", () => {
|
||||
it("should always resolve to erRelationship", () => {
|
||||
expect(resolveErEdgeType("relationship")).toBe("erRelationship");
|
||||
expect(resolveErEdgeType(undefined)).toBe("erRelationship");
|
||||
expect(resolveErEdgeType("inheritance")).toBe("erRelationship");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getErEntityHeight", () => {
|
||||
it("should compute height with no columns (uses minRows=1)", () => {
|
||||
const expected =
|
||||
ER_SIZES.entity.headerH +
|
||||
ER_SIZES.entity.minRows * ER_SIZES.entity.rowH +
|
||||
ER_SIZES.entity.paddingY * 2;
|
||||
expect(getErEntityHeight()).toBe(expected);
|
||||
expect(getErEntityHeight(undefined)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should compute height with empty columns array (uses minRows=1)", () => {
|
||||
const expected =
|
||||
ER_SIZES.entity.headerH +
|
||||
ER_SIZES.entity.minRows * ER_SIZES.entity.rowH +
|
||||
ER_SIZES.entity.paddingY * 2;
|
||||
expect(getErEntityHeight([])).toBe(expected);
|
||||
});
|
||||
|
||||
it("should compute height based on column count", () => {
|
||||
const columns: Column[] = [
|
||||
{ name: "id", type: "uuid", isPrimaryKey: true },
|
||||
{ name: "name", type: "text" },
|
||||
{ name: "email", type: "varchar" },
|
||||
];
|
||||
const expected =
|
||||
ER_SIZES.entity.headerH +
|
||||
3 * ER_SIZES.entity.rowH +
|
||||
ER_SIZES.entity.paddingY * 2;
|
||||
expect(getErEntityHeight(columns)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should compute correct height for many columns", () => {
|
||||
const columns: Column[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `col_${i}`,
|
||||
type: "text",
|
||||
}));
|
||||
const expected =
|
||||
ER_SIZES.entity.headerH +
|
||||
10 * ER_SIZES.entity.rowH +
|
||||
ER_SIZES.entity.paddingY * 2;
|
||||
expect(getErEntityHeight(columns)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ER_SIZES", () => {
|
||||
it("should have entity dimensions defined", () => {
|
||||
expect(ER_SIZES.entity.w).toBe(260);
|
||||
expect(ER_SIZES.entity.headerH).toBe(36);
|
||||
expect(ER_SIZES.entity.rowH).toBe(24);
|
||||
expect(ER_SIZES.entity.paddingY).toBe(8);
|
||||
expect(ER_SIZES.entity.minRows).toBe(1);
|
||||
});
|
||||
});
|
||||
40
apps/web/src/modules/diagram/types/er/constants.ts
Normal file
40
apps/web/src/modules/diagram/types/er/constants.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Column } from "../graph";
|
||||
|
||||
/** E-R entity layout dimensions for ELK spacing. */
|
||||
export const ER_SIZES = {
|
||||
entity: {
|
||||
w: 260,
|
||||
headerH: 36,
|
||||
rowH: 24,
|
||||
paddingY: 8,
|
||||
minRows: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Compute entity height based on column count for ELK layout. */
|
||||
export function getErEntityHeight(columns?: Column[]): number {
|
||||
const rows = Math.max(
|
||||
ER_SIZES.entity.minRows,
|
||||
columns?.length ?? 0,
|
||||
);
|
||||
return (
|
||||
ER_SIZES.entity.headerH +
|
||||
rows * ER_SIZES.entity.rowH +
|
||||
ER_SIZES.entity.paddingY * 2
|
||||
);
|
||||
}
|
||||
|
||||
/** Map DiagramNode.type (with or without er: prefix) to @xyflow/react node type string. */
|
||||
export function resolveErNodeType(type: string): string {
|
||||
const bare = type.startsWith("er:") ? type.slice(3) : type;
|
||||
switch (bare) {
|
||||
case "entity":
|
||||
default:
|
||||
return "erEntity";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map DiagramEdge.type to @xyflow/react edge type string for E-R diagrams. */
|
||||
export function resolveErEdgeType(_type: string | undefined): string {
|
||||
return "erRelationship";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseEdge, getSmoothStepPath } from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
|
||||
export function OrgchartHierarchyEdge(props: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetPosition: props.targetPosition,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={props.id}
|
||||
path={edgePath}
|
||||
style={{ stroke: "var(--diagram-orgchart)", strokeWidth: 2 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { DiagramNode } from "../graph";
|
||||
|
||||
export function OrgchartPersonNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const icon = d.icon ?? "\u{1F464}";
|
||||
const role = d.tag;
|
||||
const department = d.group;
|
||||
const accentColor = d.color || undefined;
|
||||
|
||||
return (
|
||||
<div className="oc-person" style={accentColor ? { borderLeftColor: accentColor } : undefined}>
|
||||
<div className="oc-person-avatar">{icon}</div>
|
||||
<div className="oc-person-info">
|
||||
<div className="oc-person-name">{d.label}</div>
|
||||
{role && <div className="oc-person-role">{role}</div>}
|
||||
{department && <div className="oc-person-dept">{department}</div>}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={{ opacity: 0 }} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={{ opacity: 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
OC_SIZES,
|
||||
resolveOrgchartNodeType,
|
||||
resolveOrgchartEdgeType,
|
||||
} from "./constants";
|
||||
|
||||
describe("OC_SIZES", () => {
|
||||
it("should have person dimensions", () => {
|
||||
expect(OC_SIZES.person.w).toBe(280);
|
||||
expect(OC_SIZES.person.h).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOrgchartNodeType", () => {
|
||||
it("should resolve org:person to orgchartPerson", () => {
|
||||
expect(resolveOrgchartNodeType("org:person")).toBe("orgchartPerson");
|
||||
});
|
||||
|
||||
it("should resolve bare person to orgchartPerson", () => {
|
||||
expect(resolveOrgchartNodeType("person")).toBe("orgchartPerson");
|
||||
});
|
||||
|
||||
it("should default unknown types to orgchartPerson", () => {
|
||||
expect(resolveOrgchartNodeType("org:unknown")).toBe("orgchartPerson");
|
||||
expect(resolveOrgchartNodeType("foo")).toBe("orgchartPerson");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOrgchartEdgeType", () => {
|
||||
it("should return orgchartHierarchy for any edge type", () => {
|
||||
expect(resolveOrgchartEdgeType("hierarchy")).toBe("orgchartHierarchy");
|
||||
expect(resolveOrgchartEdgeType(undefined)).toBe("orgchartHierarchy");
|
||||
expect(resolveOrgchartEdgeType("other")).toBe("orgchartHierarchy");
|
||||
});
|
||||
});
|
||||
22
apps/web/src/modules/diagram/types/orgchart/constants.ts
Normal file
22
apps/web/src/modules/diagram/types/orgchart/constants.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Org chart node dimensions for ELK layout spacing. */
|
||||
export const OC_SIZES = {
|
||||
person: {
|
||||
w: 280,
|
||||
h: 80,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Map DiagramNode.type (with or without org: prefix) to @xyflow/react node type string. */
|
||||
export function resolveOrgchartNodeType(type: string): string {
|
||||
const bare = type.startsWith("org:") ? type.slice(4) : type;
|
||||
switch (bare) {
|
||||
case "person":
|
||||
default:
|
||||
return "orgchartPerson";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map DiagramEdge.type to @xyflow/react edge type string for org chart diagrams. */
|
||||
export function resolveOrgchartEdgeType(_type: string | undefined): string {
|
||||
return "orgchartHierarchy";
|
||||
}
|
||||
7
apps/web/src/modules/diagram/types/orgchart/index.ts
Normal file
7
apps/web/src/modules/diagram/types/orgchart/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { OrgchartPersonNode } from "./OrgchartPersonNode";
|
||||
export { OrgchartHierarchyEdge } from "./OrgchartHierarchyEdge";
|
||||
export {
|
||||
OC_SIZES,
|
||||
resolveOrgchartNodeType,
|
||||
resolveOrgchartEdgeType,
|
||||
} from "./constants";
|
||||
81
apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx
Normal file
81
apps/web/src/modules/diagram/types/sequence/SeqAsyncEdge.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { EdgeLabelRenderer } from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
import { SEQ_LAYOUT, SEQ_SIZES } from "./constants";
|
||||
|
||||
const SELF_MSG_WIDTH = 40;
|
||||
|
||||
export function SeqAsyncEdge(props: EdgeProps) {
|
||||
const edgeData = props.data as Record<string, unknown> | undefined;
|
||||
const order = (edgeData?.order as number) ?? 0;
|
||||
const messageY = (edgeData?.messageY as number) ??
|
||||
SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing;
|
||||
|
||||
const isSelfMessage = props.source === props.target;
|
||||
|
||||
if (isSelfMessage) {
|
||||
const x = props.sourceX + SEQ_SIZES.participant.w / 2;
|
||||
const loopRight = x + SELF_MSG_WIDTH;
|
||||
const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6;
|
||||
const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 3"
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-open)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isLeftToRight = props.sourceX < props.targetX;
|
||||
const startX = isLeftToRight
|
||||
? props.sourceX + SEQ_SIZES.participant.w / 2
|
||||
: props.sourceX - SEQ_SIZES.participant.w / 2;
|
||||
const endX = isLeftToRight
|
||||
? props.targetX - SEQ_SIZES.participant.w / 2
|
||||
: props.targetX + SEQ_SIZES.participant.w / 2;
|
||||
|
||||
const path = `M ${startX} ${messageY} L ${endX} ${messageY}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 3"
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-open)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { DiagramNode } from "../graph";
|
||||
|
||||
export function SeqFragmentNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & { label: string };
|
||||
const fragmentType = d.label; // "alt" | "loop" | "opt"
|
||||
const guardCondition = d.tag;
|
||||
|
||||
return (
|
||||
<div className="seq-fragment">
|
||||
<div className="seq-fragment-header">
|
||||
<span className="seq-fragment-type">{fragmentType}</span>
|
||||
{guardCondition && (
|
||||
<span className="seq-fragment-guard">{guardCondition}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { DiagramNode } from "../graph";
|
||||
import { HIDDEN_HANDLE } from "../architecture/constants";
|
||||
import { SEQ_SIZES } from "./constants";
|
||||
|
||||
export function SeqParticipantNode({ data }: NodeProps) {
|
||||
const d = data as unknown as DiagramNode & {
|
||||
label: string;
|
||||
lifelineHeight?: number;
|
||||
activations?: { y: number; height: number }[];
|
||||
};
|
||||
const icon = d.icon || "👤";
|
||||
const lifelineHeight = d.lifelineHeight ?? 400;
|
||||
const activations = d.activations ?? [];
|
||||
|
||||
return (
|
||||
<div className="seq-participant" style={{ height: lifelineHeight }}>
|
||||
<div className="seq-participant-box">
|
||||
<span className="seq-participant-icon">{icon}</span>
|
||||
<span className="seq-participant-label">{d.label}</span>
|
||||
</div>
|
||||
<div
|
||||
className="seq-lifeline"
|
||||
style={{ height: lifelineHeight - SEQ_SIZES.participant.h }}
|
||||
/>
|
||||
{activations.map((act, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="seq-activation"
|
||||
style={{ top: act.y, height: act.height }}
|
||||
/>
|
||||
))}
|
||||
<Handle type="target" position={Position.Top} style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HIDDEN_HANDLE} />
|
||||
<Handle type="target" position={Position.Left} id="left" style={HIDDEN_HANDLE} />
|
||||
<Handle type="source" position={Position.Right} id="right" style={HIDDEN_HANDLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { EdgeLabelRenderer } from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
import { SEQ_LAYOUT, SEQ_SIZES } from "./constants";
|
||||
|
||||
const SELF_MSG_WIDTH = 40;
|
||||
|
||||
export function SeqReturnEdge(props: EdgeProps) {
|
||||
const edgeData = props.data as Record<string, unknown> | undefined;
|
||||
const order = (edgeData?.order as number) ?? 0;
|
||||
const messageY = (edgeData?.messageY as number) ??
|
||||
SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing;
|
||||
|
||||
const isSelfMessage = props.source === props.target;
|
||||
|
||||
if (isSelfMessage) {
|
||||
const x = props.sourceX + SEQ_SIZES.participant.w / 2;
|
||||
const loopRight = x + SELF_MSG_WIDTH;
|
||||
const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6;
|
||||
const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-filled)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isLeftToRight = props.sourceX < props.targetX;
|
||||
const startX = isLeftToRight
|
||||
? props.sourceX + SEQ_SIZES.participant.w / 2
|
||||
: props.sourceX - SEQ_SIZES.participant.w / 2;
|
||||
const endX = isLeftToRight
|
||||
? props.targetX - SEQ_SIZES.participant.w / 2
|
||||
: props.targetX + SEQ_SIZES.participant.w / 2;
|
||||
|
||||
const path = `M ${startX} ${messageY} L ${endX} ${messageY}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-filled)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx
Normal file
80
apps/web/src/modules/diagram/types/sequence/SeqSyncEdge.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { EdgeLabelRenderer } from "@xyflow/react";
|
||||
import type { EdgeProps } from "@xyflow/react";
|
||||
import { SEQ_LAYOUT, SEQ_SIZES } from "./constants";
|
||||
|
||||
const SELF_MSG_WIDTH = 40;
|
||||
|
||||
export function SeqSyncEdge(props: EdgeProps) {
|
||||
const edgeData = props.data as Record<string, unknown> | undefined;
|
||||
const order = (edgeData?.order as number) ?? 0;
|
||||
const messageY = (edgeData?.messageY as number) ??
|
||||
SEQ_LAYOUT.messageStartY + order * SEQ_LAYOUT.messageSpacing;
|
||||
|
||||
const isSelfMessage = props.source === props.target;
|
||||
|
||||
if (isSelfMessage) {
|
||||
// Self-message: U-shaped loop on the right side of the participant
|
||||
const x = props.sourceX + SEQ_SIZES.participant.w / 2;
|
||||
const loopRight = x + SELF_MSG_WIDTH;
|
||||
const loopBottom = messageY + SEQ_LAYOUT.messageSpacing * 0.6;
|
||||
const path = `M ${x} ${messageY} L ${loopRight} ${messageY} L ${loopRight} ${loopBottom} L ${x} ${loopBottom}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-filled)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(0, -100%) translate(${loopRight + 4}px, ${messageY + (loopBottom - messageY) / 2}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isLeftToRight = props.sourceX < props.targetX;
|
||||
const startX = isLeftToRight
|
||||
? props.sourceX + SEQ_SIZES.participant.w / 2
|
||||
: props.sourceX - SEQ_SIZES.participant.w / 2;
|
||||
const endX = isLeftToRight
|
||||
? props.targetX - SEQ_SIZES.participant.w / 2
|
||||
: props.targetX + SEQ_SIZES.participant.w / 2;
|
||||
|
||||
const path = `M ${startX} ${messageY} L ${endX} ${messageY}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
stroke="var(--diagram-sequence)"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
markerEnd="url(#seq-arrow-filled)"
|
||||
/>
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="seq-edge-label seq-edge-label-positioned"
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) translate(${(startX + endX) / 2}px, ${messageY - 4}px)`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SEQ_SIZES,
|
||||
SEQ_LAYOUT,
|
||||
resolveSequenceNodeType,
|
||||
resolveSequenceEdgeType,
|
||||
getSeqNodeSize,
|
||||
} from "./constants";
|
||||
|
||||
describe("SEQ_SIZES", () => {
|
||||
it("should have participant dimensions", () => {
|
||||
expect(SEQ_SIZES.participant).toEqual({ w: 160, h: 60 });
|
||||
});
|
||||
|
||||
it("should have fragment dimensions (computed dynamically)", () => {
|
||||
expect(SEQ_SIZES.fragment).toEqual({ w: 0, h: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEQ_LAYOUT", () => {
|
||||
it("should have all layout constants", () => {
|
||||
expect(SEQ_LAYOUT.participantSpacing).toBe(200);
|
||||
expect(SEQ_LAYOUT.messageStartY).toBe(100);
|
||||
expect(SEQ_LAYOUT.messageSpacing).toBe(50);
|
||||
expect(SEQ_LAYOUT.lifelinePadding).toBe(40);
|
||||
expect(SEQ_LAYOUT.activationWidth).toBe(12);
|
||||
expect(SEQ_LAYOUT.fragmentPadding).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSequenceNodeType", () => {
|
||||
it("should resolve seq:participant to seqParticipant", () => {
|
||||
expect(resolveSequenceNodeType("seq:participant")).toBe("seqParticipant");
|
||||
});
|
||||
|
||||
it("should resolve seq:fragment to seqFragment", () => {
|
||||
expect(resolveSequenceNodeType("seq:fragment")).toBe("seqFragment");
|
||||
});
|
||||
|
||||
it("should resolve bare participant to seqParticipant", () => {
|
||||
expect(resolveSequenceNodeType("participant")).toBe("seqParticipant");
|
||||
});
|
||||
|
||||
it("should resolve bare fragment to seqFragment", () => {
|
||||
expect(resolveSequenceNodeType("fragment")).toBe("seqFragment");
|
||||
});
|
||||
|
||||
it("should default unknown types to seqParticipant", () => {
|
||||
expect(resolveSequenceNodeType("unknown")).toBe("seqParticipant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSequenceEdgeType", () => {
|
||||
it("should resolve sync to seqSync", () => {
|
||||
expect(resolveSequenceEdgeType("sync")).toBe("seqSync");
|
||||
});
|
||||
|
||||
it("should resolve async to seqAsync", () => {
|
||||
expect(resolveSequenceEdgeType("async")).toBe("seqAsync");
|
||||
});
|
||||
|
||||
it("should resolve return to seqReturn", () => {
|
||||
expect(resolveSequenceEdgeType("return")).toBe("seqReturn");
|
||||
});
|
||||
|
||||
it("should default undefined to seqSync", () => {
|
||||
expect(resolveSequenceEdgeType(undefined)).toBe("seqSync");
|
||||
});
|
||||
|
||||
it("should default unknown types to seqSync", () => {
|
||||
expect(resolveSequenceEdgeType("unknown")).toBe("seqSync");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSeqNodeSize", () => {
|
||||
it("should return participant size for seqParticipant", () => {
|
||||
expect(getSeqNodeSize("seqParticipant")).toEqual(SEQ_SIZES.participant);
|
||||
});
|
||||
|
||||
it("should return null for non-sequence types", () => {
|
||||
expect(getSeqNodeSize("default")).toBeNull();
|
||||
expect(getSeqNodeSize("bpmnActivity")).toBeNull();
|
||||
expect(getSeqNodeSize(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for seqFragment (dynamically computed)", () => {
|
||||
expect(getSeqNodeSize("seqFragment")).toBeNull();
|
||||
});
|
||||
});
|
||||
49
apps/web/src/modules/diagram/types/sequence/constants.ts
Normal file
49
apps/web/src/modules/diagram/types/sequence/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const SEQ_SIZES = {
|
||||
participant: { w: 160, h: 60 },
|
||||
fragment: { w: 0, h: 0 }, // Computed dynamically from contained messages
|
||||
} as const;
|
||||
|
||||
export const SEQ_LAYOUT = {
|
||||
participantSpacing: 200,
|
||||
messageStartY: 100,
|
||||
messageSpacing: 50,
|
||||
lifelinePadding: 40,
|
||||
activationWidth: 12,
|
||||
fragmentPadding: 16,
|
||||
} as const;
|
||||
|
||||
export function resolveSequenceNodeType(type: string): string {
|
||||
const bare = type.startsWith("seq:") ? type.slice(4) : type;
|
||||
switch (bare) {
|
||||
case "participant":
|
||||
return "seqParticipant";
|
||||
case "fragment":
|
||||
return "seqFragment";
|
||||
default:
|
||||
return "seqParticipant";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSequenceEdgeType(type: string | undefined): string {
|
||||
switch (type) {
|
||||
case "sync":
|
||||
return "seqSync";
|
||||
case "async":
|
||||
return "seqAsync";
|
||||
case "return":
|
||||
return "seqReturn";
|
||||
default:
|
||||
return "seqSync";
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeqNodeSize(
|
||||
flowType: string | undefined,
|
||||
): { w: number; h: number } | null {
|
||||
switch (flowType) {
|
||||
case "seqParticipant":
|
||||
return SEQ_SIZES.participant;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user