@oml/markdown 0.10.0 → 0.12.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 (40) hide show
  1. package/out/md/md-execution.d.ts +16 -0
  2. package/out/md/md-executor.d.ts +1 -0
  3. package/out/md/md-executor.js +219 -35
  4. package/out/md/md-executor.js.map +1 -1
  5. package/out/renderers/chart-renderer.js +72 -4
  6. package/out/renderers/chart-renderer.js.map +1 -1
  7. package/out/renderers/diagram-renderer.js +896 -245
  8. package/out/renderers/diagram-renderer.js.map +1 -1
  9. package/out/renderers/graph-renderer.js +452 -18
  10. package/out/renderers/graph-renderer.js.map +1 -1
  11. package/out/renderers/matrix-renderer.d.ts +0 -2
  12. package/out/renderers/matrix-renderer.js +45 -40
  13. package/out/renderers/matrix-renderer.js.map +1 -1
  14. package/out/renderers/renderer.d.ts +4 -1
  15. package/out/renderers/renderer.js +98 -0
  16. package/out/renderers/renderer.js.map +1 -1
  17. package/out/renderers/table-renderer.d.ts +12 -2
  18. package/out/renderers/table-renderer.js +126 -39
  19. package/out/renderers/table-renderer.js.map +1 -1
  20. package/out/renderers/types.d.ts +16 -0
  21. package/out/renderers/wikilink-utils.d.ts +1 -0
  22. package/out/renderers/wikilink-utils.js +60 -32
  23. package/out/renderers/wikilink-utils.js.map +1 -1
  24. package/out/static/browser-runtime.bundle.js +8011 -1292
  25. package/out/static/browser-runtime.bundle.js.map +4 -4
  26. package/out/static/browser-runtime.js +15 -2
  27. package/out/static/browser-runtime.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/md/md-execution.ts +20 -0
  30. package/src/md/md-executor.ts +268 -40
  31. package/src/renderers/chart-renderer.ts +93 -2
  32. package/src/renderers/diagram-renderer.ts +964 -253
  33. package/src/renderers/graph-renderer.ts +512 -12
  34. package/src/renderers/matrix-renderer.ts +57 -44
  35. package/src/renderers/renderer.ts +105 -1
  36. package/src/renderers/table-renderer.ts +190 -41
  37. package/src/renderers/types.ts +20 -0
  38. package/src/renderers/wikilink-utils.ts +66 -31
  39. package/src/static/browser-runtime.ts +20 -2
  40. package/src/static/markdown-webview.css +44 -15
@@ -11,7 +11,7 @@ const TYPE_IRIS = {
11
11
  };
12
12
  const LIST_ITEM_IRI = `${D}ListItem`;
13
13
  const CSS_EDITOR_FOREGROUND = 'var(--vscode-editor-foreground, var(--oml-static-foreground, #24292f))';
14
- const CSS_EDITOR_BACKGROUND = 'var(--vscode-editor-background, transparent)';
14
+ const CSS_EDITOR_BACKGROUND = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
15
15
  const CSS_CANVAS_BACKGROUND = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
16
16
  const CSS_FOCUS_BORDER = 'var(--vscode-focusBorder, var(--oml-static-link, #0969da))';
17
17
  let diagramCanvasSeq = 0;
@@ -55,12 +55,15 @@ export class DiagramMarkdownBlockRenderer extends CanvasMarkdownBlockRenderer {
55
55
  const tripleIndex = indexTriples(rows);
56
56
  const stylesheet = parseDiagramStylesheet(result.options);
57
57
  const compiled = compileDiagramGraph(tripleIndex, stylesheet);
58
- const layoutOptions = resolveDagreLayoutOptions(result.options);
58
+ const diagramStyle = resolveElementStyle('diagram', 'diagram', [], {}, stylesheet);
59
+ const layoutOptions = resolveDagreLayoutOptions(diagramStyle);
60
+ const rootSpacing = resolveRootSpacing(diagramStyle);
59
61
  if (compiled.nodes.length === 0) {
60
62
  container.appendChild(this.createMessageContainer('No diagram nodes were inferred from the diagram namespace triples.'));
61
63
  return container;
62
64
  }
63
65
  void renderWithX6(canvas, baseId, compiled, layoutOptions, {
66
+ rootSpacing,
64
67
  downloadSvg: (content) => this.requestTextFileDownload(content, 'diagram', 'svg'),
65
68
  }).catch((error) => {
66
69
  const detail = error instanceof Error ? error.message : String(error);
@@ -76,7 +79,7 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
76
79
  return;
77
80
  }
78
81
  const GraphCtor = await loadX6GraphCtor();
79
- const layout = await layoutGraphDagre(graph, layoutOptions);
82
+ const layout = await layoutGraphDagre(graph, layoutOptions, actions.rootSpacing);
80
83
  const minHeight = asFiniteNumber(parseCssPixels(liveCanvas.style.getPropertyValue('--oml-diagram-min-height')), numericCanvasMinHeight(undefined));
81
84
  const desiredHeight = Math.max(minHeight, Math.ceil(layout.contentHeight + 12));
82
85
  setLockedCanvasHeight(liveCanvas, desiredHeight, minHeight);
@@ -84,30 +87,23 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
84
87
  let isResizingCanvas = false;
85
88
  const isPortPointerTarget = (event) => {
86
89
  const target = event?.target;
87
- if (target?.closest('.x6-port, .x6-port-body, .oml-port-body')) {
90
+ if (target?.closest('.x6-port, .x6-port-body, .oml-port-body, [port]')) {
88
91
  return true;
89
92
  }
90
93
  const path = typeof event?.composedPath === 'function' ? event.composedPath() : [];
91
94
  return path.some((entry) => (entry instanceof Element
92
95
  && (entry.classList.contains('x6-port')
93
96
  || entry.classList.contains('x6-port-body')
94
- || entry.classList.contains('oml-port-body'))));
95
- };
96
- const findPortIdFromTarget = (target) => {
97
- if (!(target instanceof Element))
98
- return undefined;
99
- const portHost = target.closest('.x6-port');
100
- if (!portHost)
101
- return undefined;
102
- return portHost.getAttribute('port')
103
- ?? portHost.getAttribute('data-port-id')
104
- ?? undefined;
97
+ || entry.classList.contains('oml-port-body')
98
+ || entry.hasAttribute('port'))));
105
99
  };
106
100
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
107
101
  const isCompartmentNode = (node) => {
108
102
  const nodeId = String(node?.id ?? '');
109
103
  return nodeById.get(nodeId)?.kind === 'Compartment';
110
104
  };
105
+ const boundPortContainers = new WeakSet();
106
+ let nodeTransform;
111
107
  const graphView = new GraphCtor({
112
108
  container: liveCanvas,
113
109
  autoResize: false,
@@ -118,10 +114,14 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
118
114
  minScale: 0.4,
119
115
  maxScale: 2.5,
120
116
  factor: 1.1,
117
+ modifiers: ['meta', 'ctrl'],
121
118
  },
122
119
  connecting: {
123
120
  router: 'normal',
124
- connector: 'rounded',
121
+ connector: {
122
+ name: 'jumpover',
123
+ args: { size: 5 },
124
+ },
125
125
  allowBlank: false,
126
126
  allowNode: false,
127
127
  allowPort: false,
@@ -137,13 +137,131 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
137
137
  edgeMovable: false,
138
138
  vertexMovable: false,
139
139
  arrowheadMovable: false,
140
- labelMovable: false,
140
+ labelMovable: true,
141
141
  };
142
142
  },
143
143
  background: {
144
144
  color: CSS_CANVAS_BACKGROUND,
145
145
  },
146
+ onPortRendered: ({ port, node, container }) => {
147
+ if (boundPortContainers.has(container)) {
148
+ return;
149
+ }
150
+ boundPortContainers.add(container);
151
+ const portIri = String(port.id ?? '').trim();
152
+ const applyPortNativeTitle = () => {
153
+ if (!portIri) {
154
+ return;
155
+ }
156
+ container.setAttribute('title', portIri);
157
+ for (const element of Array.from(container.querySelectorAll('*'))) {
158
+ element.setAttribute('title', portIri);
159
+ if (element instanceof SVGElement) {
160
+ let titleNode = null;
161
+ for (const child of Array.from(element.children)) {
162
+ if (child instanceof SVGTitleElement) {
163
+ titleNode = child;
164
+ break;
165
+ }
166
+ }
167
+ if (!titleNode) {
168
+ titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
169
+ element.insertBefore(titleNode, element.firstChild);
170
+ }
171
+ titleNode.textContent = portIri;
172
+ }
173
+ }
174
+ };
175
+ container.setAttribute('data-port-id', String(port.id));
176
+ applyPortNativeTitle();
177
+ container.style.cursor = 'grab';
178
+ container.style.touchAction = 'none';
179
+ container.addEventListener('mouseenter', (event) => {
180
+ const iri = portIri;
181
+ if (!iri) {
182
+ return;
183
+ }
184
+ applyPortNativeTitle();
185
+ const rect = container.getBoundingClientRect();
186
+ liveCanvas.dispatchEvent(new CustomEvent('md-show-iri-hover', {
187
+ bubbles: true,
188
+ detail: {
189
+ iri,
190
+ previewEnabled: /^Mac/i.test(navigator.platform) ? event.metaKey : event.ctrlKey,
191
+ anchorRect: {
192
+ left: rect.left,
193
+ right: rect.right,
194
+ top: rect.top,
195
+ bottom: rect.bottom,
196
+ width: rect.width,
197
+ height: rect.height,
198
+ },
199
+ },
200
+ }));
201
+ });
202
+ container.addEventListener('mouseleave', () => {
203
+ liveCanvas.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
204
+ });
205
+ container.addEventListener('dblclick', (event) => {
206
+ const iri = String(port.id ?? '').trim();
207
+ if (!iri) {
208
+ return;
209
+ }
210
+ event.preventDefault();
211
+ event.stopPropagation();
212
+ if (typeof event.stopImmediatePropagation === 'function') {
213
+ event.stopImmediatePropagation();
214
+ }
215
+ liveCanvas.dispatchEvent(new CustomEvent('md-navigate-iri', {
216
+ bubbles: true,
217
+ detail: { iri },
218
+ }));
219
+ });
220
+ container.addEventListener('pointerdown', (event) => {
221
+ if (typeof graphView.cleanSelection === 'function') {
222
+ graphView.cleanSelection();
223
+ }
224
+ nodeTransform.clearWidgets();
225
+ const owner = graphView.getCellById(String(node.id));
226
+ if (!owner) {
227
+ return;
228
+ }
229
+ startPortDrag(event.clientX, event.clientY, owner, String(port.id), event.pointerId, container);
230
+ event.preventDefault();
231
+ event.stopPropagation();
232
+ if (typeof event.stopImmediatePropagation === 'function') {
233
+ event.stopImmediatePropagation();
234
+ }
235
+ if (typeof container.setPointerCapture === 'function') {
236
+ try {
237
+ container.setPointerCapture(event.pointerId);
238
+ }
239
+ catch {
240
+ // Ignore capture failures; window listeners still handle the drag.
241
+ }
242
+ }
243
+ });
244
+ },
245
+ });
246
+ const x6Mod = await import('@antv/x6');
247
+ const TransformCtor = x6Mod.Transform;
248
+ if (typeof TransformCtor !== 'function') {
249
+ throw new Error('X6 Transform plugin is unavailable in @antv/x6');
250
+ }
251
+ nodeTransform = new TransformCtor({
252
+ resizing: {
253
+ enabled: (node) => node?.getData?.()?.kind === 'Node',
254
+ minWidth: 48,
255
+ minHeight: 32,
256
+ orthogonal: true,
257
+ restrict: false,
258
+ autoScroll: false,
259
+ preserveAspectRatio: false,
260
+ allowReverse: false,
261
+ },
262
+ rotating: false,
146
263
  });
