@aetherwing/fcp-drawio 0.2.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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/adapter.d.ts +38 -0
  4. package/dist/adapter.js +259 -0
  5. package/dist/adapter.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/layout/elk-layout.d.ts +49 -0
  10. package/dist/layout/elk-layout.js +144 -0
  11. package/dist/layout/elk-layout.js.map +1 -0
  12. package/dist/lib/drawio-cli.d.ts +22 -0
  13. package/dist/lib/drawio-cli.js +88 -0
  14. package/dist/lib/drawio-cli.js.map +1 -0
  15. package/dist/lib/node-types.d.ts +22 -0
  16. package/dist/lib/node-types.js +174 -0
  17. package/dist/lib/node-types.js.map +1 -0
  18. package/dist/lib/stencils/aws.d.ts +2 -0
  19. package/dist/lib/stencils/aws.js +69 -0
  20. package/dist/lib/stencils/aws.js.map +1 -0
  21. package/dist/lib/stencils/azure.d.ts +2 -0
  22. package/dist/lib/stencils/azure.js +54 -0
  23. package/dist/lib/stencils/azure.js.map +1 -0
  24. package/dist/lib/stencils/cisco.d.ts +2 -0
  25. package/dist/lib/stencils/cisco.js +30 -0
  26. package/dist/lib/stencils/cisco.js.map +1 -0
  27. package/dist/lib/stencils/gcp.d.ts +2 -0
  28. package/dist/lib/stencils/gcp.js +38 -0
  29. package/dist/lib/stencils/gcp.js.map +1 -0
  30. package/dist/lib/stencils/ibm.d.ts +2 -0
  31. package/dist/lib/stencils/ibm.js +32 -0
  32. package/dist/lib/stencils/ibm.js.map +1 -0
  33. package/dist/lib/stencils/index.d.ts +10 -0
  34. package/dist/lib/stencils/index.js +33 -0
  35. package/dist/lib/stencils/index.js.map +1 -0
  36. package/dist/lib/stencils/k8s.d.ts +2 -0
  37. package/dist/lib/stencils/k8s.js +32 -0
  38. package/dist/lib/stencils/k8s.js.map +1 -0
  39. package/dist/lib/stencils/types.d.ts +14 -0
  40. package/dist/lib/stencils/types.js +2 -0
  41. package/dist/lib/stencils/types.js.map +1 -0
  42. package/dist/lib/themes.d.ts +8 -0
  43. package/dist/lib/themes.js +32 -0
  44. package/dist/lib/themes.js.map +1 -0
  45. package/dist/model/defaults.d.ts +3 -0
  46. package/dist/model/defaults.js +26 -0
  47. package/dist/model/defaults.js.map +1 -0
  48. package/dist/model/diagram-model.d.ts +110 -0
  49. package/dist/model/diagram-model.js +938 -0
  50. package/dist/model/diagram-model.js.map +1 -0
  51. package/dist/model/event-log.d.ts +30 -0
  52. package/dist/model/event-log.js +112 -0
  53. package/dist/model/event-log.js.map +1 -0
  54. package/dist/model/id.d.ts +9 -0
  55. package/dist/model/id.js +35 -0
  56. package/dist/model/id.js.map +1 -0
  57. package/dist/model/reference-registry.d.ts +33 -0
  58. package/dist/model/reference-registry.js +143 -0
  59. package/dist/model/reference-registry.js.map +1 -0
  60. package/dist/model/spatial.d.ts +20 -0
  61. package/dist/model/spatial.js +59 -0
  62. package/dist/model/spatial.js.map +1 -0
  63. package/dist/parser/parse-op.d.ts +18 -0
  64. package/dist/parser/parse-op.js +430 -0
  65. package/dist/parser/parse-op.js.map +1 -0
  66. package/dist/parser/resolve-ref.d.ts +27 -0
  67. package/dist/parser/resolve-ref.js +232 -0
  68. package/dist/parser/resolve-ref.js.map +1 -0
  69. package/dist/parser/tokenizer.d.ts +6 -0
  70. package/dist/parser/tokenizer.js +7 -0
  71. package/dist/parser/tokenizer.js.map +1 -0
  72. package/dist/serialization/connector-intelligence.d.ts +35 -0
  73. package/dist/serialization/connector-intelligence.js +336 -0
  74. package/dist/serialization/connector-intelligence.js.map +1 -0
  75. package/dist/serialization/deserialize.d.ts +6 -0
  76. package/dist/serialization/deserialize.js +511 -0
  77. package/dist/serialization/deserialize.js.map +1 -0
  78. package/dist/serialization/serialize.d.ts +15 -0
  79. package/dist/serialization/serialize.js +332 -0
  80. package/dist/serialization/serialize.js.map +1 -0
  81. package/dist/server/intent-layer.d.ts +48 -0
  82. package/dist/server/intent-layer.js +1322 -0
  83. package/dist/server/intent-layer.js.map +1 -0
  84. package/dist/server/mcp-server.d.ts +7 -0
  85. package/dist/server/mcp-server.js +26 -0
  86. package/dist/server/mcp-server.js.map +1 -0
  87. package/dist/server/model-map.d.ts +8 -0
  88. package/dist/server/model-map.js +240 -0
  89. package/dist/server/model-map.js.map +1 -0
  90. package/dist/server/query-handler.d.ts +19 -0
  91. package/dist/server/query-handler.js +148 -0
  92. package/dist/server/query-handler.js.map +1 -0
  93. package/dist/server/response-formatter.d.ts +56 -0
  94. package/dist/server/response-formatter.js +351 -0
  95. package/dist/server/response-formatter.js.map +1 -0
  96. package/dist/server/session-handler.d.ts +6 -0
  97. package/dist/server/session-handler.js +127 -0
  98. package/dist/server/session-handler.js.map +1 -0
  99. package/dist/types/index.d.ts +238 -0
  100. package/dist/types/index.js +3 -0
  101. package/dist/types/index.js.map +1 -0
  102. package/dist/verb-specs.d.ts +6 -0
  103. package/dist/verb-specs.js +144 -0
  104. package/dist/verb-specs.js.map +1 -0
  105. package/package.json +39 -0
