sad-mcp 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { LaidOutModel } from "./types.js";
2
+ export declare function buildBPMNXml(laid: LaidOutModel): string;
3
+ export declare function safeFilename(m: {
4
+ title: {
5
+ he: string;
6
+ en?: string;
7
+ };
8
+ }): string;
@@ -0,0 +1,167 @@
1
+ // Generates BPMN 2.0 XML (with DI section) server-side, using the
2
+ // coordinates already computed by the layout pass. The VP export button
3
+ // embedded in the HTML just triggers a download of this precomputed
4
+ // string — no DOM walking, no getBBox() calls.
5
+ //
6
+ // Reference namespaces from OMG BPMN 2.0:
7
+ // bpmn http://www.omg.org/spec/BPMN/20100524/MODEL
8
+ // bpmndi http://www.omg.org/spec/BPMN/20100524/DI
9
+ // dc http://www.omg.org/spec/DD/20100524/DC
10
+ // di http://www.omg.org/spec/DD/20100524/DI
11
+ import { escapeXml } from "./svg.js";
12
+ export function buildBPMNXml(laid) {
13
+ const m = laid.model;
14
+ const title = escapeXml(m.title.en || m.title.he || "process");
15
+ // Map pool id → process id (each pool gets its own process element)
16
+ const processIds = new Map();
17
+ m.pools.forEach((p, i) => processIds.set(p.id, `process_${i + 1}`));
18
+ const processLines = [];
19
+ const collabLines = [];
20
+ // Participants reference pool processes
21
+ for (const p of m.pools) {
22
+ const procId = processIds.get(p.id);
23
+ collabLines.push(` <bpmn:participant id="${escapeXml(p.id)}" name="${escapeXml(p.name)}" processRef="${procId}"/>`);
24
+ }
25
+ for (const mf of m.messageFlows) {
26
+ const src = mf.fromElement ?? mf.fromPool;
27
+ const tgt = mf.toElement ?? mf.toPool;
28
+ const name = mf.label ? ` name="${escapeXml(mf.label)}"` : "";
29
+ collabLines.push(` <bpmn:messageFlow id="${escapeXml(mf.id)}" sourceRef="${escapeXml(src)}" targetRef="${escapeXml(tgt)}"${name}/>`);
30
+ }
31
+ // One bpmn:process block per pool
32
+ const processBlocks = [];
33
+ for (const p of m.pools) {
34
+ const procId = processIds.get(p.id);
35
+ const poolElements = m.elements.filter((el) => {
36
+ if (el.kind === "task" || el.kind === "gateway" || el.kind === "event" || el.kind === "collapsedSubProcess") {
37
+ return el.pool === p.id;
38
+ }
39
+ return false;
40
+ });
41
+ const poolFlows = m.sequenceFlows.filter((f) => {
42
+ const src = m.elements.find((e) => e.id === f.from);
43
+ const tgt = m.elements.find((e) => e.id === f.to);
44
+ if (!src || !tgt)
45
+ return false;
46
+ const srcPool = src.pool;
47
+ const tgtPool = tgt.pool;
48
+ return srcPool === p.id && tgtPool === p.id;
49
+ });
50
+ // Lanes (if organization pool with lanes)
51
+ const laneSetLines = [];
52
+ if (p.lanes.length > 0) {
53
+ laneSetLines.push(` <bpmn:laneSet id="${procId}_laneset">`);
54
+ for (const lane of p.lanes) {
55
+ const laneElements = poolElements.filter((e) => e.lane === lane.id);
56
+ laneSetLines.push(` <bpmn:lane id="${escapeXml(lane.id)}" name="${escapeXml(lane.name)}">`);
57
+ for (const le of laneElements) {
58
+ laneSetLines.push(` <bpmn:flowNodeRef>${escapeXml(le.id)}</bpmn:flowNodeRef>`);
59
+ }
60
+ laneSetLines.push(` </bpmn:lane>`);
61
+ }
62
+ laneSetLines.push(` </bpmn:laneSet>`);
63
+ }
64
+ // Element lines
65
+ const elLines = [];
66
+ for (const el of poolElements) {
67
+ const id = escapeXml(el.id);
68
+ const name = escapeXml(el.name ?? "");
69
+ if (el.kind === "task") {
70
+ elLines.push(` <bpmn:task id="${id}" name="${name}"/>`);
71
+ }
72
+ else if (el.kind === "collapsedSubProcess") {
73
+ elLines.push(` <bpmn:subProcess id="${id}" name="${name}"/>`);
74
+ }
75
+ else if (el.kind === "gateway") {
76
+ const tag = el.gatewayType === "AND" ? "parallelGateway"
77
+ : el.gatewayType === "OR" ? "inclusiveGateway"
78
+ : "exclusiveGateway";
79
+ elLines.push(` <bpmn:${tag} id="${id}" name="${name}"/>`);
80
+ }
81
+ else if (el.kind === "event") {
82
+ const tag = el.eventType === "start" ? "startEvent"
83
+ : el.eventType === "end" ? "endEvent"
84
+ : "intermediateCatchEvent";
85
+ elLines.push(` <bpmn:${tag} id="${id}" name="${name}"/>`);
86
+ }
87
+ }
88
+ // Data stores and objects are declared at process level
89
+ for (const el of m.elements) {
90
+ if (el.kind === "dataStore") {
91
+ elLines.push(` <bpmn:dataStoreReference id="${escapeXml(el.id)}" name="${escapeXml(el.name)}"/>`);
92
+ }
93
+ else if (el.kind === "dataObject") {
94
+ elLines.push(` <bpmn:dataObjectReference id="${escapeXml(el.id)}" name="${escapeXml(el.name)}"/>`);
95
+ }
96
+ }
97
+ for (const f of poolFlows) {
98
+ const name = f.label ? ` name="${escapeXml(f.label)}"` : "";
99
+ elLines.push(` <bpmn:sequenceFlow id="${escapeXml(f.id)}" sourceRef="${escapeXml(f.from)}" targetRef="${escapeXml(f.to)}"${name}/>`);
100
+ }
101
+ processBlocks.push(` <bpmn:process id="${procId}" name="${escapeXml(p.name)}" isExecutable="false">
102
+ ${laneSetLines.join("\n")}
103
+ ${elLines.join("\n")}
104
+ </bpmn:process>`);
105
+ }
106
+ // DI section — harvest coords from layout
107
+ const diLines = [];
108
+ // Pool shapes
109
+ for (const p of laid.pools) {
110
+ diLines.push(` <bpmndi:BPMNShape id="${escapeXml(p.poolId)}_di" bpmnElement="${escapeXml(p.poolId)}" isHorizontal="true">
111
+ <dc:Bounds x="${round(p.x)}" y="${round(p.y)}" width="${round(p.w)}" height="${round(p.h)}"/>
112
+ </bpmndi:BPMNShape>`);
113
+ for (const ln of p.lanes) {
114
+ if (!ln.laneId)
115
+ continue;
116
+ diLines.push(` <bpmndi:BPMNShape id="${escapeXml(ln.laneId)}_di" bpmnElement="${escapeXml(ln.laneId)}" isHorizontal="true">
117
+ <dc:Bounds x="${round(ln.x)}" y="${round(ln.y)}" width="${round(ln.w)}" height="${round(ln.h)}"/>
118
+ </bpmndi:BPMNShape>`);
119
+ }
120
+ }
121
+ // Element shapes
122
+ for (const [id, pl] of laid.placements) {
123
+ diLines.push(` <bpmndi:BPMNShape id="${escapeXml(id)}_di" bpmnElement="${escapeXml(id)}">
124
+ <dc:Bounds x="${round(pl.box.x)}" y="${round(pl.box.y)}" width="${round(pl.box.w)}" height="${round(pl.box.h)}"/>
125
+ </bpmndi:BPMNShape>`);
126
+ }
127
+ // Sequence flow edges
128
+ for (const r of laid.sequenceRoutes) {
129
+ const waypoints = r.points.map((p) => ` <di:waypoint x="${round(p.x)}" y="${round(p.y)}"/>`).join("\n");
130
+ diLines.push(` <bpmndi:BPMNEdge id="${escapeXml(r.id)}_di" bpmnElement="${escapeXml(r.id)}">
131
+ ${waypoints}
132
+ </bpmndi:BPMNEdge>`);
133
+ }
134
+ for (const r of laid.messageRoutes) {
135
+ const waypoints = r.points.map((p) => ` <di:waypoint x="${round(p.x)}" y="${round(p.y)}"/>`).join("\n");
136
+ diLines.push(` <bpmndi:BPMNEdge id="${escapeXml(r.id)}_di" bpmnElement="${escapeXml(r.id)}">
137
+ ${waypoints}
138
+ </bpmndi:BPMNEdge>`);
139
+ }
140
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
141
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
142
+ xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
143
+ xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
144
+ xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
145
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
146
+ id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn">
147
+ ${processBlocks.join("\n")}
148
+ <bpmn:collaboration id="collab_1">
149
+ ${collabLines.join("\n")}
150
+ </bpmn:collaboration>
151
+ <bpmndi:BPMNDiagram id="BPMNDiagram_1">
152
+ <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="collab_1">
153
+ ${diLines.join("\n")}
154
+ </bpmndi:BPMNPlane>
155
+ </bpmndi:BPMNDiagram>
156
+ </bpmn:definitions>`;
157
+ return xml;
158
+ }
159
+ function round(n) { return Math.round(n); }
160
+ // Returned for completeness — a caller may want the stable safe title
161
+ export function safeFilename(m) {
162
+ const base = (m.title.en || m.title.he || "process")
163
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
164
+ .replace(/\s+/g, "_")
165
+ .slice(0, 60);
166
+ return base || "process";
167
+ }
@@ -0,0 +1,18 @@
1
+ import { layoutModel } from "./layout.js";
2
+ import { routeAll } from "./routing.js";
3
+ import { computeStats, renderHTML, type RenderStats } from "./template.js";
4
+ import { parseAndValidate, validateModel, formatIssues } from "./validate.js";
5
+ import type { DiagramModel, ValidationIssue } from "./types.js";
6
+ import { safeFilename } from "./export-bpmn.js";
7
+ export type RenderResult = {
8
+ ok: true;
9
+ html: string;
10
+ stats: RenderStats;
11
+ filenameBase: string;
12
+ } | {
13
+ ok: false;
14
+ issues: ValidationIssue[];
15
+ };
16
+ export declare function renderBPMN(rawJson: string | object): RenderResult;
17
+ export { validateModel, parseAndValidate, formatIssues, layoutModel, routeAll, renderHTML, computeStats, safeFilename, };
18
+ export type { DiagramModel, ValidationIssue };
@@ -0,0 +1,23 @@
1
+ // Public entry point for the BPMN rendering pipeline.
2
+ // Pipeline: raw JSON → validate → layout → route → render HTML.
3
+ import { layoutModel } from "./layout.js";
4
+ import { routeAll } from "./routing.js";
5
+ import { computeStats, renderHTML } from "./template.js";
6
+ import { parseAndValidate, validateModel, formatIssues } from "./validate.js";
7
+ import { safeFilename } from "./export-bpmn.js";
8
+ export function renderBPMN(rawJson) {
9
+ const vr = typeof rawJson === "string"
10
+ ? parseAndValidate(rawJson)
11
+ : validateModel(rawJson);
12
+ if (!vr.ok)
13
+ return { ok: false, issues: vr.issues };
14
+ const laid = routeAll(layoutModel(vr.model));
15
+ const html = renderHTML(laid);
16
+ return {
17
+ ok: true,
18
+ html,
19
+ stats: computeStats(laid),
20
+ filenameBase: safeFilename(vr.model),
21
+ };
22
+ }
23
+ export { validateModel, parseAndValidate, formatIssues, layoutModel, routeAll, renderHTML, computeStats, safeFilename, };
@@ -0,0 +1,22 @@
1
+ import type { DiagramModel, LaidOutModel } from "./types.js";
2
+ export declare const L: {
3
+ readonly TASK_W: 140;
4
+ readonly TASK_H: 64;
5
+ readonly GW: 48;
6
+ readonly EVT_R: 18;
7
+ readonly COL_W: 186;
8
+ readonly GAP_X: 46;
9
+ readonly GAP_Y: 20;
10
+ readonly POOL_HEADER_W: 30;
11
+ readonly LANE_HEADER_W: 24;
12
+ readonly POOL_VGAP: 16;
13
+ readonly LANE_MIN_H: 140;
14
+ readonly LANE_CORRIDOR_H: 56;
15
+ readonly EMPTY_POOL_H: 60;
16
+ readonly CANVAS_TOP: 72;
17
+ readonly CANVAS_LEFT_PAD: 20;
18
+ readonly CANVAS_RIGHT_PAD: 40;
19
+ readonly CANVAS_BOTTOM_PAD: 20;
20
+ readonly MIN_CANVAS_W: 1400;
21
+ };
22
+ export declare function layoutModel(m: DiagramModel): LaidOutModel;
@@ -0,0 +1,424 @@
1
+ // Layout engine for BPMN diagrams.
2
+ // Input: a validated DiagramModel.
3
+ // Output: a LaidOutModel with every element assigned an (x, y, w, h) box,
4
+ // every pool/lane a Box, and a canvas size. Pure function, no I/O.
5
+ //
6
+ // Algorithm (banded left-to-right flow):
7
+ // 1. Order pools vertically: external-empty initiators on top, then the
8
+ // organization pool, then remaining externals.
9
+ // 2. For each non-empty pool, run Kahn's on sequenceFlows to assign each
10
+ // flow element a rank (column). Merge gateways naturally land at
11
+ // max(pred.rank) + 1 which "pulls them right" past the longest branch.
12
+ // 3. Compute each lane's inner height from the worst-case column
13
+ // (how many elements in that lane stack in one column).
14
+ // 4. Stack pools vertically; place each element at (rankCenterX, laneMidY)
15
+ // with intra-column vertical stacking centered on the lane.
16
+ // 5. Place data stores / data objects in the corridor band at the bottom
17
+ // of whichever lane has the most tasks referencing them.
18
+ //
19
+ // All sizes in CSS pixels (same coordinate space as the final SVG).
20
+ // ── Constants ──────────────────────────────────────────────────────────
21
+ export const L = {
22
+ TASK_W: 140,
23
+ TASK_H: 64,
24
+ GW: 48, // gateway diamond bounding box
25
+ EVT_R: 18, // event radius
26
+ COL_W: 186, // column = TASK_W + GAP_X
27
+ GAP_X: 46,
28
+ GAP_Y: 20,
29
+ POOL_HEADER_W: 30, // vertical label bar on left of every pool
30
+ LANE_HEADER_W: 24, // vertical label bar on left of every lane
31
+ POOL_VGAP: 16,
32
+ LANE_MIN_H: 140,
33
+ LANE_CORRIDOR_H: 56, // bottom band for data stores/objects
34
+ EMPTY_POOL_H: 60,
35
+ CANVAS_TOP: 72, // room for header bar above diagram
36
+ CANVAS_LEFT_PAD: 20,
37
+ CANVAS_RIGHT_PAD: 40,
38
+ CANVAS_BOTTOM_PAD: 20,
39
+ MIN_CANVAS_W: 1400,
40
+ };
41
+ // Element dimensions used for placement / stacking
42
+ function elementSize(el) {
43
+ switch (el.kind) {
44
+ case "task":
45
+ case "collapsedSubProcess":
46
+ return { w: L.TASK_W, h: L.TASK_H };
47
+ case "gateway":
48
+ return { w: L.GW, h: L.GW };
49
+ case "event":
50
+ return { w: L.EVT_R * 2, h: L.EVT_R * 2 };
51
+ case "dataObject":
52
+ return { w: 36, h: 44 };
53
+ case "dataStore":
54
+ return { w: 56, h: 44 };
55
+ }
56
+ }
57
+ function isFlowElement(el) {
58
+ return el.kind === "task"
59
+ || el.kind === "collapsedSubProcess"
60
+ || el.kind === "gateway"
61
+ || el.kind === "event";
62
+ }
63
+ function elementPool(el) {
64
+ if (!isFlowElement(el))
65
+ return null;
66
+ // flow elements always carry pool
67
+ return el.pool;
68
+ }
69
+ function elementLane(el) {
70
+ if (!isFlowElement(el))
71
+ return "";
72
+ const lane = el.lane;
73
+ return lane ?? "";
74
+ }
75
+ // ── Rank assignment (Kahn's) ───────────────────────────────────────────
76
+ function rankPool(poolElements, flows, laneOf) {
77
+ const rank = new Map();
78
+ const elementIds = new Set(poolElements.map((e) => e.id));
79
+ // Build full adjacency list first (needed for back-edge detection)
80
+ const allOut = new Map();
81
+ for (const el of poolElements)
82
+ allOut.set(el.id, []);
83
+ for (const f of flows) {
84
+ if (!elementIds.has(f.from) || !elementIds.has(f.to))
85
+ continue;
86
+ allOut.get(f.from).push(f.to);
87
+ }
88
+ // DFS to identify back-edges (edges pointing to an ancestor in the DFS tree).
89
+ // Removing back-edges turns the cyclic graph into a DAG so Kahn's can rank all nodes.
90
+ const backEdges = new Set();
91
+ const visited = new Set();
92
+ const inStack = new Set();
93
+ const dfs = (id) => {
94
+ if (inStack.has(id) || visited.has(id))
95
+ return;
96
+ visited.add(id);
97
+ inStack.add(id);
98
+ for (const next of allOut.get(id) ?? []) {
99
+ if (inStack.has(next))
100
+ backEdges.add(`${id}→${next}`);
101
+ else
102
+ dfs(next);
103
+ }
104
+ inStack.delete(id);
105
+ };
106
+ for (const el of poolElements)
107
+ dfs(el.id);
108
+ // Rebuild adjacency without back-edges and run Kahn's topological ranking
109
+ const inDeg = new Map();
110
+ const outEdges = new Map();
111
+ for (const el of poolElements) {
112
+ inDeg.set(el.id, 0);
113
+ outEdges.set(el.id, []);
114
+ }
115
+ for (const f of flows) {
116
+ if (!elementIds.has(f.from) || !elementIds.has(f.to))
117
+ continue;
118
+ if (backEdges.has(`${f.from}→${f.to}`))
119
+ continue;
120
+ outEdges.get(f.from).push(f.to);
121
+ inDeg.set(f.to, (inDeg.get(f.to) ?? 0) + 1);
122
+ }
123
+ const queue = [];
124
+ for (const [id, deg] of inDeg) {
125
+ if (deg === 0) {
126
+ rank.set(id, 0);
127
+ queue.push(id);
128
+ }
129
+ }
130
+ while (queue.length > 0) {
131
+ const cur = queue.shift();
132
+ const curRank = rank.get(cur);
133
+ for (const next of outEdges.get(cur) ?? []) {
134
+ // Cross-lane handoffs stay in the same column; same-lane flows advance one column.
135
+ const crossLane = laneOf ? laneOf.get(cur) !== laneOf.get(next) : false;
136
+ const cand = crossLane ? curRank : curRank + 1;
137
+ if (cand > (rank.get(next) ?? -1))
138
+ rank.set(next, cand);
139
+ const d = inDeg.get(next) - 1;
140
+ inDeg.set(next, d);
141
+ if (d === 0)
142
+ queue.push(next);
143
+ }
144
+ }
145
+ // Safety net: any still-unranked node (shouldn't happen after back-edge removal)
146
+ let maxRank = 0;
147
+ for (const r of rank.values())
148
+ if (r > maxRank)
149
+ maxRank = r;
150
+ for (const el of poolElements) {
151
+ if (!rank.has(el.id))
152
+ rank.set(el.id, maxRank + 1);
153
+ }
154
+ return rank;
155
+ }
156
+ // ── Main layout ────────────────────────────────────────────────────────
157
+ export function layoutModel(m) {
158
+ const placements = new Map();
159
+ // 1. Pool ordering
160
+ const orderedPools = orderPools(m);
161
+ // 2. Rank flow elements per pool; figure out max rank across the diagram
162
+ const poolRanks = new Map();
163
+ for (const p of orderedPools) {
164
+ if (p.type === "external-empty")
165
+ continue;
166
+ const poolEls = m.elements.filter(isFlowElement).filter((e) => e.pool === p.id);
167
+ const poolFlows = m.sequenceFlows.filter((f) => {
168
+ const from = m.elements.find((e) => e.id === f.from);
169
+ const to = m.elements.find((e) => e.id === f.to);
170
+ if (!from || !to)
171
+ return false;
172
+ return elementPool(from) === p.id && elementPool(to) === p.id;
173
+ });
174
+ const laneOf = new Map(poolEls.map((e) => [e.id, elementLane(e)]));
175
+ poolRanks.set(p.id, rankPool(poolEls, poolFlows, laneOf));
176
+ }
177
+ // Global max rank — determines canvas width
178
+ let maxRank = 0;
179
+ for (const ranks of poolRanks.values()) {
180
+ for (const r of ranks.values())
181
+ if (r > maxRank)
182
+ maxRank = r;
183
+ }
184
+ const numColumns = maxRank + 1;
185
+ // 3. Compute each pool's height by stacking lanes
186
+ // For each lane, compute inner height as max stack size per column * elementH + gaps.
187
+ const contentStartX = L.CANVAS_LEFT_PAD + L.POOL_HEADER_W;
188
+ // Reserve extra room inside the content for the lane header (organization pool)
189
+ const canvasW = Math.max(L.MIN_CANVAS_W, contentStartX + L.LANE_HEADER_W + numColumns * L.COL_W + L.CANVAS_RIGHT_PAD);
190
+ const poolBoxes = [];
191
+ let cursorY = L.CANVAS_TOP;
192
+ for (const pool of orderedPools) {
193
+ if (pool.type === "external-empty") {
194
+ const pbox = {
195
+ poolId: pool.id,
196
+ name: pool.name,
197
+ type: pool.type,
198
+ x: L.CANVAS_LEFT_PAD,
199
+ y: cursorY,
200
+ w: canvasW - L.CANVAS_LEFT_PAD - L.CANVAS_RIGHT_PAD,
201
+ h: L.EMPTY_POOL_H,
202
+ lanes: [],
203
+ };
204
+ poolBoxes.push(pbox);
205
+ cursorY += L.EMPTY_POOL_H + L.POOL_VGAP;
206
+ continue;
207
+ }
208
+ // Build lane list; synthesize one virtual lane for pools with no declared lanes
209
+ const declaredLanes = pool.lanes;
210
+ const lanes = declaredLanes.length > 0
211
+ ? declaredLanes.map((l) => ({ id: l.id, name: l.name }))
212
+ : [{ id: "", name: pool.name }];
213
+ const laneHasExplicitHeader = declaredLanes.length > 0;
214
+ // Compute lane heights
215
+ const ranks = poolRanks.get(pool.id);
216
+ const laneHeights = lanes.map((lane) => {
217
+ let perColMaxH = 0;
218
+ for (let col = 0; col <= maxRank; col++) {
219
+ const stack = m.elements.filter((e) => isFlowElement(e)
220
+ && e.pool === pool.id
221
+ && elementLane(e) === lane.id
222
+ && ranks.get(e.id) === col);
223
+ if (stack.length === 0)
224
+ continue;
225
+ // Use TASK_H as the stack unit (tasks are tallest)
226
+ const h = stack.length * L.TASK_H + (stack.length - 1) * L.GAP_Y;
227
+ if (h > perColMaxH)
228
+ perColMaxH = h;
229
+ }
230
+ const inner = Math.max(L.LANE_MIN_H - L.LANE_CORRIDOR_H, perColMaxH + 20);
231
+ // Add corridor only if this lane actually holds data nodes (computed later);
232
+ // reserve corridor space by default for consistency.
233
+ return inner + L.LANE_CORRIDOR_H;
234
+ });
235
+ const poolH = laneHeights.reduce((a, b) => a + b, 0);
236
+ const pbox = {
237
+ poolId: pool.id,
238
+ name: pool.name,
239
+ type: pool.type,
240
+ x: L.CANVAS_LEFT_PAD,
241
+ y: cursorY,
242
+ w: canvasW - L.CANVAS_LEFT_PAD - L.CANVAS_RIGHT_PAD,
243
+ h: poolH,
244
+ lanes: [],
245
+ };
246
+ poolBoxes.push(pbox);
247
+ // Place lanes within pool
248
+ let laneY = cursorY;
249
+ for (let i = 0; i < lanes.length; i++) {
250
+ const lane = lanes[i];
251
+ const laneH = laneHeights[i];
252
+ const laneHeaderW = laneHasExplicitHeader ? L.LANE_HEADER_W : 0;
253
+ const laneBox = {
254
+ poolId: pool.id,
255
+ laneId: lane.id,
256
+ name: lane.name,
257
+ x: pbox.x + L.POOL_HEADER_W,
258
+ y: laneY,
259
+ w: pbox.w - L.POOL_HEADER_W,
260
+ h: laneH,
261
+ };
262
+ pbox.lanes.push(laneBox);
263
+ // Place elements in each column within this lane
264
+ for (let col = 0; col <= maxRank; col++) {
265
+ const stack = m.elements.filter((e) => isFlowElement(e)
266
+ && e.pool === pool.id
267
+ && elementLane(e) === lane.id
268
+ && ranks.get(e.id) === col);
269
+ if (stack.length === 0)
270
+ continue;
271
+ // Inner lane content band (above the corridor)
272
+ const innerTop = laneBox.y + 10;
273
+ const innerBottom = laneBox.y + laneBox.h - L.LANE_CORRIDOR_H - 10;
274
+ const innerMid = (innerTop + innerBottom) / 2;
275
+ const totalH = stack.length * L.TASK_H + (stack.length - 1) * L.GAP_Y;
276
+ let curY = innerMid - totalH / 2;
277
+ for (const el of stack) {
278
+ const { w, h } = elementSize(el);
279
+ const colCenterX = contentStartX + laneHeaderW + col * L.COL_W + L.TASK_W / 2;
280
+ const centerY = curY + L.TASK_H / 2;
281
+ const box = {
282
+ x: colCenterX - w / 2,
283
+ y: centerY - h / 2,
284
+ w,
285
+ h,
286
+ };
287
+ placements.set(el.id, {
288
+ el,
289
+ box,
290
+ poolId: pool.id,
291
+ laneId: lane.id,
292
+ rank: col,
293
+ });
294
+ curY += L.TASK_H + L.GAP_Y;
295
+ }
296
+ }
297
+ laneY += laneH;
298
+ }
299
+ cursorY += poolH + L.POOL_VGAP;
300
+ }
301
+ // 4. Place data nodes (dataStore / dataObject) in corridors.
302
+ // For each data node, compute the (pool, lane) that holds the most of
303
+ // its associated flow elements, and drop the cylinder at the centroid
304
+ // of those elements' X positions, in the corridor band.
305
+ for (const el of m.elements) {
306
+ if (el.kind !== "dataStore" && el.kind !== "dataObject")
307
+ continue;
308
+ const assocs = m.dataAssociations.filter((d) => d.dataId === el.id);
309
+ const targets = assocs
310
+ .map((d) => placements.get(d.elementId))
311
+ .filter((p) => !!p);
312
+ if (targets.length === 0) {
313
+ // Orphan — park at the canvas bottom-left corner
314
+ const { w, h } = elementSize(el);
315
+ placements.set(el.id, {
316
+ el,
317
+ box: { x: L.CANVAS_LEFT_PAD + 20, y: cursorY + 20, w, h },
318
+ poolId: "",
319
+ laneId: "",
320
+ rank: 0,
321
+ });
322
+ cursorY += h + 10;
323
+ continue;
324
+ }
325
+ // Pick the (pool, lane) with the most associated tasks
326
+ const counts = new Map();
327
+ for (const t of targets) {
328
+ const key = `${t.poolId}/${t.laneId}`;
329
+ const c = counts.get(key) ?? { count: 0, poolId: t.poolId, laneId: t.laneId };
330
+ c.count++;
331
+ counts.set(key, c);
332
+ }
333
+ let best = null;
334
+ for (const c of counts.values()) {
335
+ if (!best || c.count > best.count)
336
+ best = c;
337
+ }
338
+ if (!best) {
339
+ const { w, h } = elementSize(el);
340
+ placements.set(el.id, {
341
+ el,
342
+ box: { x: L.CANVAS_LEFT_PAD + 20, y: cursorY + 20, w, h },
343
+ poolId: "",
344
+ laneId: "",
345
+ rank: 0,
346
+ });
347
+ continue;
348
+ }
349
+ const bestPoolId = best.poolId;
350
+ const bestLaneId = best.laneId;
351
+ const pb = poolBoxes.find((p) => p.poolId === bestPoolId);
352
+ const lb = pb?.lanes.find((l) => l.laneId === bestLaneId);
353
+ if (!pb || !lb) {
354
+ const { w, h } = elementSize(el);
355
+ placements.set(el.id, {
356
+ el,
357
+ box: { x: L.CANVAS_LEFT_PAD + 20, y: cursorY + 20, w, h },
358
+ poolId: "",
359
+ laneId: "",
360
+ rank: 0,
361
+ });
362
+ continue;
363
+ }
364
+ const xsInLane = targets
365
+ .filter((t) => t.poolId === bestPoolId && t.laneId === bestLaneId)
366
+ .map((t) => t.box.x + t.box.w / 2);
367
+ const avgX = xsInLane.reduce((a, b) => a + b, 0) / xsInLane.length;
368
+ const { w, h } = elementSize(el);
369
+ const corridorY = lb.y + lb.h - L.LANE_CORRIDOR_H / 2 - h / 2;
370
+ placements.set(el.id, {
371
+ el,
372
+ box: { x: avgX - w / 2, y: corridorY, w, h },
373
+ poolId: bestPoolId,
374
+ laneId: bestLaneId,
375
+ rank: 0,
376
+ });
377
+ }
378
+ const canvasH = cursorY + L.CANVAS_BOTTOM_PAD;
379
+ return {
380
+ model: m,
381
+ canvas: { w: canvasW, h: canvasH },
382
+ pools: poolBoxes,
383
+ placements,
384
+ sequenceRoutes: [],
385
+ messageRoutes: [],
386
+ dataAssocRoutes: [],
387
+ };
388
+ }
389
+ // ── Pool ordering ──────────────────────────────────────────────────────
390
+ //
391
+ // Heuristic:
392
+ // - Identify which external pool initiates the process by sending a
393
+ // message to the organization pool's start event.
394
+ // - Put that pool first, then the organization pool(s), then the rest.
395
+ // - Preserve relative order within each group from the input array.
396
+ function orderPools(m) {
397
+ const initiators = new Set();
398
+ const orgStartEvents = new Set();
399
+ for (const el of m.elements) {
400
+ if (el.kind === "event" && el.eventType === "start") {
401
+ const ownerId = el.pool;
402
+ const owner = m.pools.find((p) => p.id === ownerId);
403
+ if (owner && owner.type === "organization")
404
+ orgStartEvents.add(el.id);
405
+ }
406
+ }
407
+ for (const mf of m.messageFlows) {
408
+ if (mf.toElement && orgStartEvents.has(mf.toElement)) {
409
+ initiators.add(mf.fromPool);
410
+ }
411
+ }
412
+ const primary = [];
413
+ const org = [];
414
+ const supporting = [];
415
+ for (const p of m.pools) {
416
+ if (initiators.has(p.id))
417
+ primary.push(p);
418
+ else if (p.type === "organization")
419
+ org.push(p);
420
+ else
421
+ supporting.push(p);
422
+ }
423
+ return [...primary, ...org, ...supporting];
424
+ }
@@ -0,0 +1,6 @@
1
+ import type { DataAssociation, LaidOutModel, MessageFlow, RoutedFlow, SequenceFlow } from "./types.js";
2
+ declare function routeSequenceFlow(flow: SequenceFlow, laid: LaidOutModel, siblingIndex: number, siblingCount: number): RoutedFlow | null;
3
+ declare function routeMessageFlow(mf: MessageFlow, laid: LaidOutModel, trackOffset?: number): RoutedFlow | null;
4
+ declare function routeDataAssoc(da: DataAssociation, laid: LaidOutModel): RoutedFlow | null;
5
+ export declare function routeAll(laid: LaidOutModel): LaidOutModel;
6
+ export { routeSequenceFlow, routeMessageFlow, routeDataAssoc };