264
+ graphView.use(nodeTransform);
147
265
  const toPlainRect = (value) => {
148
266
  if (!value)
149
267
  return undefined;
@@ -178,12 +296,7 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
178
296
  for (const list of portsByOwner.values()) {
179
297
  list.sort((a, b) => a.id.localeCompare(b.id));
180
298
  }
181
- const ownerByPortId = new Map();
182
- for (const [ownerId, ports] of portsByOwner.entries()) {
183
- for (const port of ports) {
184
- ownerByPortId.set(port.id, ownerId);
185
- }
186
- }
299
+ const portPlacementById = computeDefaultPortPlacements(graph, nodeById, portsByOwner, layout.boxes);
187
300
  const ordered = [...graph.nodes]
188
301
  .filter((node) => node.kind !== 'Port')
189
302
  .sort((a, b) => nodeDepth(a.id, nodeById) - nodeDepth(b.id, nodeById));
@@ -194,19 +307,35 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
194
307
  const labelText = node.labels.length > 0 ? node.labels.join('\n') : localName(node.id);
195
308
  const resolvedStyle = node.style;
196
309
  const resolvedShape = resolveRenderNodeShape(resolvedStyle);
310
+ const { bodyAttrs, labelAttrs, iconSvgAttrs, iconPathAttrs, imageAttrs } = resolveNodeAttrs(resolvedStyle);
197
311
  const x = box.x;
198
312
  const y = box.y;
199
313
  const ownerPorts = portsByOwner.get(node.id) ?? [];
200
- const portItems = ownerPorts.map((port, index) => ({
201
- id: port.id,
202
- group: 'boundary',
203
- args: {
204
- x: box.width,
205
- y: ((index + 1) * box.height) / (ownerPorts.length + 1),
206
- },
207
- attrs: resolvePortAttrs(port.style, port.classes, port.labels[0] ?? 'Port'),
208
- }));
209
- const { bodyAttrs, labelAttrs, iconSvgAttrs, iconPathAttrs, imageAttrs } = resolveNodeAttrs(resolvedStyle);
314
+ const portItems = ownerPorts.map((port) => {
315
+ const placement = portPlacementById.get(port.id) ?? { side: 'right', ratio: 0.5 };
316
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
317
+ const position = placement.side === 'left' || placement.side === 'right'
318
+ ? {
319
+ x: placement.side === 'left' ? 0 : box.width,
320
+ y: ratio * box.height,
321
+ }
322
+ : {
323
+ x: ratio * box.width,
324
+ y: placement.side === 'top' ? 0 : box.height,
325
+ };
326
+ const portText = port.labels[0];
327
+ return {
328
+ id: port.id,
329
+ group: 'boundary',
330
+ args: {
331
+ x: position.x,
332
+ y: position.y,
333
+ side: placement.side,
334
+ ratio,
335
+ },
336
+ attrs: resolvePortAttrs(port.style, port.classes, portText, placement.side, typeof bodyAttrs.stroke === 'string' ? bodyAttrs.stroke : undefined),
337
+ };
338
+ });
210
339
  const iconPathSelectors = iconPathAttrs.map((_, index) => `iconPath${index}`);
211
340
  graphView.addNode({
212
341
  id: node.id,
@@ -310,7 +439,7 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
310
439
  },
311
440
  items: portItems,
312
441
  } : undefined,
313
- zIndex: 10,
442
+ zIndex: 50,
314
443
  data: {
315
444
  kind: node.kind,
316
445
  ownerId: node.parentId,
@@ -457,11 +586,32 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
457
586
  let maxBottom = Number.NEGATIVE_INFINITY;
458
587
  const childCells = [];
459
588
  const childDebug = [];
589
+ const childGeometryBounds = (child) => {
590
+ if (!child || typeof child.size !== 'function') {
591
+ return undefined;
592
+ }
593
+ const size = child.size();
594
+ const width = Number(size?.width);
595
+ const height = Number(size?.height);
596
+ let position;
597
+ if (typeof child.getPosition === 'function') {
598
+ position = child.getPosition();
599
+ }
600
+ else {
601
+ position = child.position;
602
+ }
603
+ const x = Number(position?.x);
604
+ const y = Number(position?.y);
605
+ if (![x, y, width, height].every(Number.isFinite)) {
606
+ return undefined;
607
+ }
608
+ return { x, y, width, height };
609
+ };
460
610
  for (const childId of childIds) {
461
611
  const child = graphView.getCellById(childId);
462
- if (!child || typeof child.getBBox !== 'function')
612
+ const absBBox = childGeometryBounds(child);
613
+ if (!absBBox)
463
614
  continue;
464
- const absBBox = child.getBBox();
465
615
  const relLeft = absBBox.x - containerBBox.x;
466
616
  const relTop = absBBox.y - containerBBox.y;
467
617
  minLeft = Math.min(minLeft, relLeft);
@@ -493,10 +643,11 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
493
643
  // container origin (without moving its embedded children, which keep their
494
644
  // absolute positions) and expand the size to compensate so the opposite edge
495
645
  // is preserved.
496
- // topPadding is the reserved header/label area; children must stay below it.
497
- const topPadding = containerSpec.contentTopPadding;
498
- const shiftX = minLeft < 0 ? minLeft : 0;
499
- const shiftY = minTop < topPadding ? minTop - topPadding : 0;
646
+ const spacing = resolveBoxSpacing(containerSpec.style, 0);
647
+ const minInsetX = spacing.marginLeft + spacing.paddingLeft;
648
+ const minInsetY = spacing.marginTop + spacing.paddingTop;
649
+ const shiftX = minLeft < minInsetX ? minLeft - minInsetX : 0;
650
+ const shiftY = minTop < minInsetY ? minTop - minInsetY : 0;
500
651
  // Dimensions needed relative to the (possibly shifted) new origin.
501
652
  const baseWidth = containerSize.width - shiftX;
502
653
  const baseHeight = containerSize.height - shiftY;
@@ -509,7 +660,8 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
509
660
  childIds,
510
661
  containerBBox: toPlainRect(containerBBox),
511
662
  containerSize: toPlainRect({ x: 0, y: 0, ...containerSize }),
512
- topPadding,
663
+ minInsetX,
664
+ minInsetY,
513
665
  bounds: { minLeft, minTop, maxRight, maxBottom },
514
666
  shift: { shiftX, shiftY },
515
667
  childDebug,
@@ -526,6 +678,7 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
526
678
  to: { width: neededWidth, height: neededHeight },
527
679
  });
528
680
  container.resize(neededWidth, neededHeight);
681
+ syncOwnedPortPositions(containerId);
529
682
  }
530
683
  else {
531
684
  logResize('container-no-resize', { containerId });
@@ -610,6 +763,48 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
610
763
  return;
611
764
  growAncestorContainers(node);
612
765
  });