@@ -0,0 +1,938 @@
1
+ import { EventLog } from "@aetherwing/fcp-core";
2
+ import { nextShapeId, nextEdgeId, nextGroupId, nextPageId, nextLayerId, nextSequence } from "./id.js";
3
+ import { createDefaultStyle, createDefaultEdgeStyle } from "./defaults.js";
4
+ import { ReferenceRegistry } from "./reference-registry.js";
5
+ import { NODE_TYPES, computeDefaultSize } from "../lib/node-types.js";
6
+ import { THEMES, isThemeName } from "../lib/themes.js";
7
+ import { boundsOverlap, computePushVector, isDownstream } from "./spatial.js";
8
+ const DEFAULT_GAP = 60;
9
+ const FIRST_SHAPE_POS = { x: 200, y: 200 };
10
+ export class DiagramModel {
11
+ diagram;
12
+ eventLog;
13
+ registry;
14
+ constructor() {
15
+ this.eventLog = new EventLog();
16
+ this.registry = new ReferenceRegistry();
17
+ this.diagram = this.createEmptyDiagram("Untitled");
18
+ }
19
+ // ── Diagram lifecycle ────────────────────────────────────
20
+ createNew(title) {
21
+ this.diagram = this.createEmptyDiagram(title);
22
+ this.eventLog = new EventLog();
23
+ this.rebuildRegistry();
24
+ }
25
+ createEmptyDiagram(title) {
26
+ const pageId = nextPageId();
27
+ const layerId = nextLayerId();
28
+ const page = {
29
+ id: pageId,
30
+ name: "Page-1",
31
+ shapes: new Map(),
32
+ edges: new Map(),
33
+ groups: new Map(),
34
+ layers: [{ id: layerId, name: "Default", visible: true, locked: false, order: 0 }],
35
+ defaultLayer: layerId,
36
+ };
37
+ return {
38
+ id: crypto.randomUUID(),
39
+ title,
40
+ filePath: null,
41
+ pages: [page],
42
+ activePage: pageId,
43
+ customTypes: new Map(),
44
+ customThemes: new Map(),
45
+ loadedStencilPacks: new Set(),
46
+ metadata: {
47
+ host: "fcp-drawio",
48
+ modified: new Date().toISOString(),
49
+ version: "0.2.0",
50
+ },
51
+ };
52
+ }
53
+ // ── Page access ──────────────────────────────────────────
54
+ getActivePage() {
55
+ const page = this.diagram.pages.find((p) => p.id === this.diagram.activePage);
56
+ if (!page)
57
+ throw new Error("No active page");
58
+ return page;
59
+ }
60
+ getPageByName(name) {
61
+ return this.diagram.pages.find((p) => p.name === name);
62
+ }
63
+ switchPage(name) {
64
+ const page = this.getPageByName(name);
65
+ if (!page)
66
+ return null;
67
+ this.diagram.activePage = page.id;
68
+ this.rebuildRegistry();
69
+ return page;
70
+ }
71
+ addPage(name) {
72
+ const layerId = nextLayerId();
73
+ const page = {
74
+ id: nextPageId(),
75
+ name,
76
+ shapes: new Map(),
77
+ edges: new Map(),
78
+ groups: new Map(),
79
+ layers: [{ id: layerId, name: "Default", visible: true, locked: false, order: 0 }],
80
+ defaultLayer: layerId,
81
+ };
82
+ this.diagram.pages.push(page);
83
+ this.emit({ type: "page_added", page });
84
+ this.diagram.activePage = page.id;
85
+ this.rebuildRegistry();
86
+ return page;
87
+ }
88
+ removePage(name) {
89
+ if (this.diagram.pages.length <= 1)
90
+ return false;
91
+ const idx = this.diagram.pages.findIndex((p) => p.name === name);
92
+ if (idx === -1)
93
+ return false;
94
+ const [removed] = this.diagram.pages.splice(idx, 1);
95
+ this.emit({ type: "page_removed", page: removed });
96
+ if (this.diagram.activePage === removed.id) {
97
+ this.diagram.activePage = this.diagram.pages[0].id;
98
+ this.rebuildRegistry();
99
+ }
100
+ return true;
101
+ }
102
+ // ── Shape CRUD ───────────────────────────────────────────
103
+ addShape(label, type, options = {}) {
104
+ const page = this.getActivePage();
105
+ const now = nextSequence();
106
+ // Compute size
107
+ const computedSize = options.size ?? computeDefaultSize(type, label);
108
+ // Compute position
109
+ const position = this.computePosition(page, options, computedSize);
110
+ // Build style — skip default theme if stencil provides its own colors
111
+ const effectiveTheme = options.skipDefaultTheme ? undefined : options.theme;
112
+ const style = this.buildShapeStyle(type, effectiveTheme, options.skipDefaultTheme);
113
+ const shape = {
114
+ id: nextShapeId(),
115
+ label,
116
+ type,
117
+ bounds: { x: position.x, y: position.y, width: computedSize.width, height: computedSize.height },
118
+ style,
119
+ parentGroup: options.inGroup ?? null,
120
+ layer: page.defaultLayer,
121
+ metadata: {},
122
+ baseStyleOverride: options.baseStyleOverride,
123
+ createdAt: now,
124
+ modifiedAt: now,
125
+ };
126
+ page.shapes.set(shape.id, shape);
127
+ // If placed in a group, add to group membership
128
+ if (options.inGroup) {
129
+ const group = page.groups.get(options.inGroup);
130
+ if (group) {
131
+ group.memberIds.add(shape.id);
132
+ this.recomputeGroupBounds(group, page);
133
+ }
134
+ }
135
+ this.emit({ type: "shape_created", shape });
136
+ this.rebuildRegistry();
137
+ return shape;
138
+ }
139
+ modifyShape(id, changes) {
140
+ const page = this.getActivePage();
141
+ const shape = page.shapes.get(id);
142
+ if (!shape)
143
+ return null;
144
+ const before = {};
145
+ const after = {};
146
+ for (const [key, value] of Object.entries(changes)) {
147
+ if (value !== undefined) {
148
+ before[key] = shape[key];
149
+ after[key] = value;
150
+ shape[key] = value;
151
+ }
152
+ }
153
+ shape.modifiedAt = nextSequence();
154
+ this.emit({ type: "shape_modified", id, before, after });
155
+ this.rebuildRegistry();
156
+ return shape;
157
+ }
158
+ removeShape(id) {
159
+ const page = this.getActivePage();
160
+ const shape = page.shapes.get(id);
161
+ if (!shape)
162
+ return null;
163
+ // Remove connected edges
164
+ const edgesToRemove = [];
165
+ for (const [edgeId, edge] of page.edges) {
166
+ if (edge.sourceId === id || edge.targetId === id) {
167
+ edgesToRemove.push(edgeId);
168
+ }
169
+ }
170
+ for (const edgeId of edgesToRemove) {
171
+ this.removeEdge(edgeId);
172
+ }
173
+ // Remove from group
174
+ if (shape.parentGroup) {
175
+ const group = page.groups.get(shape.parentGroup);
176
+ if (group) {
177
+ group.memberIds.delete(id);
178
+ this.recomputeGroupBounds(group, page);
179
+ }
180
+ }
181
+ page.shapes.delete(id);
182
+ this.emit({ type: "shape_deleted", shape });
183
+ this.rebuildRegistry();
184
+ return shape;
185
+ }
186
+ // ── Edge CRUD ────────────────────────────────────────────
187
+ addEdge(sourceId, targetId, options = {}) {
188
+ const page = this.getActivePage();
189
+ if (!page.shapes.has(sourceId) || !page.shapes.has(targetId))
190
+ return null;
191
+ const now = nextSequence();
192
+ const edgeStyle = { ...createDefaultEdgeStyle(), ...options.style };
193
+ const edge = {
194
+ id: nextEdgeId(),
195
+ sourceId,
196
+ targetId,
197
+ label: options.label ?? null,
198
+ style: edgeStyle,
199
+ waypoints: [],
200
+ sourceArrow: options.sourceArrow ?? "none",
201
+ targetArrow: options.targetArrow ?? "arrow",
202
+ createdAt: now,
203
+ modifiedAt: now,
204
+ };
205
+ page.edges.set(edge.id, edge);
206
+ this.emit({ type: "edge_created", edge });
207
+ this.rebuildRegistry();
208
+ return edge;
209
+ }
210
+ removeEdge(id) {
211
+ const page = this.getActivePage();
212
+ const edge = page.edges.get(id);
213
+ if (!edge)
214
+ return null;
215
+ page.edges.delete(id);
216
+ this.emit({ type: "edge_deleted", edge });
217
+ this.rebuildRegistry();
218
+ return edge;
219
+ }
220
+ modifyEdge(id, changes) {
221
+ const page = this.getActivePage();
222
+ const edge = page.edges.get(id);
223
+ if (!edge)
224
+ return null;
225
+ const before = {};
226
+ const after = {};
227
+ for (const [key, value] of Object.entries(changes)) {
228
+ if (value !== undefined) {
229
+ before[key] = edge[key];
230
+ after[key] = value;
231
+ edge[key] = value;
232
+ }
233
+ }
234
+ edge.modifiedAt = nextSequence();
235
+ this.emit({ type: "edge_modified", id, before, after });
236
+ return edge;
237
+ }
238
+ findEdge(sourceId, targetId) {
239
+ const page = this.getActivePage();
240
+ for (const edge of page.edges.values()) {
241
+ if (edge.sourceId === sourceId && edge.targetId === targetId)
242
+ return edge;
243
+ }
244
+ return undefined;
245
+ }
246
+ // ── Group operations ─────────────────────────────────────
247
+ createGroup(name, memberIds) {
248
+ const page = this.getActivePage();
249
+ // Validate all members exist
250
+ for (const id of memberIds) {
251
+ if (!page.shapes.has(id))
252
+ return null;
253
+ }
254
+ const group = {
255
+ id: nextGroupId(),
256
+ name,
257
+ memberIds: new Set(memberIds),
258
+ isContainer: true,
259
+ collapsed: false,
260
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
261
+ style: createDefaultStyle(),
262
+ };
263
+ // Set parent group on each member
264
+ for (const id of memberIds) {
265
+ const shape = page.shapes.get(id);
266
+ shape.parentGroup = group.id;
267
+ }
268
+ this.recomputeGroupBounds(group, page);
269
+ page.groups.set(group.id, group);
270
+ this.emit({ type: "group_created", group });
271
+ this.rebuildRegistry();
272
+ return group;
273
+ }
274
+ dissolveGroup(groupId) {
275
+ const page = this.getActivePage();
276
+ const group = page.groups.get(groupId);
277
+ if (!group)
278
+ return null;
279
+ // Clear parent group from members
280
+ for (const id of group.memberIds) {
281
+ const shape = page.shapes.get(id);
282
+ if (shape)
283
+ shape.parentGroup = null;
284
+ }
285
+ page.groups.delete(groupId);
286
+ this.emit({ type: "group_dissolved", group });
287
+ this.rebuildRegistry();
288
+ return group;
289
+ }
290
+ getGroupByName(name) {
291
+ const page = this.getActivePage();
292
+ for (const group of page.groups.values()) {
293
+ if (group.name === name)
294
+ return group;
295
+ }
296
+ return undefined;
297
+ }
298
+ // ── Layer CRUD ─────────────────────────────────────────
299
+ addLayer(name) {
300
+ const page = this.getActivePage();
301
+ const layer = {
302
+ id: nextLayerId(),
303
+ name,
304
+ visible: true,
305
+ locked: false,
306
+ order: page.layers.length,
307
+ };
308
+ page.layers.push(layer);
309
+ this.emit({ type: "layer_created", layer, pageId: page.id });
310
+ return layer;
311
+ }
312
+ modifyLayer(layerId, changes) {
313
+ const page = this.getActivePage();
314
+ const layer = page.layers.find((l) => l.id === layerId);
315
+ if (!layer)
316
+ return null;
317
+ const before = {};
318
+ const after = {};
319
+ for (const [key, value] of Object.entries(changes)) {
320
+ if (value !== undefined) {
321
+ before[key] = layer[key];
322
+ after[key] = value;
323
+ layer[key] = value;
324
+ }
325
+ }
326
+ this.emit({ type: "layer_modified", pageId: page.id, layerId, before, after });
327
+ return layer;
328
+ }
329
+ // ── Flow direction ─────────────────────────────────────
330
+ setFlowDirection(dir) {
331
+ const page = this.getActivePage();
332
+ const before = page.flowDirection;
333
+ page.flowDirection = dir;
334
+ this.emit({ type: "flow_direction_changed", pageId: page.id, before, after: dir });
335
+ }
336
+ // ── Title ──────────────────────────────────────────────
337
+ setTitle(title) {
338
+ const before = this.diagram.title;
339
+ this.diagram.title = title;
340
+ this.emit({ type: "title_changed", before, after: title });
341
+ }
342
+ // ── Custom types ─────────────────────────────────────────
343
+ defineCustomType(name, base, options = {}) {
344
+ const ct = {
345
+ name,
346
+ base,
347
+ theme: options.theme,
348
+ badge: options.badge,
349
+ defaultSize: options.size,
350
+ };
351
+ this.diagram.customTypes.set(name, ct);
352
+ return ct;
353
+ }
354
+ defineCustomTheme(name, fill, stroke, fontColor) {
355
+ const ct = { name, fill, stroke, fontColor };
356
+ this.diagram.customThemes.set(name, ct);
357
+ return ct;
358
+ }
359
+ // ── Checkpoints and undo ─────────────────────────────────
360
+ checkpoint(name) {
361
+ this.eventLog.checkpoint(name);
362
+ }
363
+ undo(count = 1) {
364
+ const events = this.eventLog.undo(count);
365
+ for (const event of events) {
366
+ this.reverseEvent(event);
367
+ }
368
+ this.rebuildRegistry();
369
+ return events;
370
+ }
371
+ undoTo(checkpointName) {
372
+ const events = this.eventLog.undoTo(checkpointName);
373
+ if (!events)
374
+ return null;
375
+ for (const event of events) {
376
+ this.reverseEvent(event);
377
+ }
378
+ this.rebuildRegistry();
379
+ return events;
380
+ }
381
+ redo(count = 1) {
382
+ const events = this.eventLog.redo(count);
383
+ for (const event of events) {
384
+ this.replayEvent(event);
385
+ }
386
+ this.rebuildRegistry();
387
+ return events;
388
+ }
389
+ getHistory(count) {
390
+ return this.eventLog.recent(count);
391
+ }
392
+ canUndo() {
393
+ return this.eventLog.canUndo();
394
+ }
395
+ canRedo() {
396
+ return this.eventLog.canRedo();
397
+ }
398
+ /** Compact state digest for drift detection. */
399
+ getDigest() {
400
+ const page = this.getActivePage();
401
+ const pageIdx = this.diagram.pages.findIndex(p => p.id === this.diagram.activePage) + 1;
402
+ const totalPages = this.diagram.pages.length;
403
+ const bounds = this.computeCanvasBounds();
404
+ const canvasStr = bounds ? `${Math.round(bounds.width)}x${Math.round(bounds.height)} ` : "";
405
+ return `[${page.shapes.size}s ${page.edges.size}e ${page.groups.size}g ${canvasStr}p:${pageIdx}/${totalPages}]`;
406
+ }
407
+ /** Compute the bounding box of all shapes and groups on the active page. */
408
+ computeCanvasBounds() {
409
+ const page = this.getActivePage();
410
+ if (page.shapes.size === 0)
411
+ return null;
412
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
413
+ for (const shape of page.shapes.values()) {
414
+ minX = Math.min(minX, shape.bounds.x);
415
+ minY = Math.min(minY, shape.bounds.y);
416
+ maxX = Math.max(maxX, shape.bounds.x + shape.bounds.width);
417
+ maxY = Math.max(maxY, shape.bounds.y + shape.bounds.height);
418
+ }
419
+ for (const group of page.groups.values()) {
420
+ minX = Math.min(minX, group.bounds.x);
421
+ minY = Math.min(minY, group.bounds.y);
422
+ maxX = Math.max(maxX, group.bounds.x + group.bounds.width);
423
+ maxY = Math.max(maxY, group.bounds.y + group.bounds.height);
424
+ }
425
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
426
+ }
427
+ /**
428
+ * Resolve a named canvas region to absolute coordinates.
429
+ * Regions: top-left, top-center, top-right, middle-left, center, middle-right,
430
+ * bottom-left, bottom-center, bottom-right
431
+ * Centers the entity of given size within the region.
432
+ */
433
+ resolveCanvasRegion(region, entitySize) {
434
+ const canvas = this.computeCanvasBounds();
435
+ if (!canvas) {
436
+ // No shapes yet — place in a default 800x600 canvas
437
+ return this.resolveRegionInBounds({ x: 0, y: 0, width: 800, height: 600 }, region, entitySize);
438
+ }
439
+ // Add margin around existing content
440
+ const margin = 60;
441
+ const expanded = {
442
+ x: canvas.x - margin,
443
+ y: canvas.y - margin,
444
+ width: canvas.width + margin * 2,
445
+ height: canvas.height + margin * 2,
446
+ };
447
+ return this.resolveRegionInBounds(expanded, region, entitySize);
448
+ }
449
+ resolveRegionInBounds(bounds, region, entitySize) {
450
+ const thirdW = bounds.width / 3;
451
+ const thirdH = bounds.height / 3;
452
+ let col; // 0=left, 1=center, 2=right
453
+ let row; // 0=top, 1=middle, 2=bottom
454
+ switch (region) {
455
+ case "top-left":
456
+ row = 0;
457
+ col = 0;
458
+ break;
459
+ case "top-center":
460
+ row = 0;
461
+ col = 1;
462
+ break;
463
+ case "top-right":
464
+ row = 0;
465
+ col = 2;
466
+ break;
467
+ case "middle-left":
468
+ row = 1;
469
+ col = 0;
470
+ break;
471
+ case "center":
472
+ row = 1;
473
+ col = 1;
474
+ break;
475
+ case "middle-right":
476
+ row = 1;
477
+ col = 2;
478
+ break;
479
+ case "bottom-left":
480
+ row = 2;
481
+ col = 0;
482
+ break;
483
+ case "bottom-center":
484
+ row = 2;
485
+ col = 1;
486
+ break;
487
+ case "bottom-right":
488
+ row = 2;
489
+ col = 2;
490
+ break;
491
+ default: return null;
492
+ }
493
+ // Center entity within the region cell
494
+ const cellX = bounds.x + col * thirdW;
495
+ const cellY = bounds.y + row * thirdH;
496
+ return {
497
+ x: Math.round(cellX + (thirdW - entitySize.width) / 2),
498
+ y: Math.round(cellY + (thirdH - entitySize.height) / 2),
499
+ };
500
+ }
501
+ /** Public wrapper for recomputing group bounds. */
502
+ recomputeGroupBoundsPublic(groupId) {
503
+ const page = this.getActivePage();
504
+ const group = page.groups.get(groupId);
505
+ if (group)
506
+ this.recomputeGroupBounds(group, page);
507
+ }
508
+ /**
509
+ * Detect and resolve collisions after moving an entity.
510
+ * Pushes overlapping downstream items in the flow direction.
511
+ * Returns the number of items shifted.
512
+ */
513
+ detectAndResolveCollisions(entityId, isGroup, maxDepth = 5) {
514
+ const page = this.getActivePage();
515
+ const flowDir = page.flowDirection ?? "TB";
516
+ // Get the bounds of the moved entity
517
+ let movedBounds;
518
+ if (isGroup) {
519
+ const group = page.groups.get(entityId);
520
+ if (!group)
521
+ return 0;
522
+ movedBounds = group.bounds;
523
+ }
524
+ else {
525
+ const shape = page.shapes.get(entityId);
526
+ if (!shape)
527
+ return 0;
528
+ movedBounds = shape.bounds;
529
+ }
530
+ const groupedShapeIds = new Set();
531
+ for (const group of page.groups.values()) {
532
+ for (const id of group.memberIds)
533
+ groupedShapeIds.add(id);
534
+ }
535
+ const entities = [];
536
+ // Add ungrouped shapes (excluding the moved entity)
537
+ for (const shape of page.shapes.values()) {
538
+ if (groupedShapeIds.has(shape.id))
539
+ continue;
540
+ if (!isGroup && shape.id === entityId)
541
+ continue;
542
+ entities.push({ id: shape.id, bounds: shape.bounds, isGroup: false });
543
+ }
544
+ // Add groups (excluding the moved group)
545
+ for (const group of page.groups.values()) {
546
+ if (isGroup && group.id === entityId)
547
+ continue;
548
+ entities.push({ id: group.id, bounds: group.bounds, isGroup: true });
549
+ }
550
+ // Ripple: push overlapping downstream entities
551
+ let totalShifted = 0;
552
+ const pushed = new Set(); // track already-pushed IDs
553
+ let waveBounds = [movedBounds]; // bounds that may cause ripple
554
+ for (let depth = 0; depth < maxDepth && waveBounds.length > 0; depth++) {
555
+ const nextWave = [];
556
+ for (const sourceBounds of waveBounds) {
557
+ for (const entity of entities) {
558
+ if (pushed.has(entity.id))
559
+ continue;
560
+ if (!isDownstream(sourceBounds, entity.bounds, flowDir))
561
+ continue;
562
+ if (!boundsOverlap(sourceBounds, entity.bounds))
563
+ continue;
564
+ const push = computePushVector(sourceBounds, entity.bounds, flowDir);
565
+ if (!push)
566
+ continue;
567
+ // Apply push
568
+ if (entity.isGroup) {
569
+ this.pushGroup(entity.id, push.dx, push.dy);
570
+ }
571
+ else {
572
+ this.pushShape(entity.id, push.dx, push.dy);
573
+ }
574
+ pushed.add(entity.id);
575
+ totalShifted++;
576
+ // Update entity bounds for future iterations
577
+ entity.bounds = {
578
+ ...entity.bounds,
579
+ x: entity.bounds.x + push.dx,
580
+ y: entity.bounds.y + push.dy,
581
+ };
582
+ nextWave.push(entity.bounds);
583
+ }
584
+ }
585
+ waveBounds = nextWave;
586
+ }
587
+ return totalShifted;
588
+ }
589
+ pushShape(shapeId, dx, dy) {
590
+ const page = this.getActivePage();
591
+ const shape = page.shapes.get(shapeId);
592
+ if (!shape)
593
+ return;
594
+ const before = { bounds: { ...shape.bounds } };
595
+ shape.bounds = { ...shape.bounds, x: shape.bounds.x + dx, y: shape.bounds.y + dy };
596
+ shape.modifiedAt = nextSequence();
597
+ this.emit({
598
+ type: "shape_modified",
599
+ id: shapeId,
600
+ before,
601
+ after: { bounds: { ...shape.bounds } },
602
+ });
603
+ }
604
+ pushGroup(groupId, dx, dy) {
605
+ const page = this.getActivePage();
606
+ const group = page.groups.get(groupId);
607
+ if (!group)
608
+ return;
609
+ // Move all member shapes
610
+ for (const memberId of group.memberIds) {
611
+ this.pushShape(memberId, dx, dy);
612
+ }
613
+ // Recompute group bounds
614
+ this.recomputeGroupBounds(group, page);
615
+ }
616
+ // ── Layout application ──────────────────────────────────────
617
+ /**
618
+ * Apply an ELK layout result: update shape positions, edge waypoints, and recompute group bounds.
619
+ * Emits shape_modified/edge_modified events for undo support.
620
+ */
621
+ applyLayout(result) {
622
+ const page = this.getActivePage();
623
+ let count = 0;
624
+ // Update shape positions
625
+ for (const [id, pos] of result.shapePositions) {
626
+ const shape = page.shapes.get(id);
627
+ if (shape) {
628
+ const before = { bounds: { ...shape.bounds } };
629
+ shape.bounds = { ...shape.bounds, x: pos.x, y: pos.y };
630
+ shape.modifiedAt = nextSequence();
631
+ this.emit({
632
+ type: "shape_modified",
633
+ id,
634
+ before,
635
+ after: { bounds: { ...shape.bounds } },
636
+ });
637
+ count++;
638
+ }
639
+ }
640
+ // Update edge waypoints
641
+ for (const [id, waypoints] of result.edgeWaypoints) {
642
+ const edge = page.edges.get(id);
643
+ if (edge && waypoints.length > 0) {
644
+ const before = { waypoints: [...edge.waypoints] };
645
+ edge.waypoints = waypoints;
646
+ edge.modifiedAt = nextSequence();
647
+ this.emit({
648
+ type: "edge_modified",
649
+ id,
650
+ before,
651
+ after: { waypoints: [...waypoints] },
652
+ });
653
+ }
654
+ }
655
+ // Recompute group bounds
656
+ for (const [, group] of page.groups) {
657
+ this.recomputeGroupBounds(group, page);
658
+ }
659
+ this.rebuildRegistry();
660
+ return count;
661
+ }
662
+ // ── Position computation ─────────────────────────────────
663
+ computePosition(page, options, size) {
664
+ // Absolute position
665
+ if (options.at)
666
+ return options.at;
667
+ // Relative to another shape
668
+ if (options.near) {
669
+ const ref = page.shapes.get(options.near);
670
+ if (ref) {
671
+ return this.positionRelativeTo(ref.bounds, size, options.dir ?? "below");
672
+ }
673
+ }
674
+ // Relative to most recent shape
675
+ const recent = this.registry.getMostRecent(1);
676
+ if (recent.length > 0) {
677
+ return this.positionRelativeTo(recent[0].bounds, { width: size.width, height: size.height }, "below");
678
+ }
679
+ // First shape on empty page
680
+ return FIRST_SHAPE_POS;
681
+ }
682
+ positionRelativeTo(ref, size, dir) {
683
+ const gap = DEFAULT_GAP;
684
+ const refCx = ref.x + ref.width / 2;
685
+ const refCy = ref.y + ref.height / 2;
686
+ switch (dir) {
687
+ case "below":
688
+ return { x: refCx - size.width / 2, y: ref.y + ref.height + gap };
689
+ case "above":
690
+ return { x: refCx - size.width / 2, y: ref.y - gap - size.height };
691
+ case "right":
692
+ return { x: ref.x + ref.width + gap, y: refCy - size.height / 2 };
693
+ case "left":
694
+ return { x: ref.x - gap - size.width, y: refCy - size.height / 2 };
695
+ case "below-right":
696
+ return { x: ref.x + ref.width + gap, y: ref.y + ref.height + gap };
697
+ case "below-left":
698
+ return { x: ref.x - gap - size.width, y: ref.y + ref.height + gap };
699
+ case "above-right":
700
+ return { x: ref.x + ref.width + gap, y: ref.y - gap - size.height };
701
+ case "above-left":
702
+ return { x: ref.x - gap - size.width, y: ref.y - gap - size.height };
703
+ default:
704
+ return { x: refCx - size.width / 2, y: ref.y + ref.height + gap };
705
+ }
706
+ }
707
+ // ── Style building ───────────────────────────────────────
708
+ buildShapeStyle(type, theme, skipDefaultTheme) {
709
+ const style = createDefaultStyle();
710
+ const typeDef = NODE_TYPES[type];
711
+ if (typeDef && typeDef.baseStyle.includes("rounded=1")) {
712
+ style.rounded = true;
713
+ }
714
+ // When using a stencil with no explicit theme, skip applying default "blue" theme
715
+ // so stencil's embedded colors pass through
716
+ if (skipDefaultTheme && !theme) {
717
+ return style;
718
+ }
719
+ // Apply theme colors
720
+ const themeName = theme ?? "blue";
721
+ if (isThemeName(themeName)) {
722
+ const colors = THEMES[themeName];
723
+ style.fillColor = colors.fill;
724
+ style.strokeColor = colors.stroke;
725
+ if (colors.fontColor)
726
+ style.fontColor = colors.fontColor;
727
+ }
728
+ return style;
729
+ }
730
+ // ── Group bounds ─────────────────────────────────────────
731
+ recomputeGroupBounds(group, page) {
732
+ const paddingX = 40;
733
+ const paddingBottom = 35;
734
+ const paddingTop = 50; // room for bold group label
735
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
736
+ for (const id of group.memberIds) {
737
+ const shape = page.shapes.get(id);
738
+ if (!shape)
739
+ continue;
740
+ minX = Math.min(minX, shape.bounds.x);
741
+ minY = Math.min(minY, shape.bounds.y);
742
+ maxX = Math.max(maxX, shape.bounds.x + shape.bounds.width);
743
+ maxY = Math.max(maxY, shape.bounds.y + shape.bounds.height);
744
+ }
745
+ if (minX !== Infinity) {
746
+ let width = maxX - minX + paddingX * 2;
747
+ // Ensure minimum width so group label isn't truncated (~8px per char)
748
+ const minLabelWidth = group.name.length * 9 + paddingX * 2;
749
+ width = Math.max(width, minLabelWidth);
750
+ group.bounds = {
751
+ x: minX - paddingX,
752
+ y: minY - paddingTop,
753
+ width,
754
+ height: maxY - minY + paddingTop + paddingBottom,
755
+ };
756
+ }
757
+ }
758
+ // ── Event handling ───────────────────────────────────────
759
+ emit(event) {
760
+ this.eventLog.append(event);
761
+ this.diagram.metadata.modified = new Date().toISOString();
762
+ }
763
+ reverseEvent(event) {
764
+ const page = this.getActivePage();
765
+ switch (event.type) {
766
+ case "shape_created":
767
+ page.shapes.delete(event.shape.id);
768
+ break;
769
+ case "shape_deleted":
770
+ page.shapes.set(event.shape.id, { ...event.shape });
771
+ break;
772
+ case "shape_modified": {
773
+ const shape = page.shapes.get(event.id);
774
+ if (shape)
775
+ Object.assign(shape, event.before);
776
+ break;
777
+ }
778
+ case "edge_created":
779
+ page.edges.delete(event.edge.id);
780
+ break;
781
+ case "edge_deleted":
782
+ page.edges.set(event.edge.id, { ...event.edge });
783
+ break;
784
+ case "edge_modified": {
785
+ const edge = page.edges.get(event.id);
786
+ if (edge)
787
+ Object.assign(edge, event.before);
788
+ break;
789
+ }
790
+ case "group_created":
791
+ page.groups.delete(event.group.id);
792
+ for (const id of event.group.memberIds) {
793
+ const shape = page.shapes.get(id);
794
+ if (shape)
795
+ shape.parentGroup = null;
796
+ }
797
+ break;
798
+ case "group_dissolved":
799
+ page.groups.set(event.group.id, {
800
+ ...event.group,
801
+ memberIds: new Set(event.group.memberIds),
802
+ });
803
+ for (const id of event.group.memberIds) {
804
+ const shape = page.shapes.get(id);
805
+ if (shape)
806
+ shape.parentGroup = event.group.id;
807
+ }
808
+ break;
809
+ case "page_added": {
810
+ const idx = this.diagram.pages.findIndex((p) => p.id === event.page.id);
811
+ if (idx !== -1)
812
+ this.diagram.pages.splice(idx, 1);
813
+ break;
814
+ }
815
+ case "page_removed":
816
+ this.diagram.pages.push(event.page);
817
+ break;
818
+ case "layer_created": {
819
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
820
+ if (p) {
821
+ const idx = p.layers.findIndex((l) => l.id === event.layer.id);
822
+ if (idx !== -1)
823
+ p.layers.splice(idx, 1);
824
+ }
825
+ break;
826
+ }
827
+ case "layer_modified": {
828
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
829
+ if (p) {
830
+ const layer = p.layers.find((l) => l.id === event.layerId);
831
+ if (layer)
832
+ Object.assign(layer, event.before);
833
+ }
834
+ break;
835
+ }
836
+ case "flow_direction_changed": {
837
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
838
+ if (p)
839
+ p.flowDirection = event.before;
840
+ break;
841
+ }
842
+ case "title_changed":
843
+ this.diagram.title = event.before;
844
+ break;
845
+ case "checkpoint":
846
+ // No-op for undo
847
+ break;
848
+ }
849
+ }
850
+ replayEvent(event) {
851
+ const page = this.getActivePage();
852
+ switch (event.type) {
853
+ case "shape_created":
854
+ page.shapes.set(event.shape.id, { ...event.shape });
855
+ break;
856
+ case "shape_deleted":
857
+ page.shapes.delete(event.shape.id);
858
+ break;
859
+ case "shape_modified": {
860
+ const shape = page.shapes.get(event.id);
861
+ if (shape)
862
+ Object.assign(shape, event.after);
863
+ break;
864
+ }
865
+ case "edge_created":
866
+ page.edges.set(event.edge.id, { ...event.edge });
867
+ break;
868
+ case "edge_deleted":
869
+ page.edges.delete(event.edge.id);
870
+ break;
871
+ case "edge_modified": {
872
+ const edge = page.edges.get(event.id);
873
+ if (edge)
874
+ Object.assign(edge, event.after);
875
+ break;
876
+ }
877
+ case "group_created":
878
+ page.groups.set(event.group.id, {
879
+ ...event.group,
880
+ memberIds: new Set(event.group.memberIds),
881
+ });
882
+ for (const id of event.group.memberIds) {
883
+ const shape = page.shapes.get(id);
884
+ if (shape)
885
+ shape.parentGroup = event.group.id;
886
+ }
887
+ break;
888
+ case "group_dissolved":
889
+ page.groups.delete(event.group.id);
890
+ for (const id of event.group.memberIds) {
891
+ const shape = page.shapes.get(id);
892
+ if (shape)
893
+ shape.parentGroup = null;
894
+ }
895
+ break;
896
+ case "page_added":
897
+ this.diagram.pages.push(event.page);
898
+ break;
899
+ case "page_removed": {
900
+ const idx = this.diagram.pages.findIndex((p) => p.id === event.page.id);
901
+ if (idx !== -1)
902
+ this.diagram.pages.splice(idx, 1);
903
+ break;
904
+ }
905
+ case "layer_created": {
906
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
907
+ if (p)
908
+ p.layers.push({ ...event.layer });
909
+ break;
910
+ }
911
+ case "layer_modified": {
912
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
913
+ if (p) {
914
+ const layer = p.layers.find((l) => l.id === event.layerId);
915
+ if (layer)
916
+ Object.assign(layer, event.after);
917
+ }
918
+ break;
919
+ }
920
+ case "flow_direction_changed": {
921
+ const p = this.diagram.pages.find((pg) => pg.id === event.pageId);
922
+ if (p)
923
+ p.flowDirection = event.after;
924
+ break;
925
+ }
926
+ case "title_changed":
927
+ this.diagram.title = event.after;
928
+ break;
929
+ case "checkpoint":
930
+ break;
931
+ }
932
+ }
933
+ // ── Registry rebuild ─────────────────────────────────────
934
+ rebuildRegistry() {
935
+ this.registry.rebuild(this.getActivePage());
936
+ }
937
+ }
938
+ //# sourceMappingURL=diagram-model.js.map