766
+ const syncOwnedPortPositions = (ownerId) => {
767
+ const owner = graphView.getCellById(ownerId);
768
+ if (!owner || typeof owner.size !== 'function' || typeof owner.getPorts !== 'function' || typeof owner.setPortProp !== 'function') {
769
+ return;
770
+ }
771
+ const size = owner.size();
772
+ const portIds = owner.getPorts()
773
+ .map((port) => String(port?.id ?? ''))
774
+ .filter((portId) => portId.length > 0);
775
+ for (const portId of portIds) {
776
+ const placement = portPlacementById.get(portId) ?? { side: 'right', ratio: 0.5 };
777
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
778
+ const nextArgs = placement.side === 'left' || placement.side === 'right'
779
+ ? {
780
+ x: placement.side === 'left' ? 0 : size.width,
781
+ y: ratio * size.height,
782
+ side: placement.side,
783
+ ratio,
784
+ }
785
+ : {
786
+ x: ratio * size.width,
787
+ y: placement.side === 'top' ? 0 : size.height,
788
+ side: placement.side,
789
+ ratio,
790
+ };
791
+ owner.setPortProp(portId, {
792
+ args: nextArgs,
793
+ label: {
794
+ position: resolveBorderPortLabelPosition(placement.side),
795
+ },
796
+ });
797
+ }
798
+ };
799
+ graphView.on('node:resizing', ({ node }) => {
800
+ syncOwnedPortPositions(String(node?.id ?? ''));
801
+ });
802
+ graphView.on('node:resized', ({ node, options }) => {
803
+ if (options?.silent)
804
+ return;
805
+ syncOwnedPortPositions(String(node?.id ?? ''));
806
+ growAncestorContainers(node);
807
+ });
613
808
  const edgeLabelPosition = (placement) => {
614
809
  if (placement === 'begin')
615
810
  return 0.15;
@@ -617,105 +812,104 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
617
812
  return 0.85;
618
813
  return 0.5;
619
814
  };
620
- const endpointOwnerId = (nodeId) => {
621
- const node = nodeById.get(nodeId);
622
- if (!node)
623
- return undefined;
624
- return node.kind === 'Port' ? node.parentId : node.id;
625
- };
626
- const undirectedPairKey = (a, b) => (a < b ? `${a}<->${b}` : `${b}<->${a}`);
627
- const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
815
+ const directedPairKey = (sourceId, targetId) => `${sourceId}=>${targetId}`;
816
+ const undirectedPairKey = (leftId, rightId) => (leftId < rightId ? `${leftId}<=>${rightId}` : `${rightId}<=>${leftId}`);
817
+ const edgeIdsByPair = new Map();
628
818
  const undirectedEdgeIdsByPair = new Map();
629
819
  for (const edge of graph.edges) {
630
- const sourceOwner = endpointOwnerId(edge.sourceId);
631
- const targetOwner = endpointOwnerId(edge.targetId);
632
- if (!sourceOwner || !targetOwner || sourceOwner === targetOwner)
820
+ if (!edge.sourceId || !edge.targetId)
633
821
  continue;
634
- const key = undirectedPairKey(sourceOwner, targetOwner);
635
- const ids = undirectedEdgeIdsByPair.get(key) ?? [];
822
+ const key = directedPairKey(edge.sourceId, edge.targetId);
823
+ const ids = edgeIdsByPair.get(key) ?? [];
636
824
  ids.push(edge.id);
637
- undirectedEdgeIdsByPair.set(key, ids);
638
- }
639
- const fanningVertexForEdge = (edge) => {
640
- const sourceOwner = endpointOwnerId(edge.sourceId);
641
- const targetOwner = endpointOwnerId(edge.targetId);
642
- if (!sourceOwner || !targetOwner || sourceOwner === targetOwner) {
643
- return undefined;
644
- }
645
- const edgeIds = undirectedEdgeIdsByPair.get(undirectedPairKey(sourceOwner, targetOwner)) ?? [];
646
- if (edgeIds.length <= 1) {
825
+ edgeIdsByPair.set(key, ids);
826
+ const undirectedKey = undirectedPairKey(edge.sourceId, edge.targetId);
827
+ const pairIds = undirectedEdgeIdsByPair.get(undirectedKey) ?? [];
828
+ pairIds.push(edge.id);
829
+ undirectedEdgeIdsByPair.set(undirectedKey, pairIds);
830
+ }
831
+ const edgePointForEndpoint = (endpointId) => {
832
+ const endpoint = nodeById.get(endpointId);
833
+ if (!endpoint) {
647
834
  return undefined;
648
835
  }
649
- const orderedEdgeIds = [...edgeIds].sort((leftId, rightId) => {
650
- const leftEdge = edgeById.get(leftId);
651
- const rightEdge = edgeById.get(rightId);
652
- if (!leftEdge || !rightEdge)
653
- return leftId.localeCompare(rightId);
654
- const leftSource = endpointOwnerId(leftEdge.sourceId);
655
- const leftTarget = endpointOwnerId(leftEdge.targetId);
656
- const rightSource = endpointOwnerId(rightEdge.sourceId);
657
- const rightTarget = endpointOwnerId(rightEdge.targetId);
658
- const leftForward = leftSource && leftTarget ? (leftSource < leftTarget ? 0 : 1) : 0;
659
- const rightForward = rightSource && rightTarget ? (rightSource < rightTarget ? 0 : 1) : 0;
660
- if (leftForward !== rightForward)
661
- return leftForward - rightForward;
662
- return leftId.localeCompare(rightId);
663
- });
664
- const [pairA, pairB] = sourceOwner < targetOwner
665
- ? [sourceOwner, targetOwner]
666
- : [targetOwner, sourceOwner];
667
- const forwardIds = [];
668
- const reverseIds = [];
669
- for (const edgeId of orderedEdgeIds) {
670
- const pairEdge = edgeById.get(edgeId);
671
- if (!pairEdge)
672
- continue;
673
- const pairSource = endpointOwnerId(pairEdge.sourceId);
674
- const pairTarget = endpointOwnerId(pairEdge.targetId);
675
- if (pairSource === pairA && pairTarget === pairB) {
676
- forwardIds.push(edgeId);
836
+ if (endpoint.kind === 'Port' && endpoint.parentId) {
837
+ const ownerBox = layout.boxes.get(endpoint.parentId);
838
+ const placement = portPlacementById.get(endpoint.id);
839
+ if (!ownerBox || !placement) {
840
+ return undefined;
841
+ }
842
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
843
+ if (placement.side === 'left') {
844
+ return { x: ownerBox.x, y: ownerBox.y + (ownerBox.height * ratio) };
845
+ }
846
+ if (placement.side === 'right') {
847
+ return { x: ownerBox.x + ownerBox.width, y: ownerBox.y + (ownerBox.height * ratio) };
677
848
  }
678
- else if (pairSource === pairB && pairTarget === pairA) {
679
- reverseIds.push(edgeId);
849
+ if (placement.side === 'top') {
850
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y };
680
851
  }
852
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y + ownerBox.height };
681
853
  }
682
- // Only fan when both directions exist for the same endpoint pair.
683
- if (forwardIds.length === 0 || reverseIds.length === 0) {
854
+ const box = layout.boxes.get(endpointId);
855
+ if (!box) {
684
856
  return undefined;
685
857
  }
686
- let directionSign = 0;
687
- let laneIndex = 0;
688
- let laneCount = 0;
689
- if (sourceOwner === pairA && targetOwner === pairB) {
690
- directionSign = 1;
691
- laneIndex = forwardIds.indexOf(edge.id);
692
- laneCount = forwardIds.length;
693
- }
694
- else if (sourceOwner === pairB && targetOwner === pairA) {
695
- directionSign = -1;
696
- laneIndex = reverseIds.indexOf(edge.id);
697
- laneCount = reverseIds.length;
858
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
859
+ };
860
+ const fanningVertexForEdge = (edge) => {
861
+ if (!edge.sourceId || !edge.targetId) {
862
+ return undefined;
698
863
  }
699
- if (directionSign === 0 || laneIndex < 0 || laneCount <= 0) {
864
+ const undirectedIds = undirectedEdgeIdsByPair.get(undirectedPairKey(edge.sourceId, edge.targetId)) ?? [];
865
+ if (undirectedIds.length <= 1) {
700
866
  return undefined;
701
867
  }
702
- const pairABox = layout.boxes.get(pairA);
703
- const pairBBox = layout.boxes.get(pairB);
704
- if (!pairABox || !pairBBox) {
868
+ const sourcePoint = edgePointForEndpoint(edge.sourceId);
869
+ const targetPoint = edgePointForEndpoint(edge.targetId);
870
+ if (!sourcePoint || !targetPoint) {
705
871
  return undefined;
706
872
  }
707
- const sx = pairABox.x + (pairABox.width / 2);
708
- const sy = pairABox.y + (pairABox.height / 2);
709
- const tx = pairBBox.x + (pairBBox.width / 2);
710
- const ty = pairBBox.y + (pairBBox.height / 2);
873
+ const sx = sourcePoint.x;
874
+ const sy = sourcePoint.y;
875
+ const tx = targetPoint.x;
876
+ const ty = targetPoint.y;
711
877
  const dx = tx - sx;
712
878
  const dy = ty - sy;
713
879
  const len = Math.hypot(dx, dy);
714
880
  if (!Number.isFinite(len) || len < 1) {
715
881
  return undefined;
716
882
  }
717
- const laneOffset = (laneIndex - ((laneCount - 1) / 2));
718
- const offset = (directionSign * 16) + (laneOffset * 10);
883
+ const forwardIds = (edgeIdsByPair.get(directedPairKey(edge.sourceId, edge.targetId)) ?? [])
884
+ .slice()
885
+ .sort((left, right) => left.localeCompare(right));
886
+ const reverseIds = (edgeIdsByPair.get(directedPairKey(edge.targetId, edge.sourceId)) ?? [])
887
+ .slice()
888
+ .sort((left, right) => left.localeCompare(right));
889
+ let offset = 0;
890
+ if (forwardIds.length > 0 && reverseIds.length > 0) {
891
+ const forwardIndex = forwardIds.indexOf(edge.id);
892
+ const reverseIndex = reverseIds.indexOf(edge.id);
893
+ if (forwardIndex >= 0) {
894
+ const laneOffset = forwardIndex - ((forwardIds.length - 1) / 2);
895
+ offset = 16 + (laneOffset * 10);
896
+ }
897
+ else if (reverseIndex >= 0) {
898
+ const laneOffset = reverseIndex - ((reverseIds.length - 1) / 2);
899
+ offset = -16 + (laneOffset * 10);
900
+ }
901
+ else {
902
+ return undefined;
903
+ }
904
+ }
905
+ else {
906
+ const laneIndex = forwardIds.indexOf(edge.id);
907
+ if (laneIndex < 0 || forwardIds.length <= 1) {
908
+ return undefined;
909
+ }
910
+ const laneOffset = laneIndex - ((forwardIds.length - 1) / 2);
911
+ offset = laneOffset * 16;
912
+ }
719
913
  if (Math.abs(offset) < 0.01) {
720
914
  return undefined;
721
915
  }
@@ -750,8 +944,8 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
750
944
  id: edge.id,
751
945
  source: resolveEndpoint(edge.sourceId),
752
946
  target: resolveEndpoint(edge.targetId),
753
- router: { name: 'normal' },
754
- connector: { name: 'rounded' },
947
+ router: resolveEdgeRouter(resolvedStyle),
948
+ connector: resolveEdgeConnector(resolvedStyle),
755
949
  attrs: {
756
950
  line: lineAttrs,
757
951
  },
@@ -768,7 +962,7 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
768
962
  labelBody: resolveEdgeLabelBodyAttrs(resolvedStyle, label.placement),
769
963
  },
770
964
  })),
771
- zIndex: 5,
965
+ zIndex: 50,
772
966
  });
773
967
  }
774
968
  // Compartments are structural containers: do not drag them; select parent instead.
@@ -806,56 +1000,120 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
806
1000
  e.preventDefault();
807
1001
  e.stopPropagation();
808
1002
  });
809
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
1003
+ const sidePriority = (side) => {
1004
+ switch (side) {
1005
+ case 'left':
1006
+ return 0;
1007
+ case 'top':
1008
+ return 1;
1009
+ case 'right':
1010
+ return 2;
1011
+ case 'bottom':
1012
+ return 3;
1013
+ }
1014
+ };
1015
+ const projectPortPlacement = (node, clientX, clientY, preferredSide) => {
1016
+ if (!node || typeof node.size !== 'function')
1017
+ return undefined;
1018
+ const size = node.size();
1019
+ const position = typeof node.getPosition === 'function'
1020
+ ? node.getPosition()
1021
+ : { x: Number(node?.position?.x ?? 0), y: Number(node?.position?.y ?? 0) };
1022
+ const localPoint = typeof graphView.clientToGraph === 'function'
1023
+ ? graphView.clientToGraph(clientX, clientY)
1024
+ : { x: clientX, y: clientY };
1025
+ const localX = clamp(localPoint.x - position.x, 0, size.width);
1026
+ const localY = clamp(localPoint.y - position.y, 0, size.height);
1027
+ const candidates = [
1028
+ { side: 'left', distance: localX },
1029
+ { side: 'right', distance: size.width - localX },
1030
+ { side: 'top', distance: localY },
1031
+ { side: 'bottom', distance: size.height - localY },
1032
+ ];
1033
+ candidates.sort((left, right) => left.distance - right.distance || sidePriority(left.side) - sidePriority(right.side));
1034
+ const closest = candidates[0];
1035
+ const preferredCandidate = preferredSide
1036
+ ? candidates.find((candidate) => candidate.side === preferredSide)
1037
+ : undefined;
1038
+ const hysteresis = 8;
1039
+ const side = preferredCandidate && preferredSide && (preferredCandidate.distance - closest.distance) <= hysteresis
1040
+ ? preferredSide
1041
+ : closest.side;
1042
+ const ratio = side === 'left' || side === 'right'
1043
+ ? (size.height > 0 ? localY / size.height : 0.5)
1044
+ : (size.width > 0 ? localX / size.width : 0.5);
1045
+ return {
1046
+ side,
1047
+ ratio: Math.max(0.05, Math.min(0.95, ratio)),
1048
+ };
1049
+ };
810
1050
  const onPointerMove = (event) => {
811
- if (!activePortDrag)
1051
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId)
812
1052
  return;
813
1053
  const node = graphView.getCellById(activePortDrag.nodeId);
814
1054
  if (!node || typeof node.size !== 'function' || typeof node.getPort !== 'function' || typeof node.setPortProp !== 'function')
815
1055
  return;
1056
+ const placement = projectPortPlacement(node, event.clientX, event.clientY, activePortDrag.side);
1057
+ if (!placement)
1058
+ return;
1059
+ if (placement.side === activePortDrag.side && Math.abs(placement.ratio - activePortDrag.ratio) < 0.005) {
1060
+ return;
1061
+ }
816
1062
  const size = node.size();
817
- const deltaY = event.clientY - activePortDrag.startClientY;
818
- const nextY = clamp(activePortDrag.startPortY + deltaY, 4, Math.max(4, size.height - 4));
1063
+ const nextX = placement.side === 'left'
1064
+ ? 0
1065
+ : placement.side === 'right'
1066
+ ? size.width
1067
+ : placement.ratio * size.width;
1068
+ const nextY = placement.side === 'top'
1069
+ ? 0
1070
+ : placement.side === 'bottom'
1071
+ ? size.height
1072
+ : placement.ratio * size.height;
1073
+ node.setPortProp(activePortDrag.portId, 'args/x', nextX);
819
1074
  node.setPortProp(activePortDrag.portId, 'args/y', nextY);
1075
+ node.setPortProp(activePortDrag.portId, 'args/side', placement.side);
1076
+ node.setPortProp(activePortDrag.portId, 'args/ratio', placement.ratio);
1077
+ if (placement.side !== activePortDrag.side) {
1078
+ node.setPortProp(activePortDrag.portId, 'label/position', resolveBorderPortLabelPosition(placement.side));
1079
+ }
1080
+ activePortDrag.side = placement.side;
1081
+ activePortDrag.ratio = placement.ratio;
1082
+ portPlacementById.set(activePortDrag.portId, placement);
820
1083
  };
821
- const onPointerUp = () => {
1084
+ const onPointerUp = (event) => {
1085
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId)
1086
+ return;
1087
+ if (typeof activePortDrag.container.releasePointerCapture === 'function') {
1088
+ try {
1089
+ activePortDrag.container.releasePointerCapture(activePortDrag.pointerId);
1090
+ }
1091
+ catch {
1092
+ // Ignore capture release failures.
1093
+ }
1094
+ }
822
1095
  activePortDrag = undefined;
823
1096
  };
824
1097
  window.addEventListener('pointermove', onPointerMove);
825
1098
  window.addEventListener('pointerup', onPointerUp);
826
- const startPortDrag = (clientY, node, portId) => {
1099
+ const startPortDrag = (clientX, clientY, node, portId, pointerId, container) => {
827
1100
  if (!node || typeof node.getPort !== 'function')
828
1101
  return;
829
- const existing = node.getPort(portId);
830
- const startPortY = typeof existing?.args?.y === 'number' ? existing.args.y : (node.size().height / 2);
1102
+ const placement = projectPortPlacement(node, clientX, clientY, portPlacementById.get(portId)?.side ?? 'right')
1103
+ ?? portPlacementById.get(portId)
1104
+ ?? { side: 'right', ratio: 0.5 };
831
1105
  activePortDrag = {
832
1106
  nodeId: String(node.id),
833
1107
  portId: String(portId),
834
- startClientY: clientY,
835
- startPortY,
1108
+ pointerId,
1109
+ side: placement.side,
1110
+ ratio: placement.ratio,
1111
+ container,
836
1112
  };
837
1113
  };
838
- const onCanvasPointerDown = (event) => {
839
- if (!isPortPointerTarget(event))
840
- return;
841
- const portId = findPortIdFromTarget(event.target);
842
- if (!portId)
843
- return;
844
- const ownerId = ownerByPortId.get(portId);
845
- if (!ownerId)
846
- return;
847
- const node = graphView.getCellById(ownerId);
848
- startPortDrag(event.clientY, node, portId);
849
- event.preventDefault();
850
- event.stopPropagation();
851
- if (typeof event.stopImmediatePropagation === 'function')
852
- event.stopImmediatePropagation();
853
- };
854
- liveCanvas.addEventListener('pointerdown', onCanvasPointerDown, true);
855
1114
  liveCanvas.addEventListener('DOMNodeRemovedFromDocument', () => {
856
1115
  window.removeEventListener('pointermove', onPointerMove);
857
1116
  window.removeEventListener('pointerup', onPointerUp);
858
- liveCanvas.removeEventListener('pointerdown', onCanvasPointerDown, true);
859
1117
  }, { once: true });
860
1118
  const resultContainer = liveCanvas.closest('.oml-md-result');
861
1119
  const resizeObserver = new ResizeObserver(() => {
@@ -910,8 +1168,100 @@ async function renderWithX6(canvas, baseId, graph, layoutOptions, actions) {
910
1168
  resizeHandle.addEventListener('pointermove', onResizePointerMove);
911
1169
  resizeHandle.addEventListener('pointerup', onResizePointerEnd);
912
1170
  resizeHandle.addEventListener('pointercancel', onResizePointerEnd);
1171
+ installDiagramNodeInteractions(liveCanvas, graphView);
913
1172
  installDiagramToolbar(liveCanvas, graphView, graph, actions);
914
1173
  }
1174
+ function installDiagramNodeInteractions(canvas, graphView) {
1175
+ const ensureSvgNativeTitle = (element, iri) => {
1176
+ let titleNode = null;
1177
+ for (const child of Array.from(element.children)) {
1178
+ if (child instanceof SVGTitleElement) {
1179
+ titleNode = child;
1180
+ break;
1181
+ }
1182
+ }
1183
+ if (!titleNode) {
1184
+ titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
1185
+ element.insertBefore(titleNode, element.firstChild);
1186
+ }
1187
+ titleNode.textContent = iri;
1188
+ };
1189
+ const applyNativeTooltipTitle = (container, iri, eventTarget) => {
1190
+ if (!iri) {
1191
+ return;
1192
+ }
1193
+ if (container instanceof HTMLElement || container instanceof SVGElement) {
1194
+ container.setAttribute('title', iri);
1195
+ }
1196
+ if (container instanceof Element) {
1197
+ for (const element of Array.from(container.querySelectorAll('*'))) {
1198
+ element.setAttribute('title', iri);
1199
+ if (element instanceof SVGElement) {
1200
+ ensureSvgNativeTitle(element, iri);
1201
+ }
1202
+ }
1203
+ if (container instanceof SVGElement) {
1204
+ ensureSvgNativeTitle(container, iri);
1205
+ }
1206
+ }
1207
+ if (eventTarget instanceof HTMLElement || eventTarget instanceof SVGElement) {
1208
+ eventTarget.setAttribute('title', iri);
1209
+ if (eventTarget instanceof SVGElement) {
1210
+ ensureSvgNativeTitle(eventTarget, iri);
1211
+ }
1212
+ }
1213
+ };
1214
+ const clearHover = () => {
1215
+ canvas.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
1216
+ };
1217
+ graphView.on('node:mouseenter', ({ node, e }) => {
1218
+ const iri = String(node?.id ?? '');
1219
+ if (iri) {
1220
+ const view = graphView.findViewByCell?.(node);
1221
+ const bbox = view?.container?.getBoundingClientRect?.();
1222
+ applyNativeTooltipTitle(view?.container, iri, e?.target ?? null);
1223
+ if (!bbox) {
1224
+ return;
1225
+ }
1226
+ canvas.dispatchEvent(new CustomEvent('md-show-iri-hover', {
1227
+ bubbles: true,
1228
+ detail: {
1229
+ iri,
1230
+ previewEnabled: !!e && (/^Mac/i.test(navigator.platform) ? e.metaKey : e.ctrlKey),
1231
+ anchorRect: {
1232
+ left: bbox.left,
1233
+ right: bbox.right,
1234
+ top: bbox.top,
1235
+ bottom: bbox.bottom,
1236
+ width: bbox.width,
1237
+ height: bbox.height,
1238
+ },
1239
+ },
1240
+ }));
1241
+ }
1242
+ });
1243
+ graphView.on('node:mouseleave', () => {
1244
+ clearHover();
1245
+ });
1246
+ graphView.on('blank:mousemove', () => {
1247
+ clearHover();
1248
+ });
1249
+ graphView.on('node:dblclick', ({ node, e }) => {
1250
+ const iri = String(node?.id ?? '');
1251
+ if (!iri) {
1252
+ return;
1253
+ }
1254
+ e.preventDefault();
1255
+ e.stopPropagation();
1256
+ if (typeof e.stopImmediatePropagation === 'function') {
1257
+ e.stopImmediatePropagation();
1258
+ }
1259
+ canvas.dispatchEvent(new CustomEvent('md-navigate-iri', {
1260
+ bubbles: true,
1261
+ detail: { iri },
1262
+ }));
1263
+ });
1264
+ }
915
1265
  function installDiagramToolbar(graphRoot, graphView, graph, actions) {
916
1266
  const hotspot = document.createElement('div');
917
1267
  hotspot.className = 'graph-corner-hotspot';
@@ -1238,7 +1588,7 @@ async function loadDagreLib() {
1238
1588
  dagreLib = mod.default ?? mod;
1239
1589
  return dagreLib;
1240
1590
  }
1241
- async function layoutGraphDagre(graph, options) {
1591
+ async function layoutGraphDagre(graph, options, rootSpacing) {
1242
1592
  const dagre = await loadDagreLib();
1243
1593
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
1244
1594
  const layoutNodes = graph.nodes.filter((node) => node.kind !== 'Port');
@@ -1275,8 +1625,6 @@ async function layoutGraphDagre(graph, options) {
1275
1625
  stack: {
1276
1626
  direction: styleLayout.direction === 'horizontal' ? 'horizontal' : 'vertical',
1277
1627
  gap: clampLayoutNumber(styleLayout.gap, 0, 400, 0),
1278
- marginx: clampLayoutNumber(styleLayout.marginx, 0, 200, 0),
1279
- marginy: clampLayoutNumber(styleLayout.marginy, 0, 200, 0),
1280
1628
  stretch: typeof styleLayout.stretch === 'boolean' ? styleLayout.stretch : true,
1281
1629
  },
1282
1630
  };
@@ -1296,33 +1644,43 @@ async function layoutGraphDagre(graph, options) {
1296
1644
  rankdir,
1297
1645
  nodesep: clampLayoutNumber(styleLayout.nodesep, 0, 400, options.nodesep),
1298
1646
  ranksep: clampLayoutNumber(styleLayout.ranksep, 0, 500, options.ranksep),
1299
- marginx: clampLayoutNumber(styleLayout.marginx, 0, 200, options.marginx),
1300
- marginy: clampLayoutNumber(styleLayout.marginy, 0, 200, options.marginy),
1301
1647
  },
1302
1648
  };
1303
1649
  };
1304
1650
  const childPositionById = new Map();
1651
+ const branchChildForParent = (parentId, nodeId) => {
1652
+ let cursorId = nodeId;
1653
+ while (cursorId) {
1654
+ const node = nodeById.get(cursorId);
1655
+ if (!node) {
1656
+ return undefined;
1657
+ }
1658
+ if (node.parentId === parentId) {
1659
+ return node.id;
1660
+ }
1661
+ cursorId = node.parentId;
1662
+ }
1663
+ return undefined;
1664
+ };
1305
1665
  const edgesBetweenChildren = (parentId, childSet) => {
1306
1666
  const seen = new Set();
1307
1667
  const links = [];
1308
1668
  for (const edge of graph.edges) {
1309
1669
  const sourceId = endpointOwner(edge.sourceId);
1310
1670
  const targetId = endpointOwner(edge.targetId);
1311
- if (!sourceId || !targetId || sourceId === targetId)
1312
- continue;
1313
- if (!childSet.has(sourceId) || !childSet.has(targetId))
1671
+ if (!sourceId || !targetId)
1314
1672
  continue;
1315
- const sourceNode = nodeById.get(sourceId);
1316
- const targetNode = nodeById.get(targetId);
1317
- if (!sourceNode || !targetNode)
1673
+ const sourceBranchId = branchChildForParent(parentId, sourceId);
1674
+ const targetBranchId = branchChildForParent(parentId, targetId);
1675
+ if (!sourceBranchId || !targetBranchId || sourceBranchId === targetBranchId)
1318
1676
  continue;
1319
- if (sourceNode.parentId !== parentId || targetNode.parentId !== parentId)
1677
+ if (!childSet.has(sourceBranchId) || !childSet.has(targetBranchId))
1320
1678
  continue;
1321
- const key = `${sourceId}=>${targetId}`;
1679
+ const key = `${sourceBranchId}=>${targetBranchId}`;
1322
1680
  if (seen.has(key))
1323
1681
  continue;
1324
1682
  seen.add(key);
1325
- links.push({ source: sourceId, target: targetId });
1683
+ links.push({ source: sourceBranchId, target: targetBranchId });
1326
1684
  }
1327
1685
  return links;
1328
1686
  };
@@ -1335,8 +1693,8 @@ async function layoutGraphDagre(graph, options) {
1335
1693
  return { width: 0, height: 0 };
1336
1694
  }
1337
1695
  const parent = parentId ? nodeById.get(parentId) : undefined;
1338
- const topPadding = parent?.contentTopPadding ?? 0;
1339
1696
  const localLayout = layoutOptionsForParent(parentId);
1697
+ const spacing = parent ? resolveBoxSpacing(parent.style, 0) : rootSpacing;
1340
1698
  let totalWidth = 0;
1341
1699
  let totalHeight = 0;
1342
1700
  if (localLayout.type === 'stack') {
@@ -1344,13 +1702,13 @@ async function layoutGraphDagre(graph, options) {
1344
1702
  const childNodes = childIds.map((childId) => nodeById.get(childId)).filter((node) => !!node);
1345
1703
  const maxChildWidth = childNodes.reduce((max, child) => Math.max(max, child.width), 0);
1346
1704
  const maxChildHeight = childNodes.reduce((max, child) => Math.max(max, child.height), 0);
1347
- const parentContentWidth = Math.max(0, (parent?.width ?? 0) - (local.marginx * 2));
1348
- const parentContentHeight = Math.max(0, (parent?.height ?? 0) - topPadding - (local.marginy * 2));
1705
+ const parentContentWidth = Math.max(0, (parent?.width ?? 0) - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1706
+ const parentContentHeight = Math.max(0, (parent?.height ?? 0) - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1349
1707
  const availableWidth = Math.max(maxChildWidth, parentContentWidth);
1350
1708
  const availableHeight = Math.max(maxChildHeight, parentContentHeight);
1351
1709
  const isSingleChild = childNodes.length === 1;
1352
- let cursorX = local.marginx;
1353
- let cursorY = topPadding + local.marginy;
1710
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1711
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1354
1712
  for (const child of childNodes) {
1355
1713
  if (local.stretch && isSingleChild) {
1356
1714
  child.width = Math.max(0, availableWidth);
@@ -1373,14 +1731,14 @@ async function layoutGraphDagre(graph, options) {
1373
1731
  if (local.direction === 'vertical') {
1374
1732
  const contentHeight = Math.max(0, childNodes.reduce((sum, child) => sum + child.height, 0) + (Math.max(0, childNodes.length - 1) * local.gap));
1375
1733
  const contentWidth = Math.max(0, childNodes.reduce((max, child) => Math.max(max, child.width), 0));
1376
- totalWidth = (local.marginx * 2) + contentWidth;
1377
- totalHeight = topPadding + (local.marginy * 2) + contentHeight;
1734
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1735
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1378
1736
  }
1379
1737
  else {
1380
1738
  const contentWidth = Math.max(0, childNodes.reduce((sum, child) => sum + child.width, 0) + (Math.max(0, childNodes.length - 1) * local.gap));
1381
1739
  const contentHeight = Math.max(0, childNodes.reduce((max, child) => Math.max(max, child.height), 0));
1382
- totalWidth = (local.marginx * 2) + contentWidth;
1383
- totalHeight = topPadding + (local.marginy * 2) + contentHeight;
1740
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1741
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1384
1742
  }
1385
1743
  }
1386
1744
  else {
@@ -1438,22 +1796,18 @@ async function layoutGraphDagre(graph, options) {
1438
1796
  const left = laid.x - (width / 2);
1439
1797
  const top = laid.y - (height / 2);
1440
1798
  childPositionById.set(childId, {
1441
- x: localOptions.marginx + (left - minX),
1442
- y: topPadding + localOptions.marginy + (top - minY),
1799
+ x: spacing.marginLeft + spacing.paddingLeft + (left - minX),
1800
+ y: spacing.marginTop + spacing.paddingTop + (top - minY),
1443
1801
  });
1444
1802
  }
1445
1803
  const contentWidth = Math.max(0, maxX - minX);
1446
1804
  const contentHeight = Math.max(0, maxY - minY);
1447
- totalWidth = (localOptions.marginx * 2) + contentWidth;
1448
- totalHeight = topPadding + (localOptions.marginy * 2) + contentHeight;
1805
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1806
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1449
1807
  }
1450
1808
  if (parent) {
1451
- if (!parent.hasExplicitWidth) {
1452
- parent.width = Math.max(parent.width, totalWidth);
1453
- }
1454
- if (!parent.hasExplicitHeight) {
1455
- parent.height = Math.max(parent.height, totalHeight);
1456
- }
1809
+ parent.width = Math.max(parent.width, totalWidth);
1810
+ parent.height = Math.max(parent.height, totalHeight);
1457
1811
  }
1458
1812
  return { width: totalWidth, height: totalHeight };
1459
1813
  };
@@ -1471,9 +1825,9 @@ async function layoutGraphDagre(graph, options) {
1471
1825
  if (localLayout.type === 'stack') {
1472
1826
  const local = localLayout.stack;
1473
1827
  const childNodes = childIds.map((childId) => nodeById.get(childId)).filter((node) => !!node);
1474
- const topPadding = parent.contentTopPadding;
1475
- const contentWidth = Math.max(0, parent.width - (local.marginx * 2));
1476
- const contentHeight = Math.max(0, parent.height - topPadding - (local.marginy * 2));
1828
+ const spacing = resolveBoxSpacing(parent.style, 0);
1829
+ const contentWidth = Math.max(0, parent.width - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1830
+ const contentHeight = Math.max(0, parent.height - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1477
1831
  const isSingleChild = childNodes.length === 1;
1478
1832
  if (local.stretch) {
1479
1833
  for (const child of childNodes) {
@@ -1490,8 +1844,8 @@ async function layoutGraphDagre(graph, options) {
1490
1844
  }
1491
1845
  }
1492
1846
  }
1493
- let cursorX = local.marginx;
1494
- let cursorY = topPadding + local.marginy;
1847
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1848
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1495
1849
  for (const child of childNodes) {
1496
1850
  childPositionById.set(child.id, { x: cursorX, y: cursorY });
1497
1851
  if (local.direction === 'vertical') {
@@ -1692,9 +2046,6 @@ function compileDiagramGraph(index, stylesheet) {
1692
2046
  children: [],
1693
2047
  width: 160,
1694
2048
  height: 70,
1695
- hasExplicitWidth: false,
1696
- hasExplicitHeight: false,
1697
- contentTopPadding: 0,
1698
2049
  });
1699
2050
  }
1700
2051
  const validParent = (child, parent) => {
@@ -1777,9 +2128,6 @@ function compileDiagramGraph(index, stylesheet) {
1777
2128
  const estimated = estimateSize(node.kind, baseLabel, node.labels.length, node.style);
1778
2129
  node.width = estimated.width;
1779
2130
  node.height = estimated.height;
1780
- node.hasExplicitWidth = estimated.hasExplicitWidth;
1781
- node.hasExplicitHeight = estimated.hasExplicitHeight;
1782
- node.contentTopPadding = resolveContainerTopPadding(node, baseLabel);
1783
2131
  }
1784
2132
  for (const edge of edges) {
1785
2133
  edge.style = styleFor('edge', edge.id, edge.classes, edge.properties);
@@ -1807,57 +2155,40 @@ function compileDiagramGraph(index, stylesheet) {
1807
2155
  function estimateSize(kind, label, labelCount, style) {
1808
2156
  const styledWidth = toPositiveNumber(style.width);
1809
2157
  const styledHeight = toPositiveNumber(style.height);
1810
- if (styledWidth && styledHeight) {
1811
- return { width: styledWidth, height: styledHeight, hasExplicitWidth: true, hasExplicitHeight: true };
1812
- }
2158
+ const attrs = extractStyleAttrs(style);
2159
+ const labelAttrs = asRecord(attrs.label);
2160
+ const labelDisplay = typeof labelAttrs?.display === 'string' ? labelAttrs.display.trim().toLowerCase() : '';
2161
+ const labelOpacity = toNonNegativeNumber(labelAttrs?.opacity);
2162
+ const labelVisible = labelDisplay !== 'none' && (labelOpacity === undefined || labelOpacity > 0);
2163
+ const effectiveLabel = labelVisible ? label : '';
2164
+ const effectiveLabelCount = labelVisible ? labelCount : 0;
1813
2165
  if (kind === 'Port') {
1814
2166
  return {
1815
- width: styledWidth ?? 14,
1816
- height: styledHeight ?? 14,
1817
- hasExplicitWidth: styledWidth !== undefined,
1818
- hasExplicitHeight: styledHeight !== undefined,
2167
+ width: Math.max(14, styledWidth ?? 0),
2168
+ height: Math.max(14, styledHeight ?? 0),
1819
2169
  };
1820
2170
  }
1821
- const textWidth = Math.max(24, label.length * 7);
1822
- const baseWidth = Math.max(72, Math.min(280, textWidth + 26));
2171
+ const textWidth = effectiveLabel.length > 0 ? Math.max(24, effectiveLabel.length * 7) : 0;
2172
+ const baseWidth = effectiveLabel.length > 0 ? Math.max(72, Math.min(280, textWidth + 26)) : 0;
1823
2173
  if (kind === 'Compartment') {
1824
- const size = { width: baseWidth, height: Math.max(44, 24 + labelCount * 16) };
2174
+ const size = {
2175
+ width: baseWidth,
2176
+ height: effectiveLabelCount > 0 ? Math.max(44, 24 + effectiveLabelCount * 16) : 0,
2177
+ };
1825
2178
  return {
1826
- width: styledWidth ?? size.width,
1827
- height: styledHeight ?? size.height,
1828
- hasExplicitWidth: styledWidth !== undefined,
1829
- hasExplicitHeight: styledHeight !== undefined,
2179
+ width: Math.max(size.width, styledWidth ?? 0),
2180
+ height: Math.max(size.height, styledHeight ?? 0),
1830
2181
  };
1831
2182
  }
1832
- const size = { width: baseWidth, height: Math.max(36, 20 + labelCount * 16) };
2183
+ const size = {
2184
+ width: baseWidth,
2185
+ height: effectiveLabelCount > 0 ? Math.max(36, 20 + effectiveLabelCount * 16) : 0,
2186
+ };
1833
2187
  return {
1834
- width: styledWidth ?? size.width,
1835
- height: styledHeight ?? size.height,
1836
- hasExplicitWidth: styledWidth !== undefined,
1837
- hasExplicitHeight: styledHeight !== undefined,
2188
+ width: Math.max(size.width, styledWidth ?? 0),
2189
+ height: Math.max(size.height, styledHeight ?? 0),
1838
2190
  };
1839
2191
  }
1840
- function resolveContainerTopPadding(node, labelText) {
1841
- if (node.children.length === 0) {
1842
- return 0;
1843
- }
1844
- const explicitPadding = toNonNegativeNumber(node.style.paddingTop);
1845
- if (explicitPadding !== undefined) {
1846
- return Math.ceil(explicitPadding);
1847
- }
1848
- const attrs = extractStyleAttrs(node.style);
1849
- const label = asRecord(attrs.label);
1850
- if (label?.display === 'none') {
1851
- return 0;
1852
- }
1853
- const labelOpacity = toNonNegativeNumber(label?.opacity);
1854
- if (labelOpacity !== undefined && labelOpacity <= 0) {
1855
- return 0;
1856
- }
1857
- const fontSize = toPositiveNumber(label?.fontSize) ?? 12;
1858
- const lineCount = Math.max(1, labelText.split('\n').filter((line) => line.trim().length > 0).length);
1859
- return Math.ceil((fontSize * 1.2) * lineCount);
1860
- }
1861
2192
  function localName(value) {
1862
2193
  return displayLabelFromIri(value);
1863
2194
  }
@@ -1907,9 +2238,7 @@ function resolveDagreLayoutOptions(options) {
1907
2238
  : 'LR';
1908
2239
  const nodesep = clampLayoutNumber(layout.nodesep, 0, 400, 28);
1909
2240
  const ranksep = clampLayoutNumber(layout.ranksep, 0, 500, 64);
1910
- const marginx = clampLayoutNumber(layout.marginx, 0, 200, 16);
1911
- const marginy = clampLayoutNumber(layout.marginy, 0, 200, 16);
1912
- return { rankdir, nodesep, ranksep, marginx, marginy };
2241
+ return { rankdir, nodesep, ranksep };
1913
2242
  }
1914
2243
  function clampLayoutNumber(value, min, max, fallback) {
1915
2244
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -1917,6 +2246,121 @@ function clampLayoutNumber(value, min, max, fallback) {
1917
2246
  }
1918
2247
  return Math.max(min, Math.min(max, Math.round(value)));
1919
2248
  }
2249
+ function resolveRootSpacing(options) {
2250
+ return readBoxSpacing(options, {
2251
+ marginTop: 16,
2252
+ marginBottom: 16,
2253
+ marginLeft: 16,
2254
+ marginRight: 16,
2255
+ paddingTop: 0,
2256
+ paddingBottom: 0,
2257
+ paddingLeft: 0,
2258
+ paddingRight: 0,
2259
+ });
2260
+ }
2261
+ function resolveBoxSpacing(style, fallbackMargin) {
2262
+ return readBoxSpacing(style, {
2263
+ marginTop: fallbackMargin,
2264
+ marginBottom: fallbackMargin,
2265
+ marginLeft: fallbackMargin,
2266
+ marginRight: fallbackMargin,
2267
+ paddingTop: 0,
2268
+ paddingBottom: 0,
2269
+ paddingLeft: 0,
2270
+ paddingRight: 0,
2271
+ });
2272
+ }
2273
+ function readBoxSpacing(style, defaults) {
2274
+ const source = style ?? {};
2275
+ return {
2276
+ ...readCssBoxSpacing(source.margin, 'margin', defaults),
2277
+ ...readCssBoxSpacing(source.padding, 'padding', defaults),
2278
+ };
2279
+ }
2280
+ function readCssBoxSpacing(value, kind, defaults) {
2281
+ const sides = parseCssBoxShorthand(value);
2282
+ const isMargin = kind === 'margin';
2283
+ if (isMargin) {
2284
+ return {
2285
+ marginTop: sides?.top ?? defaults.marginTop,
2286
+ marginRight: sides?.right ?? defaults.marginRight,
2287
+ marginBottom: sides?.bottom ?? defaults.marginBottom,
2288
+ marginLeft: sides?.left ?? defaults.marginLeft,
2289
+ paddingTop: defaults.paddingTop,
2290
+ paddingRight: defaults.paddingRight,
2291
+ paddingBottom: defaults.paddingBottom,
2292
+ paddingLeft: defaults.paddingLeft,
2293
+ };
2294
+ }
2295
+ return {
2296
+ marginTop: defaults.marginTop,
2297
+ marginRight: defaults.marginRight,
2298
+ marginBottom: defaults.marginBottom,
2299
+ marginLeft: defaults.marginLeft,
2300
+ paddingTop: sides?.top ?? defaults.paddingTop,
2301
+ paddingRight: sides?.right ?? defaults.paddingRight,
2302
+ paddingBottom: sides?.bottom ?? defaults.paddingBottom,
2303
+ paddingLeft: sides?.left ?? defaults.paddingLeft,
2304
+ };
2305
+ }
2306
+ function parseCssBoxShorthand(value) {
2307
+ const values = Array.isArray(value)
2308
+ ? value
2309
+ : typeof value === 'string'
2310
+ ? (value.includes(',') ? undefined : value.trim().split(/\s+/).filter((token) => token.length > 0))
2311
+ : typeof value === 'number'
2312
+ ? [value]
2313
+ : undefined;
2314
+ if (!values || values.length === 0 || values.length > 4) {
2315
+ return undefined;
2316
+ }
2317
+ const parsed = values.map((entry) => {
2318
+ if (typeof entry === 'number' && Number.isFinite(entry)) {
2319
+ return entry;
2320
+ }
2321
+ if (typeof entry === 'string') {
2322
+ const next = Number.parseFloat(entry);
2323
+ if (Number.isFinite(next)) {
2324
+ return next;
2325
+ }
2326
+ }
2327
+ return undefined;
2328
+ });
2329
+ if (parsed.some((entry) => entry === undefined)) {
2330
+ return undefined;
2331
+ }
2332
+ const clampCssBoxValue = (entry) => {
2333
+ if (entry === undefined)
2334
+ return undefined;
2335
+ return Math.max(0, Math.min(200, Math.round(entry)));
2336
+ };
2337
+ if (parsed.length === 1) {
2338
+ const single = clampCssBoxValue(parsed[0]);
2339
+ return single === undefined ? undefined : { top: single, right: single, bottom: single, left: single };
2340
+ }
2341
+ if (parsed.length === 2) {
2342
+ const vertical = clampCssBoxValue(parsed[0]);
2343
+ const horizontal = clampCssBoxValue(parsed[1]);
2344
+ if (vertical === undefined || horizontal === undefined)
2345
+ return undefined;
2346
+ return { top: vertical, right: horizontal, bottom: vertical, left: horizontal };
2347
+ }
2348
+ if (parsed.length === 3) {
2349
+ const top = clampCssBoxValue(parsed[0]);
2350
+ const horizontal = clampCssBoxValue(parsed[1]);
2351
+ const bottom = clampCssBoxValue(parsed[2]);
2352
+ if (top === undefined || horizontal === undefined || bottom === undefined)
2353
+ return undefined;
2354
+ return { top, right: horizontal, bottom, left: horizontal };
2355
+ }
2356
+ const top = clampCssBoxValue(parsed[0]);
2357
+ const right = clampCssBoxValue(parsed[1]);
2358
+ const bottom = clampCssBoxValue(parsed[2]);
2359
+ const left = clampCssBoxValue(parsed[3]);
2360
+ if (top === undefined || right === undefined || bottom === undefined || left === undefined)
2361
+ return undefined;
2362
+ return { top, right, bottom, left };
2363
+ }
1920
2364
  function parseDiagramStylesheet(options) {
1921
2365
  const stylesheet = options?.stylesheet;
1922
2366
  if (!Array.isArray(stylesheet)) {
@@ -1943,7 +2387,7 @@ function parseDiagramStylesheet(options) {
1943
2387
  return rules;
1944
2388
  }
1945
2389
  function parseStyleSelector(selector) {
1946
- const match = /^\s*(node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
2390
+ const match = /^\s*(diagram|node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
1947
2391
  if (!match)
1948
2392
  return undefined;
1949
2393
  return {
@@ -1956,7 +2400,13 @@ function parseStyleSelector(selector) {
1956
2400
  // These pass through as top-level keys so callers can read style.layout, style.width, etc.
1957
2401
  const OML_PASSTHROUGH_STYLE_KEYS = new Set([
1958
2402
  'layout', 'shape', 'width', 'height',
2403
+ 'margin', 'padding', 'router', 'connector',
2404
+ ]);
2405
+ const LEGACY_BOX_SPACING_KEYS = new Set([
2406
+ 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
1959
2407
  'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
2408
+ 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
2409
+ 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
1960
2410
  ]);
1961
2411
  // Record-valued top-level style keys that map directly into the attrs sub-tree under the same name.
1962
2412
  // 'label' and 'icon' cover nodes, compartments, ports, and edges.
@@ -1978,6 +2428,9 @@ function normalizeDiagramStyle(elementKind, raw) {
1978
2428
  const key = rawKey.trim();
1979
2429
  if (!key || value === undefined || value === null)
1980
2430
  continue;
2431
+ if (LEGACY_BOX_SPACING_KEYS.has(key)) {
2432
+ continue;
2433
+ }
1981
2434
  // OML node-level keys: pass through to the top level of the normalized style object
1982
2435
  if (OML_PASSTHROUGH_STYLE_KEYS.has(key)) {
1983
2436
  passthrough[key] = value;
@@ -2254,6 +2707,17 @@ function resolveEdgeLineAttrs(style) {
2254
2707
  ...(line ?? {}),
2255
2708
  };
2256
2709
  }
2710
+ function resolveEdgeRouter(style) {
2711
+ const router = asRecord(style.router);
2712
+ return router ?? { name: 'normal' };
2713
+ }
2714
+ function resolveEdgeConnector(style) {
2715
+ const connector = asRecord(style.connector);
2716
+ return connector ?? {
2717
+ name: 'jumpover',
2718
+ args: { size: 5 },
2719
+ };
2720
+ }
2257
2721
  function resolveEdgeLabelAttrs(style, placement, text) {
2258
2722
  const attrs = extractStyleAttrs(style);
2259
2723
  const base = asRecord(attrs.label);
@@ -2276,17 +2740,41 @@ function resolveEdgeLabelBodyAttrs(style, placement) {
2276
2740
  fillOpacity: 0.9,
2277
2741
  stroke: 'none',
2278
2742
  strokeWidth: 0,
2743
+ pointerEvents: 'all',
2279
2744
  ...(base ?? {}),
2280
2745
  ...(specific ?? {}),
2281
2746
  };
2282
2747
  }
2283
- function resolvePortAttrs(style, classes, text) {
2748
+ function resolvePortAttrs(style, classes, text, side = 'right', ownerStroke) {
2284
2749
  const attrs = extractStyleAttrs(style);
2285
2750
  const body = asRecord(attrs.body);
2286
2751
  const icon = asRecord(attrs.icon);
2287
2752
  const label = asRecord(attrs.label);
2288
2753
  const imageUrl = extractImageHrefFromIcon(icon);
2289
- return {
2754
+ const labelPosition = side === 'left'
2755
+ ? {
2756
+ textAnchor: 'end',
2757
+ x: -10,
2758
+ dy: '0.9em',
2759
+ }
2760
+ : side === 'top'
2761
+ ? {
2762
+ textAnchor: 'middle',
2763
+ x: 0,
2764
+ dy: '-0.3em',
2765
+ }
2766
+ : side === 'bottom'
2767
+ ? {
2768
+ textAnchor: 'middle',
2769
+ x: 0,
2770
+ dy: '1.4em',
2771
+ }
2772
+ : {
2773
+ textAnchor: 'start',
2774
+ x: 10,
2775
+ dy: '0.9em',
2776
+ };
2777
+ const result = {
2290
2778
  body: {
2291
2779
  width: 12,
2292
2780
  height: 12,
@@ -2294,7 +2782,7 @@ function resolvePortAttrs(style, classes, text) {
2294
2782
  y: -6,
2295
2783
  class: ['oml-port-body', ...classes].join(' '),
2296
2784
  magnet: false,
2297
- stroke: CSS_FOCUS_BORDER,
2785
+ stroke: ownerStroke ?? CSS_EDITOR_FOREGROUND,
2298
2786
  strokeWidth: 1,
2299
2787
  fill: CSS_EDITOR_BACKGROUND,
2300
2788
  ...(body ?? {}),
@@ -2309,17 +2797,180 @@ function resolvePortAttrs(style, classes, text) {
2309
2797
  ...(icon ?? {}),
2310
2798
  ...(imageUrl ? { href: imageUrl, xlinkHref: imageUrl, 'xlink:href': imageUrl } : {}),
2311
2799
  },
2312
- label: {
2800
+ };
2801
+ if (text) {
2802
+ result.label = {
2313
2803
  text,
2314
2804
  fill: CSS_EDITOR_FOREGROUND,
2315
2805
  fontFamily: 'var(--vscode-editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
2316
2806
  fontSize: 12,
2317
- textAnchor: 'start',
2318
- x: 10,
2319
- dy: '0.9em',
2807
+ ...labelPosition,
2320
2808
  ...(label ?? {}),
2321
- },
2809
+ };
2810
+ }
2811
+ return result;
2812
+ }
2813
+ function resolveBorderPortLabelPosition(side) {
2814
+ return { name: side };
2815
+ }
2816
+ function clamp(value, min, max) {
2817
+ return Math.max(min, Math.min(max, value));
2818
+ }
2819
+ function computeDefaultPortPlacements(graph, nodeById, portsByOwner, boxes) {
2820
+ const placements = new Map();
2821
+ const ownerCenter = (nodeId) => {
2822
+ const box = boxes.get(nodeId);
2823
+ if (!box) {
2824
+ return undefined;
2825
+ }
2826
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
2322
2827
  };
2828
+ const portIdsByOwner = new Map();
2829
+ for (const [ownerId, ports] of portsByOwner.entries()) {
2830
+ portIdsByOwner.set(ownerId, new Set(ports.map((port) => port.id)));
2831
+ }
2832
+ const peerPortIdsByPort = new Map();
2833
+ const peerCentersByPort = new Map();
2834
+ for (const edge of graph.edges) {
2835
+ const source = nodeById.get(edge.sourceId);
2836
+ const target = nodeById.get(edge.targetId);
2837
+ const sourceOwnerId = source?.kind === 'Port' ? source.parentId : source?.id;
2838
+ const targetOwnerId = target?.kind === 'Port' ? target.parentId : target?.id;
2839
+ if (!sourceOwnerId || !targetOwnerId || sourceOwnerId === targetOwnerId) {
2840
+ continue;
2841
+ }
2842
+ const sourcePeer = ownerCenter(targetOwnerId);
2843
+ const targetPeer = ownerCenter(sourceOwnerId);
2844
+ if (source?.kind === 'Port' && sourcePeer) {
2845
+ const peers = peerCentersByPort.get(source.id) ?? [];
2846
+ peers.push(sourcePeer);
2847
+ peerCentersByPort.set(source.id, peers);
2848
+ if (target?.kind === 'Port') {
2849
+ const peerPortIds = peerPortIdsByPort.get(source.id) ?? [];
2850
+ peerPortIds.push(target.id);
2851
+ peerPortIdsByPort.set(source.id, peerPortIds);
2852
+ }
2853
+ }
2854
+ if (target?.kind === 'Port' && targetPeer) {
2855
+ const peers = peerCentersByPort.get(target.id) ?? [];
2856
+ peers.push(targetPeer);
2857
+ peerCentersByPort.set(target.id, peers);
2858
+ if (source?.kind === 'Port') {
2859
+ const peerPortIds = peerPortIdsByPort.get(target.id) ?? [];
2860
+ peerPortIds.push(source.id);
2861
+ peerPortIdsByPort.set(target.id, peerPortIds);
2862
+ }
2863
+ }
2864
+ }
2865
+ const anchorFor = (ownerId, side, ratio) => {
2866
+ const box = boxes.get(ownerId);
2867
+ if (!box) {
2868
+ return undefined;
2869
+ }
2870
+ const clampedRatio = clamp(ratio, 0.05, 0.95);
2871
+ if (side === 'left') {
2872
+ return { x: box.x, y: box.y + (box.height * clampedRatio) };
2873
+ }
2874
+ if (side === 'right') {
2875
+ return { x: box.x + box.width, y: box.y + (box.height * clampedRatio) };
2876
+ }
2877
+ if (side === 'top') {
2878
+ return { x: box.x + (box.width * clampedRatio), y: box.y };
2879
+ }
2880
+ return { x: box.x + (box.width * clampedRatio), y: box.y + box.height };
2881
+ };
2882
+ const currentAnchorForPort = (portId) => {
2883
+ const port = nodeById.get(portId);
2884
+ if (!port?.parentId) {
2885
+ return undefined;
2886
+ }
2887
+ const placement = placements.get(portId) ?? { side: 'right', ratio: 0.5 };
2888
+ return anchorFor(port.parentId, placement.side, placement.ratio);
2889
+ };
2890
+ const peerAnchorsForPort = (portId) => {
2891
+ const peerAnchors = [];
2892
+ for (const peerPortId of peerPortIdsByPort.get(portId) ?? []) {
2893
+ const anchor = currentAnchorForPort(peerPortId);
2894
+ if (anchor) {
2895
+ peerAnchors.push(anchor);
2896
+ }
2897
+ }
2898
+ for (const peerCenter of peerCentersByPort.get(portId) ?? []) {
2899
+ peerAnchors.push(peerCenter);
2900
+ }
2901
+ return peerAnchors;
2902
+ };
2903
+ const candidateCost = (ownerId, side, portId) => {
2904
+ const owner = ownerCenter(ownerId);
2905
+ const candidate = anchorFor(ownerId, side, 0.5);
2906
+ if (!owner || !candidate) {
2907
+ return Number.POSITIVE_INFINITY;
2908
+ }
2909
+ const peerAnchors = peerAnchorsForPort(portId);
2910
+ if (peerAnchors.length === 0) {
2911
+ return side === 'right' ? 0 : 1;
2912
+ }
2913
+ let cost = 0;
2914
+ for (const peer of peerAnchors) {
2915
+ cost += Math.abs(candidate.x - peer.x) + Math.abs(candidate.y - peer.y);
2916
+ }
2917
+ return cost;
2918
+ };
2919
+ const sideOrder = ['right', 'left', 'top', 'bottom'];
2920
+ const primaryCoordinate = (side, portId) => {
2921
+ const peerAnchors = peerAnchorsForPort(portId);
2922
+ if (peerAnchors.length === 0) {
2923
+ return 0;
2924
+ }
2925
+ const values = peerAnchors.map((peer) => ((side === 'left' || side === 'right') ? peer.y : peer.x));
2926
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
2927
+ };
2928
+ for (const [ownerId, ports] of portsByOwner.entries()) {
2929
+ const count = ports.length;
2930
+ for (let index = 0; index < count; index += 1) {
2931
+ placements.set(ports[index].id, {
2932
+ side: 'right',
2933
+ ratio: count <= 0 ? 0.5 : ((index + 1) / (count + 1)),
2934
+ });
2935
+ }
2936
+ const ownerPortIds = portIdsByOwner.get(ownerId) ?? new Set();
2937
+ for (let pass = 0; pass < 2; pass += 1) {
2938
+ for (const port of ports) {
2939
+ let bestSide = 'right';
2940
+ let bestCost = Number.POSITIVE_INFINITY;
2941
+ for (const side of sideOrder) {
2942
+ const cost = candidateCost(ownerId, side, port.id);
2943
+ if (cost < bestCost || (cost === bestCost && sideOrder.indexOf(side) < sideOrder.indexOf(bestSide))) {
2944
+ bestCost = cost;
2945
+ bestSide = side;
2946
+ }
2947
+ }
2948
+ const existing = placements.get(port.id) ?? { ratio: 0.5, side: bestSide };
2949
+ placements.set(port.id, { side: bestSide, ratio: existing.ratio });
2950
+ }
2951
+ const sideGroups = new Map([
2952
+ ['left', []],
2953
+ ['right', []],
2954
+ ['top', []],
2955
+ ['bottom', []],
2956
+ ]);
2957
+ for (const port of ports) {
2958
+ sideGroups.get(placements.get(port.id)?.side ?? 'right')?.push(port);
2959
+ }
2960
+ for (const [side, sidePorts] of sideGroups.entries()) {
2961
+ sidePorts.sort((left, right) => (primaryCoordinate(side, left.id) - primaryCoordinate(side, right.id)
2962
+ || left.id.localeCompare(right.id)));
2963
+ for (let index = 0; index < sidePorts.length; index += 1) {
2964
+ placements.set(sidePorts[index].id, {
2965
+ side,
2966
+ ratio: (index + 1) / (sidePorts.length + 1),
2967
+ });
2968
+ }
2969
+ }
2970
+ void ownerPortIds;
2971
+ }
2972
+ }
2973
+ return placements;
2323
2974
  }
2324
2975
  function toPositiveNumber(value) {
2325
2976
  if (typeof value === 'number' && Number.isFinite(value) && value > 0) {