@oml/markdown 0.9.0 → 0.11.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 (37) hide show
  1. package/out/md/md-executor.js +2 -9
  2. package/out/md/md-executor.js.map +1 -1
  3. package/out/md/md-runtime.js +2 -26
  4. package/out/md/md-runtime.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 +738 -252
  8. package/out/renderers/diagram-renderer.js.map +1 -1
  9. package/out/renderers/graph-renderer.js +5 -9
  10. package/out/renderers/graph-renderer.js.map +1 -1
  11. package/out/renderers/renderer.d.ts +3 -0
  12. package/out/renderers/renderer.js +53 -0
  13. package/out/renderers/renderer.js.map +1 -1
  14. package/out/renderers/table-renderer.d.ts +8 -1
  15. package/out/renderers/table-renderer.js +22 -1
  16. package/out/renderers/table-renderer.js.map +1 -1
  17. package/out/renderers/text-renderer.d.ts +0 -1
  18. package/out/renderers/text-renderer.js +2 -9
  19. package/out/renderers/text-renderer.js.map +1 -1
  20. package/out/renderers/wikilink-utils.js +3 -10
  21. package/out/renderers/wikilink-utils.js.map +1 -1
  22. package/out/static/browser-runtime.bundle.js +1591 -6408
  23. package/out/static/browser-runtime.bundle.js.map +4 -4
  24. package/out/static/browser-runtime.js +3 -0
  25. package/out/static/browser-runtime.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/md/md-executor.ts +2 -9
  28. package/src/md/md-runtime.ts +2 -28
  29. package/src/renderers/chart-renderer.ts +93 -2
  30. package/src/renderers/diagram-renderer.ts +799 -258
  31. package/src/renderers/graph-renderer.ts +5 -9
  32. package/src/renderers/renderer.ts +66 -0
  33. package/src/renderers/table-renderer.ts +39 -3
  34. package/src/renderers/text-renderer.ts +2 -7
  35. package/src/renderers/wikilink-utils.ts +4 -10
  36. package/src/static/browser-runtime.ts +3 -0
  37. package/src/static/markdown-webview.css +1361 -0
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2026 Modelware. All rights reserved.
2
2
 
3
3
  import 'reflect-metadata';
4
- import { CanvasMarkdownBlockRenderer } from './renderer.js';
4
+ import { CanvasMarkdownBlockRenderer, displayLabelFromIri } from './renderer.js';
5
5
  import type { MdBlockExecutionResult } from './types.js';
6
6
 
7
7
  type NodeKind = 'Node' | 'Compartment' | 'Port';
@@ -16,9 +16,6 @@ type NodeSpec = {
16
16
  children: string[];
17
17
  width: number;
18
18
  height: number;
19
- hasExplicitWidth: boolean;
20
- hasExplicitHeight: boolean;
21
- contentTopPadding: number;
22
19
  };
23
20
 
24
21
  type EdgeLabel = {
@@ -37,7 +34,7 @@ type EdgeSpec = {
37
34
  };
38
35
 
39
36
  type DiagramStyleRule = {
40
- elementKind: 'node' | 'compartment' | 'port' | 'edge';
37
+ elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge';
41
38
  className?: string;
42
39
  condition?: string;
43
40
  style: Record<string, unknown>;
@@ -67,18 +64,25 @@ type NativeLayoutResult = {
67
64
  contentHeight: number;
68
65
  };
69
66
 
67
+ type BoxSpacing = {
68
+ marginTop: number;
69
+ marginBottom: number;
70
+ marginLeft: number;
71
+ marginRight: number;
72
+ paddingTop: number;
73
+ paddingBottom: number;
74
+ paddingLeft: number;
75
+ paddingRight: number;
76
+ };
77
+
70
78
  type DagreLayoutOptions = {
71
79
  rankdir: 'LR' | 'RL' | 'TB' | 'BT';
72
80
  nodesep: number;
73
81
  ranksep: number;
74
- marginx: number;
75
- marginy: number;
76
82
  };
77
83
  type StackLayoutOptions = {
78
84
  direction: 'vertical' | 'horizontal';
79
85
  gap: number;
80
- marginx: number;
81
- marginy: number;
82
86
  stretch: boolean;
83
87
  };
84
88
  type ParentLayoutOptions =
@@ -89,6 +93,11 @@ type RenderNodeShape = {
89
93
  bodyTag: 'rect' | 'ellipse';
90
94
  bodyDefaults: Record<string, unknown>;
91
95
  };
96
+ type PortSide = 'left' | 'right' | 'top' | 'bottom';
97
+ type PortPlacement = {
98
+ side: PortSide;
99
+ ratio: number;
100
+ };
92
101
 
93
102
  const D = 'http://opencaesar.io/oml/diagram#';
94
103
  const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
@@ -150,13 +159,16 @@ export class DiagramMarkdownBlockRenderer extends CanvasMarkdownBlockRenderer {
150
159
  const tripleIndex = indexTriples(rows);
151
160
  const stylesheet = parseDiagramStylesheet(result.options);
152
161
  const compiled = compileDiagramGraph(tripleIndex, stylesheet);
153
- const layoutOptions = resolveDagreLayoutOptions(result.options);
162
+ const diagramStyle = resolveElementStyle('diagram', 'diagram', [], {}, stylesheet);
163
+ const layoutOptions = resolveDagreLayoutOptions(diagramStyle);
164
+ const rootSpacing = resolveRootSpacing(diagramStyle);
154
165
  if (compiled.nodes.length === 0) {
155
166
  container.appendChild(this.createMessageContainer('No diagram nodes were inferred from the diagram namespace triples.'));
156
167
  return container;
157
168
  }
158
169
 
159
170
  void renderWithX6(canvas, baseId, compiled, layoutOptions, {
171
+ rootSpacing,
160
172
  downloadSvg: (content: string) => this.requestTextFileDownload(content, 'diagram', 'svg'),
161
173
  }).catch((error) => {
162
174
  const detail = error instanceof Error ? error.message : String(error);
@@ -172,7 +184,7 @@ async function renderWithX6(
172
184
  baseId: string,
173
185
  graph: { nodes: NodeSpec[]; edges: EdgeSpec[]; roots: string[] },
174
186
  layoutOptions: DagreLayoutOptions,
175
- actions: { downloadSvg: (content: string) => void }
187
+ actions: { downloadSvg: (content: string) => void; rootSpacing: BoxSpacing }
176
188
  ): Promise<void> {
177
189
  await waitForCanvasReady(canvas);
178
190
  const liveCanvas = getLiveCanvas(baseId);
@@ -181,7 +193,7 @@ async function renderWithX6(
181
193
  }
182
194
 
183
195
  const GraphCtor = await loadX6GraphCtor();
184
- const layout = await layoutGraphDagre(graph, layoutOptions);
196
+ const layout = await layoutGraphDagre(graph, layoutOptions, actions.rootSpacing);
185
197
  const minHeight = asFiniteNumber(
186
198
  parseCssPixels(liveCanvas.style.getPropertyValue('--oml-diagram-min-height')),
187
199
  numericCanvasMinHeight(undefined)
@@ -191,13 +203,15 @@ async function renderWithX6(
191
203
  let activePortDrag: {
192
204
  nodeId: string;
193
205
  portId: string;
194
- startClientY: number;
195
- startPortY: number;
206
+ pointerId: number;
207
+ side: PortSide;
208
+ ratio: number;
209
+ container: Element;
196
210
  } | undefined;
197
211
  let isResizingCanvas = false;
198
212
  const isPortPointerTarget = (event: PointerEvent): boolean => {
199
213
  const target = event?.target as Element | null;
200
- if (target?.closest('.x6-port, .x6-port-body, .oml-port-body')) {
214
+ if (target?.closest('.x6-port, .x6-port-body, .oml-port-body, [port]')) {
201
215
  return true;
202
216
  }
203
217
  const path = typeof event?.composedPath === 'function' ? event.composedPath() : [];
@@ -205,23 +219,18 @@ async function renderWithX6(
205
219
  entry instanceof Element
206
220
  && (entry.classList.contains('x6-port')
207
221
  || entry.classList.contains('x6-port-body')
208
- || entry.classList.contains('oml-port-body'))
222
+ || entry.classList.contains('oml-port-body')
223
+ || entry.hasAttribute('port'))
209
224
  ));
210
225
  };
211
- const findPortIdFromTarget = (target: EventTarget | null): string | undefined => {
212
- if (!(target instanceof Element)) return undefined;
213
- const portHost = target.closest('.x6-port');
214
- if (!portHost) return undefined;
215
- return portHost.getAttribute('port')
216
- ?? portHost.getAttribute('data-port-id')
217
- ?? undefined;
218
- };
219
226
 
220
227
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
221
228
  const isCompartmentNode = (node: any): boolean => {
222
229
  const nodeId = String(node?.id ?? '');
223
230
  return nodeById.get(nodeId)?.kind === 'Compartment';
224
231
  };
232
+ const boundPortContainers = new WeakSet<Element>();
233
+ let nodeTransform: any;
225
234
 
226
235
  const graphView: any = new GraphCtor({
227
236
  container: liveCanvas,
@@ -236,7 +245,10 @@ async function renderWithX6(
236
245
  },
237
246
  connecting: {
238
247
  router: 'normal',
239
- connector: 'rounded',
248
+ connector: {
249
+ name: 'jumpover',
250
+ args: { size: 5 },
251
+ },
240
252
  allowBlank: false,
241
253
  allowNode: false,
242
254
  allowPort: false,
@@ -252,13 +264,64 @@ async function renderWithX6(
252
264
  edgeMovable: false,
253
265
  vertexMovable: false,
254
266
  arrowheadMovable: false,
255
- labelMovable: false,
267
+ labelMovable: true,
256
268
  };
257
269
  },
258
270
  background: {
259
271
  color: CSS_CANVAS_BACKGROUND,
260
272
  },
273
+ onPortRendered: ({ port, node, container }: { port: { id: string }; node: { id: string }; container: HTMLElement }) => {
274
+ if (boundPortContainers.has(container)) {
275
+ return;
276
+ }
277
+ boundPortContainers.add(container);
278
+ container.setAttribute('data-port-id', String(port.id));
279
+ container.style.cursor = 'grab';
280
+ container.style.touchAction = 'none';
281
+ container.addEventListener('pointerdown', (event: PointerEvent) => {
282
+ if (typeof graphView.cleanSelection === 'function') {
283
+ graphView.cleanSelection();
284
+ }
285
+ nodeTransform.clearWidgets();
286
+ const owner = graphView.getCellById(String(node.id));
287
+ if (!owner) {
288
+ return;
289
+ }
290
+ startPortDrag(event.clientX, event.clientY, owner, String(port.id), event.pointerId, container);
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ if (typeof event.stopImmediatePropagation === 'function') {
294
+ event.stopImmediatePropagation();
295
+ }
296
+ if (typeof container.setPointerCapture === 'function') {
297
+ try {
298
+ container.setPointerCapture(event.pointerId);
299
+ } catch {
300
+ // Ignore capture failures; window listeners still handle the drag.
301
+ }
302
+ }
303
+ });
304
+ },
261
305
  });
306
+ const x6Mod = await import('@antv/x6');
307
+ const TransformCtor = (x6Mod as any).Transform;
308
+ if (typeof TransformCtor !== 'function') {
309
+ throw new Error('X6 Transform plugin is unavailable in @antv/x6');
310
+ }
311
+ nodeTransform = new TransformCtor({
312
+ resizing: {
313
+ enabled: (node: any) => node?.getData?.()?.kind === 'Node',
314
+ minWidth: 48,
315
+ minHeight: 32,
316
+ orthogonal: true,
317
+ restrict: false,
318
+ autoScroll: false,
319
+ preserveAspectRatio: false,
320
+ allowReverse: false,
321
+ },
322
+ rotating: false,
323
+ });
324
+ graphView.use(nodeTransform);
262
325
  const toPlainRect = (value: any): Record<string, number> | undefined => {
263
326
  if (!value) return undefined;
264
327
  const x = Number(value.x);
@@ -291,12 +354,7 @@ async function renderWithX6(
291
354
  for (const list of portsByOwner.values()) {
292
355
  list.sort((a, b) => a.id.localeCompare(b.id));
293
356
  }
294
- const ownerByPortId = new Map<string, string>();
295
- for (const [ownerId, ports] of portsByOwner.entries()) {
296
- for (const port of ports) {
297
- ownerByPortId.set(port.id, ownerId);
298
- }
299
- }
357
+ const portPlacementById = computeDefaultPortPlacements(graph, nodeById, portsByOwner, layout.boxes);
300
358
  const ordered = [...graph.nodes]
301
359
  .filter((node) => node.kind !== 'Port')
302
360
  .sort((a, b) => nodeDepth(a.id, nodeById) - nodeDepth(b.id, nodeById));
@@ -307,20 +365,41 @@ async function renderWithX6(
307
365
  const labelText = node.labels.length > 0 ? node.labels.join('\n') : localName(node.id);
308
366
  const resolvedStyle = node.style;
309
367
  const resolvedShape = resolveRenderNodeShape(resolvedStyle);
368
+ const { bodyAttrs, labelAttrs, iconSvgAttrs, iconPathAttrs, imageAttrs } = resolveNodeAttrs(resolvedStyle);
310
369
  const x = box.x;
311
370
  const y = box.y;
312
371
  const ownerPorts = portsByOwner.get(node.id) ?? [];
313
- const portItems = ownerPorts.map((port, index) => ({
314
- id: port.id,
315
- group: 'boundary',
316
- args: {
317
- x: box.width,
318
- y: ((index + 1) * box.height) / (ownerPorts.length + 1),
319
- },
320
- attrs: resolvePortAttrs(port.style, port.classes, port.labels[0] ?? 'Port'),
321
- }));
322
-
323
- const { bodyAttrs, labelAttrs, iconSvgAttrs, iconPathAttrs, imageAttrs } = resolveNodeAttrs(resolvedStyle);
372
+ const portItems = ownerPorts.map((port) => {
373
+ const placement = portPlacementById.get(port.id) ?? { side: 'right' as PortSide, ratio: 0.5 };
374
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
375
+ const position = placement.side === 'left' || placement.side === 'right'
376
+ ? {
377
+ x: placement.side === 'left' ? 0 : box.width,
378
+ y: ratio * box.height,
379
+ }
380
+ : {
381
+ x: ratio * box.width,
382
+ y: placement.side === 'top' ? 0 : box.height,
383
+ };
384
+ const portText = port.labels[0];
385
+ return {
386
+ id: port.id,
387
+ group: 'boundary',
388
+ args: {
389
+ x: position.x,
390
+ y: position.y,
391
+ side: placement.side,
392
+ ratio,
393
+ },
394
+ attrs: resolvePortAttrs(
395
+ port.style,
396
+ port.classes,
397
+ portText,
398
+ placement.side,
399
+ typeof bodyAttrs.stroke === 'string' ? bodyAttrs.stroke : undefined
400
+ ),
401
+ };
402
+ });
324
403
  const iconPathSelectors = iconPathAttrs.map((_, index) => `iconPath${index}`);
325
404
  graphView.addNode({
326
405
  id: node.id,
@@ -424,7 +503,7 @@ async function renderWithX6(
424
503
  },
425
504
  items: portItems,
426
505
  } : undefined,
427
- zIndex: 10,
506
+ zIndex: 50,
428
507
  data: {
429
508
  kind: node.kind,
430
509
  ownerId: node.parentId,
@@ -561,10 +640,30 @@ async function renderWithX6(
561
640
  let maxBottom = Number.NEGATIVE_INFINITY;
562
641
  const childCells: any[] = [];
563
642
  const childDebug: Array<Record<string, unknown>> = [];
643
+ const childGeometryBounds = (child: any): { x: number; y: number; width: number; height: number } | undefined => {
644
+ if (!child || typeof child.size !== 'function') {
645
+ return undefined;
646
+ }
647
+ const size = child.size();
648
+ const width = Number(size?.width);
649
+ const height = Number(size?.height);
650
+ let position: any;
651
+ if (typeof child.getPosition === 'function') {
652
+ position = child.getPosition();
653
+ } else {
654
+ position = child.position;
655
+ }
656
+ const x = Number(position?.x);
657
+ const y = Number(position?.y);
658
+ if (![x, y, width, height].every(Number.isFinite)) {
659
+ return undefined;
660
+ }
661
+ return { x, y, width, height };
662
+ };
564
663
  for (const childId of childIds) {
565
664
  const child = graphView.getCellById(childId);
566
- if (!child || typeof child.getBBox !== 'function') continue;
567
- const absBBox = child.getBBox();
665
+ const absBBox = childGeometryBounds(child);
666
+ if (!absBBox) continue;
568
667
  const relLeft = absBBox.x - containerBBox.x;
569
668
  const relTop = absBBox.y - containerBBox.y;
570
669
  minLeft = Math.min(minLeft, relLeft);
@@ -596,10 +695,11 @@ async function renderWithX6(
596
695
  // container origin (without moving its embedded children, which keep their
597
696
  // absolute positions) and expand the size to compensate so the opposite edge
598
697
  // is preserved.
599
- // topPadding is the reserved header/label area; children must stay below it.
600
- const topPadding = containerSpec.contentTopPadding;
601
- const shiftX = minLeft < 0 ? minLeft : 0;
602
- const shiftY = minTop < topPadding ? minTop - topPadding : 0;
698
+ const spacing = resolveBoxSpacing(containerSpec.style, 0);
699
+ const minInsetX = spacing.marginLeft + spacing.paddingLeft;
700
+ const minInsetY = spacing.marginTop + spacing.paddingTop;
701
+ const shiftX = minLeft < minInsetX ? minLeft - minInsetX : 0;
702
+ const shiftY = minTop < minInsetY ? minTop - minInsetY : 0;
603
703
  // Dimensions needed relative to the (possibly shifted) new origin.
604
704
  const baseWidth = containerSize.width - shiftX;
605
705
  const baseHeight = containerSize.height - shiftY;
@@ -612,7 +712,8 @@ async function renderWithX6(
612
712
  childIds,
613
713
  containerBBox: toPlainRect(containerBBox),
614
714
  containerSize: toPlainRect({ x: 0, y: 0, ...containerSize }),
615
- topPadding,
715
+ minInsetX,
716
+ minInsetY,
616
717
  bounds: { minLeft, minTop, maxRight, maxBottom },
617
718
  shift: { shiftX, shiftY },
618
719
  childDebug,
@@ -632,6 +733,7 @@ async function renderWithX6(
632
733
  to: { width: neededWidth, height: neededHeight },
633
734
  });
634
735
  container.resize(neededWidth, neededHeight);
736
+ syncOwnedPortPositions(containerId);
635
737
  } else {
636
738
  logResize('container-no-resize', { containerId });
637
739
  }
@@ -708,106 +810,149 @@ async function renderWithX6(
708
810
  if (!isPrimaryMover(node, options)) return;
709
811
  growAncestorContainers(node);
710
812
  });
813
+ const syncOwnedPortPositions = (ownerId: string): void => {
814
+ const owner = graphView.getCellById(ownerId);
815
+ if (!owner || typeof owner.size !== 'function' || typeof owner.getPorts !== 'function' || typeof owner.setPortProp !== 'function') {
816
+ return;
817
+ }
818
+ const size = owner.size();
819
+ const portIds = (owner.getPorts() as any[])
820
+ .map((port) => String(port?.id ?? ''))
821
+ .filter((portId) => portId.length > 0);
822
+ for (const portId of portIds) {
823
+ const placement = portPlacementById.get(portId) ?? { side: 'right' as PortSide, ratio: 0.5 };
824
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
825
+ const nextArgs = placement.side === 'left' || placement.side === 'right'
826
+ ? {
827
+ x: placement.side === 'left' ? 0 : size.width,
828
+ y: ratio * size.height,
829
+ side: placement.side,
830
+ ratio,
831
+ }
832
+ : {
833
+ x: ratio * size.width,
834
+ y: placement.side === 'top' ? 0 : size.height,
835
+ side: placement.side,
836
+ ratio,
837
+ };
838
+ owner.setPortProp(portId, {
839
+ args: nextArgs,
840
+ label: {
841
+ position: resolveBorderPortLabelPosition(placement.side),
842
+ },
843
+ });
844
+ }
845
+ };
846
+ graphView.on('node:resizing', ({ node }: { node: any }) => {
847
+ syncOwnedPortPositions(String(node?.id ?? ''));
848
+ });
849
+ graphView.on('node:resized', ({ node, options }: { node: any; options?: Record<string, unknown> }) => {
850
+ if (options?.silent) return;
851
+ syncOwnedPortPositions(String(node?.id ?? ''));
852
+ growAncestorContainers(node);
853
+ });
711
854
 
712
855
  const edgeLabelPosition = (placement: EdgeLabel['placement']): number => {
713
856
  if (placement === 'begin') return 0.15;
714
857
  if (placement === 'end') return 0.85;
715
858
  return 0.5;
716
859
  };
717
- const endpointOwnerId = (nodeId: string): string | undefined => {
718
- const node = nodeById.get(nodeId);
719
- if (!node) return undefined;
720
- return node.kind === 'Port' ? node.parentId : node.id;
721
- };
722
- const undirectedPairKey = (a: string, b: string): string => (a < b ? `${a}<->${b}` : `${b}<->${a}`);
723
- const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
860
+ const directedPairKey = (sourceId: string, targetId: string): string => `${sourceId}=>${targetId}`;
861
+ const undirectedPairKey = (leftId: string, rightId: string): string => (
862
+ leftId < rightId ? `${leftId}<=>${rightId}` : `${rightId}<=>${leftId}`
863
+ );
864
+ const edgeIdsByPair = new Map<string, string[]>();
724
865
  const undirectedEdgeIdsByPair = new Map<string, string[]>();
725
866
  for (const edge of graph.edges) {
726
- const sourceOwner = endpointOwnerId(edge.sourceId);
727
- const targetOwner = endpointOwnerId(edge.targetId);
728
- if (!sourceOwner || !targetOwner || sourceOwner === targetOwner) continue;
729
- const key = undirectedPairKey(sourceOwner, targetOwner);
730
- const ids = undirectedEdgeIdsByPair.get(key) ?? [];
867
+ if (!edge.sourceId || !edge.targetId) continue;
868
+ const key = directedPairKey(edge.sourceId, edge.targetId);
869
+ const ids = edgeIdsByPair.get(key) ?? [];
731
870
  ids.push(edge.id);
732
- undirectedEdgeIdsByPair.set(key, ids);
733
- }
734
- const fanningVertexForEdge = (edge: EdgeSpec): Array<{ x: number; y: number }> | undefined => {
735
- const sourceOwner = endpointOwnerId(edge.sourceId);
736
- const targetOwner = endpointOwnerId(edge.targetId);
737
- if (!sourceOwner || !targetOwner || sourceOwner === targetOwner) {
738
- return undefined;
739
- }
740
- const edgeIds = undirectedEdgeIdsByPair.get(undirectedPairKey(sourceOwner, targetOwner)) ?? [];
741
- if (edgeIds.length <= 1) {
871
+ edgeIdsByPair.set(key, ids);
872
+ const undirectedKey = undirectedPairKey(edge.sourceId, edge.targetId);
873
+ const pairIds = undirectedEdgeIdsByPair.get(undirectedKey) ?? [];
874
+ pairIds.push(edge.id);
875
+ undirectedEdgeIdsByPair.set(undirectedKey, pairIds);
876
+ }
877
+ const edgePointForEndpoint = (endpointId: string): { x: number; y: number } | undefined => {
878
+ const endpoint = nodeById.get(endpointId);
879
+ if (!endpoint) {
742
880
  return undefined;
743
881
  }
744
- const orderedEdgeIds = [...edgeIds].sort((leftId, rightId) => {
745
- const leftEdge = edgeById.get(leftId);
746
- const rightEdge = edgeById.get(rightId);
747
- if (!leftEdge || !rightEdge) return leftId.localeCompare(rightId);
748
- const leftSource = endpointOwnerId(leftEdge.sourceId);
749
- const leftTarget = endpointOwnerId(leftEdge.targetId);
750
- const rightSource = endpointOwnerId(rightEdge.sourceId);
751
- const rightTarget = endpointOwnerId(rightEdge.targetId);
752
- const leftForward = leftSource && leftTarget ? (leftSource < leftTarget ? 0 : 1) : 0;
753
- const rightForward = rightSource && rightTarget ? (rightSource < rightTarget ? 0 : 1) : 0;
754
- if (leftForward !== rightForward) return leftForward - rightForward;
755
- return leftId.localeCompare(rightId);
756
- });
757
- const [pairA, pairB] = sourceOwner < targetOwner
758
- ? [sourceOwner, targetOwner]
759
- : [targetOwner, sourceOwner];
760
- const forwardIds: string[] = [];
761
- const reverseIds: string[] = [];
762
- for (const edgeId of orderedEdgeIds) {
763
- const pairEdge = edgeById.get(edgeId);
764
- if (!pairEdge) continue;
765
- const pairSource = endpointOwnerId(pairEdge.sourceId);
766
- const pairTarget = endpointOwnerId(pairEdge.targetId);
767
- if (pairSource === pairA && pairTarget === pairB) {
768
- forwardIds.push(edgeId);
769
- } else if (pairSource === pairB && pairTarget === pairA) {
770
- reverseIds.push(edgeId);
882
+ if (endpoint.kind === 'Port' && endpoint.parentId) {
883
+ const ownerBox = layout.boxes.get(endpoint.parentId);
884
+ const placement = portPlacementById.get(endpoint.id);
885
+ if (!ownerBox || !placement) {
886
+ return undefined;
887
+ }
888
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
889
+ if (placement.side === 'left') {
890
+ return { x: ownerBox.x, y: ownerBox.y + (ownerBox.height * ratio) };
891
+ }
892
+ if (placement.side === 'right') {
893
+ return { x: ownerBox.x + ownerBox.width, y: ownerBox.y + (ownerBox.height * ratio) };
771
894
  }
895
+ if (placement.side === 'top') {
896
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y };
897
+ }
898
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y + ownerBox.height };
772
899
  }
773
- // Only fan when both directions exist for the same endpoint pair.
774
- if (forwardIds.length === 0 || reverseIds.length === 0) {
900
+ const box = layout.boxes.get(endpointId);
901
+ if (!box) {
775
902
  return undefined;
776
903
  }
777
-
778
- let directionSign = 0;
779
- let laneIndex = 0;
780
- let laneCount = 0;
781
- if (sourceOwner === pairA && targetOwner === pairB) {
782
- directionSign = 1;
783
- laneIndex = forwardIds.indexOf(edge.id);
784
- laneCount = forwardIds.length;
785
- } else if (sourceOwner === pairB && targetOwner === pairA) {
786
- directionSign = -1;
787
- laneIndex = reverseIds.indexOf(edge.id);
788
- laneCount = reverseIds.length;
904
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
905
+ };
906
+ const fanningVertexForEdge = (edge: EdgeSpec): Array<{ x: number; y: number }> | undefined => {
907
+ if (!edge.sourceId || !edge.targetId) {
908
+ return undefined;
789
909
  }
790
- if (directionSign === 0 || laneIndex < 0 || laneCount <= 0) {
910
+ const undirectedIds = undirectedEdgeIdsByPair.get(undirectedPairKey(edge.sourceId, edge.targetId)) ?? [];
911
+ if (undirectedIds.length <= 1) {
791
912
  return undefined;
792
913
  }
793
-
794
- const pairABox = layout.boxes.get(pairA);
795
- const pairBBox = layout.boxes.get(pairB);
796
- if (!pairABox || !pairBBox) {
914
+ const sourcePoint = edgePointForEndpoint(edge.sourceId);
915
+ const targetPoint = edgePointForEndpoint(edge.targetId);
916
+ if (!sourcePoint || !targetPoint) {
797
917
  return undefined;
798
918
  }
799
- const sx = pairABox.x + (pairABox.width / 2);
800
- const sy = pairABox.y + (pairABox.height / 2);
801
- const tx = pairBBox.x + (pairBBox.width / 2);
802
- const ty = pairBBox.y + (pairBBox.height / 2);
919
+ const sx = sourcePoint.x;
920
+ const sy = sourcePoint.y;
921
+ const tx = targetPoint.x;
922
+ const ty = targetPoint.y;
803
923
  const dx = tx - sx;
804
924
  const dy = ty - sy;
805
925
  const len = Math.hypot(dx, dy);
806
926
  if (!Number.isFinite(len) || len < 1) {
807
927
  return undefined;
808
928
  }
809
- const laneOffset = (laneIndex - ((laneCount - 1) / 2));
810
- const offset = (directionSign * 16) + (laneOffset * 10);
929
+ const forwardIds = (edgeIdsByPair.get(directedPairKey(edge.sourceId, edge.targetId)) ?? [])
930
+ .slice()
931
+ .sort((left, right) => left.localeCompare(right));
932
+ const reverseIds = (edgeIdsByPair.get(directedPairKey(edge.targetId, edge.sourceId)) ?? [])
933
+ .slice()
934
+ .sort((left, right) => left.localeCompare(right));
935
+ let offset = 0;
936
+ if (forwardIds.length > 0 && reverseIds.length > 0) {
937
+ const forwardIndex = forwardIds.indexOf(edge.id);
938
+ const reverseIndex = reverseIds.indexOf(edge.id);
939
+ if (forwardIndex >= 0) {
940
+ const laneOffset = forwardIndex - ((forwardIds.length - 1) / 2);
941
+ offset = 16 + (laneOffset * 10);
942
+ } else if (reverseIndex >= 0) {
943
+ const laneOffset = reverseIndex - ((reverseIds.length - 1) / 2);
944
+ offset = -16 + (laneOffset * 10);
945
+ } else {
946
+ return undefined;
947
+ }
948
+ } else {
949
+ const laneIndex = forwardIds.indexOf(edge.id);
950
+ if (laneIndex < 0 || forwardIds.length <= 1) {
951
+ return undefined;
952
+ }
953
+ const laneOffset = laneIndex - ((forwardIds.length - 1) / 2);
954
+ offset = laneOffset * 16;
955
+ }
811
956
  if (Math.abs(offset) < 0.01) {
812
957
  return undefined;
813
958
  }
@@ -844,8 +989,8 @@ async function renderWithX6(
844
989
  id: edge.id,
845
990
  source: resolveEndpoint(edge.sourceId),
846
991
  target: resolveEndpoint(edge.targetId),
847
- router: { name: 'normal' },
848
- connector: { name: 'rounded' },
992
+ router: resolveEdgeRouter(resolvedStyle),
993
+ connector: resolveEdgeConnector(resolvedStyle),
849
994
  attrs: {
850
995
  line: lineAttrs,
851
996
  },
@@ -862,7 +1007,7 @@ async function renderWithX6(
862
1007
  labelBody: resolveEdgeLabelBodyAttrs(resolvedStyle, label.placement),
863
1008
  },
864
1009
  })),
865
- zIndex: 5,
1010
+ zIndex: 50,
866
1011
  });
867
1012
  }
868
1013
 
@@ -902,49 +1047,113 @@ async function renderWithX6(
902
1047
  e.stopPropagation();
903
1048
  });
904
1049
 
905
- const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
1050
+ const sidePriority = (side: PortSide): number => {
1051
+ switch (side) {
1052
+ case 'left':
1053
+ return 0;
1054
+ case 'top':
1055
+ return 1;
1056
+ case 'right':
1057
+ return 2;
1058
+ case 'bottom':
1059
+ return 3;
1060
+ }
1061
+ };
1062
+ const projectPortPlacement = (node: any, clientX: number, clientY: number, preferredSide?: PortSide): PortPlacement | undefined => {
1063
+ if (!node || typeof node.size !== 'function') return undefined;
1064
+ const size = node.size();
1065
+ const position = typeof node.getPosition === 'function'
1066
+ ? node.getPosition()
1067
+ : { x: Number(node?.position?.x ?? 0), y: Number(node?.position?.y ?? 0) };
1068
+ const localPoint = typeof graphView.clientToGraph === 'function'
1069
+ ? graphView.clientToGraph(clientX, clientY)
1070
+ : { x: clientX, y: clientY };
1071
+ const localX = clamp(localPoint.x - position.x, 0, size.width);
1072
+ const localY = clamp(localPoint.y - position.y, 0, size.height);
1073
+ const candidates: Array<{ side: PortSide; distance: number }> = [
1074
+ { side: 'left', distance: localX },
1075
+ { side: 'right', distance: size.width - localX },
1076
+ { side: 'top', distance: localY },
1077
+ { side: 'bottom', distance: size.height - localY },
1078
+ ];
1079
+ candidates.sort((left, right) => left.distance - right.distance || sidePriority(left.side) - sidePriority(right.side));
1080
+ const closest = candidates[0];
1081
+ const preferredCandidate = preferredSide
1082
+ ? candidates.find((candidate) => candidate.side === preferredSide)
1083
+ : undefined;
1084
+ const hysteresis = 8;
1085
+ const side = preferredCandidate && preferredSide && (preferredCandidate.distance - closest.distance) <= hysteresis
1086
+ ? preferredSide
1087
+ : closest.side;
1088
+ const ratio = side === 'left' || side === 'right'
1089
+ ? (size.height > 0 ? localY / size.height : 0.5)
1090
+ : (size.width > 0 ? localX / size.width : 0.5);
1091
+ return {
1092
+ side,
1093
+ ratio: Math.max(0.05, Math.min(0.95, ratio)),
1094
+ };
1095
+ };
906
1096
  const onPointerMove = (event: PointerEvent): void => {
907
- if (!activePortDrag) return;
1097
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId) return;
908
1098
  const node = graphView.getCellById(activePortDrag.nodeId);
909
1099
  if (!node || typeof node.size !== 'function' || typeof node.getPort !== 'function' || typeof node.setPortProp !== 'function') return;
1100
+ const placement = projectPortPlacement(node, event.clientX, event.clientY, activePortDrag.side);
1101
+ if (!placement) return;
1102
+ if (placement.side === activePortDrag.side && Math.abs(placement.ratio - activePortDrag.ratio) < 0.005) {
1103
+ return;
1104
+ }
910
1105
  const size = node.size();
911
- const deltaY = event.clientY - activePortDrag.startClientY;
912
- const nextY = clamp(activePortDrag.startPortY + deltaY, 4, Math.max(4, size.height - 4));
1106
+ const nextX = placement.side === 'left'
1107
+ ? 0
1108
+ : placement.side === 'right'
1109
+ ? size.width
1110
+ : placement.ratio * size.width;
1111
+ const nextY = placement.side === 'top'
1112
+ ? 0
1113
+ : placement.side === 'bottom'
1114
+ ? size.height
1115
+ : placement.ratio * size.height;
1116
+ node.setPortProp(activePortDrag.portId, 'args/x', nextX);
913
1117
  node.setPortProp(activePortDrag.portId, 'args/y', nextY);
1118
+ node.setPortProp(activePortDrag.portId, 'args/side', placement.side);
1119
+ node.setPortProp(activePortDrag.portId, 'args/ratio', placement.ratio);
1120
+ if (placement.side !== activePortDrag.side) {
1121
+ node.setPortProp(activePortDrag.portId, 'label/position', resolveBorderPortLabelPosition(placement.side));
1122
+ }
1123
+ activePortDrag.side = placement.side;
1124
+ activePortDrag.ratio = placement.ratio;
1125
+ portPlacementById.set(activePortDrag.portId, placement);
914
1126
  };
915
- const onPointerUp = (): void => {
1127
+ const onPointerUp = (event: PointerEvent): void => {
1128
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId) return;
1129
+ if (typeof activePortDrag.container.releasePointerCapture === 'function') {
1130
+ try {
1131
+ activePortDrag.container.releasePointerCapture(activePortDrag.pointerId);
1132
+ } catch {
1133
+ // Ignore capture release failures.
1134
+ }
1135
+ }
916
1136
  activePortDrag = undefined;
917
1137
  };
918
1138
  window.addEventListener('pointermove', onPointerMove);
919
1139
  window.addEventListener('pointerup', onPointerUp);
920
- const startPortDrag = (clientY: number, node: any, portId: string): void => {
1140
+ const startPortDrag = (clientX: number, clientY: number, node: any, portId: string, pointerId: number, container: Element): void => {
921
1141
  if (!node || typeof node.getPort !== 'function') return;
922
- const existing = node.getPort(portId);
923
- const startPortY = typeof existing?.args?.y === 'number' ? existing.args.y : (node.size().height / 2);
1142
+ const placement = projectPortPlacement(node, clientX, clientY, portPlacementById.get(portId)?.side ?? 'right')
1143
+ ?? portPlacementById.get(portId)
1144
+ ?? { side: 'right' as PortSide, ratio: 0.5 };
924
1145
  activePortDrag = {
925
1146
  nodeId: String(node.id),
926
1147
  portId: String(portId),
927
- startClientY: clientY,
928
- startPortY,
1148
+ pointerId,
1149
+ side: placement.side,
1150
+ ratio: placement.ratio,
1151
+ container,
929
1152
  };
930
1153
  };
931
- const onCanvasPointerDown = (event: PointerEvent): void => {
932
- if (!isPortPointerTarget(event)) return;
933
- const portId = findPortIdFromTarget(event.target);
934
- if (!portId) return;
935
- const ownerId = ownerByPortId.get(portId);
936
- if (!ownerId) return;
937
- const node = graphView.getCellById(ownerId);
938
- startPortDrag(event.clientY, node, portId);
939
- event.preventDefault();
940
- event.stopPropagation();
941
- if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
942
- };
943
- liveCanvas.addEventListener('pointerdown', onCanvasPointerDown, true);
944
1154
  liveCanvas.addEventListener('DOMNodeRemovedFromDocument', () => {
945
1155
  window.removeEventListener('pointermove', onPointerMove);
946
1156
  window.removeEventListener('pointerup', onPointerUp);
947
- liveCanvas.removeEventListener('pointerdown', onCanvasPointerDown, true);
948
1157
  }, { once: true });
949
1158
 
950
1159
  const resultContainer = liveCanvas.closest('.oml-md-result');
@@ -1348,7 +1557,8 @@ async function loadDagreLib(): Promise<any> {
1348
1557
 
1349
1558
  async function layoutGraphDagre(
1350
1559
  graph: { nodes: NodeSpec[]; edges: EdgeSpec[]; roots: string[] },
1351
- options: DagreLayoutOptions
1560
+ options: DagreLayoutOptions,
1561
+ rootSpacing: BoxSpacing
1352
1562
  ): Promise<NativeLayoutResult> {
1353
1563
  const dagre = await loadDagreLib();
1354
1564
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
@@ -1385,8 +1595,6 @@ async function layoutGraphDagre(
1385
1595
  stack: {
1386
1596
  direction: styleLayout.direction === 'horizontal' ? 'horizontal' : 'vertical',
1387
1597
  gap: clampLayoutNumber(styleLayout.gap, 0, 400, 0),
1388
- marginx: clampLayoutNumber(styleLayout.marginx, 0, 200, 0),
1389
- marginy: clampLayoutNumber(styleLayout.marginy, 0, 200, 0),
1390
1598
  stretch: typeof styleLayout.stretch === 'boolean' ? styleLayout.stretch : true,
1391
1599
  },
1392
1600
  };
@@ -1406,29 +1614,40 @@ async function layoutGraphDagre(
1406
1614
  rankdir,
1407
1615
  nodesep: clampLayoutNumber(styleLayout.nodesep, 0, 400, options.nodesep),
1408
1616
  ranksep: clampLayoutNumber(styleLayout.ranksep, 0, 500, options.ranksep),
1409
- marginx: clampLayoutNumber(styleLayout.marginx, 0, 200, options.marginx),
1410
- marginy: clampLayoutNumber(styleLayout.marginy, 0, 200, options.marginy),
1411
1617
  },
1412
1618
  };
1413
1619
  };
1414
1620
 
1415
1621
  const childPositionById = new Map<string, { x: number; y: number }>();
1622
+ const branchChildForParent = (parentId: string | undefined, nodeId: string): string | undefined => {
1623
+ let cursorId: string | undefined = nodeId;
1624
+ while (cursorId) {
1625
+ const node = nodeById.get(cursorId);
1626
+ if (!node) {
1627
+ return undefined;
1628
+ }
1629
+ if (node.parentId === parentId) {
1630
+ return node.id;
1631
+ }
1632
+ cursorId = node.parentId;
1633
+ }
1634
+ return undefined;
1635
+ };
1416
1636
  const edgesBetweenChildren = (parentId: string | undefined, childSet: Set<string>): Array<{ source: string; target: string }> => {
1417
1637
  const seen = new Set<string>();
1418
1638
  const links: Array<{ source: string; target: string }> = [];
1419
1639
  for (const edge of graph.edges) {
1420
1640
  const sourceId = endpointOwner(edge.sourceId);
1421
1641
  const targetId = endpointOwner(edge.targetId);
1422
- if (!sourceId || !targetId || sourceId === targetId) continue;
1423
- if (!childSet.has(sourceId) || !childSet.has(targetId)) continue;
1424
- const sourceNode = nodeById.get(sourceId);
1425
- const targetNode = nodeById.get(targetId);
1426
- if (!sourceNode || !targetNode) continue;
1427
- if (sourceNode.parentId !== parentId || targetNode.parentId !== parentId) continue;
1428
- const key = `${sourceId}=>${targetId}`;
1642
+ if (!sourceId || !targetId) continue;
1643
+ const sourceBranchId = branchChildForParent(parentId, sourceId);
1644
+ const targetBranchId = branchChildForParent(parentId, targetId);
1645
+ if (!sourceBranchId || !targetBranchId || sourceBranchId === targetBranchId) continue;
1646
+ if (!childSet.has(sourceBranchId) || !childSet.has(targetBranchId)) continue;
1647
+ const key = `${sourceBranchId}=>${targetBranchId}`;
1429
1648
  if (seen.has(key)) continue;
1430
1649
  seen.add(key);
1431
- links.push({ source: sourceId, target: targetId });
1650
+ links.push({ source: sourceBranchId, target: targetBranchId });
1432
1651
  }
1433
1652
  return links;
1434
1653
  };
@@ -1443,8 +1662,8 @@ async function layoutGraphDagre(
1443
1662
  }
1444
1663
 
1445
1664
  const parent = parentId ? nodeById.get(parentId) : undefined;
1446
- const topPadding = parent?.contentTopPadding ?? 0;
1447
1665
  const localLayout = layoutOptionsForParent(parentId);
1666
+ const spacing = parent ? resolveBoxSpacing(parent.style, 0) : rootSpacing;
1448
1667
  let totalWidth = 0;
1449
1668
  let totalHeight = 0;
1450
1669
  if (localLayout.type === 'stack') {
@@ -1452,13 +1671,13 @@ async function layoutGraphDagre(
1452
1671
  const childNodes = childIds.map((childId) => nodeById.get(childId)).filter((node): node is NodeSpec => !!node);
1453
1672
  const maxChildWidth = childNodes.reduce((max, child) => Math.max(max, child.width), 0);
1454
1673
  const maxChildHeight = childNodes.reduce((max, child) => Math.max(max, child.height), 0);
1455
- const parentContentWidth = Math.max(0, (parent?.width ?? 0) - (local.marginx * 2));
1456
- const parentContentHeight = Math.max(0, (parent?.height ?? 0) - topPadding - (local.marginy * 2));
1674
+ const parentContentWidth = Math.max(0, (parent?.width ?? 0) - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1675
+ const parentContentHeight = Math.max(0, (parent?.height ?? 0) - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1457
1676
  const availableWidth = Math.max(maxChildWidth, parentContentWidth);
1458
1677
  const availableHeight = Math.max(maxChildHeight, parentContentHeight);
1459
1678
  const isSingleChild = childNodes.length === 1;
1460
- let cursorX = local.marginx;
1461
- let cursorY = topPadding + local.marginy;
1679
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1680
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1462
1681
  for (const child of childNodes) {
1463
1682
  if (local.stretch && isSingleChild) {
1464
1683
  child.width = Math.max(0, availableWidth);
@@ -1481,16 +1700,16 @@ async function layoutGraphDagre(
1481
1700
  childNodes.reduce((sum, child) => sum + child.height, 0) + (Math.max(0, childNodes.length - 1) * local.gap)
1482
1701
  );
1483
1702
  const contentWidth = Math.max(0, childNodes.reduce((max, child) => Math.max(max, child.width), 0));
1484
- totalWidth = (local.marginx * 2) + contentWidth;
1485
- totalHeight = topPadding + (local.marginy * 2) + contentHeight;
1703
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1704
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1486
1705
  } else {
1487
1706
  const contentWidth = Math.max(
1488
1707
  0,
1489
1708
  childNodes.reduce((sum, child) => sum + child.width, 0) + (Math.max(0, childNodes.length - 1) * local.gap)
1490
1709
  );
1491
1710
  const contentHeight = Math.max(0, childNodes.reduce((max, child) => Math.max(max, child.height), 0));
1492
- totalWidth = (local.marginx * 2) + contentWidth;
1493
- totalHeight = topPadding + (local.marginy * 2) + contentHeight;
1711
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1712
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1494
1713
  }
1495
1714
  } else {
1496
1715
  const localOptions = localLayout.dagre;
@@ -1546,23 +1765,19 @@ async function layoutGraphDagre(
1546
1765
  const left = laid.x - (width / 2);
1547
1766
  const top = laid.y - (height / 2);
1548
1767
  childPositionById.set(childId, {
1549
- x: localOptions.marginx + (left - minX),
1550
- y: topPadding + localOptions.marginy + (top - minY),
1768
+ x: spacing.marginLeft + spacing.paddingLeft + (left - minX),
1769
+ y: spacing.marginTop + spacing.paddingTop + (top - minY),
1551
1770
  });
1552
1771
  }
1553
1772
 
1554
1773
  const contentWidth = Math.max(0, maxX - minX);
1555
1774
  const contentHeight = Math.max(0, maxY - minY);
1556
- totalWidth = (localOptions.marginx * 2) + contentWidth;
1557
- totalHeight = topPadding + (localOptions.marginy * 2) + contentHeight;
1775
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1776
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1558
1777
  }
1559
1778
  if (parent) {
1560
- if (!parent.hasExplicitWidth) {
1561
- parent.width = Math.max(parent.width, totalWidth);
1562
- }
1563
- if (!parent.hasExplicitHeight) {
1564
- parent.height = Math.max(parent.height, totalHeight);
1565
- }
1779
+ parent.width = Math.max(parent.width, totalWidth);
1780
+ parent.height = Math.max(parent.height, totalHeight);
1566
1781
  }
1567
1782
  return { width: totalWidth, height: totalHeight };
1568
1783
  };
@@ -1582,9 +1797,9 @@ async function layoutGraphDagre(
1582
1797
  if (localLayout.type === 'stack') {
1583
1798
  const local = localLayout.stack;
1584
1799
  const childNodes = childIds.map((childId) => nodeById.get(childId)).filter((node): node is NodeSpec => !!node);
1585
- const topPadding = parent.contentTopPadding;
1586
- const contentWidth = Math.max(0, parent.width - (local.marginx * 2));
1587
- const contentHeight = Math.max(0, parent.height - topPadding - (local.marginy * 2));
1800
+ const spacing = resolveBoxSpacing(parent.style, 0);
1801
+ const contentWidth = Math.max(0, parent.width - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1802
+ const contentHeight = Math.max(0, parent.height - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1588
1803
  const isSingleChild = childNodes.length === 1;
1589
1804
 
1590
1805
  if (local.stretch) {
@@ -1602,8 +1817,8 @@ async function layoutGraphDagre(
1602
1817
  }
1603
1818
  }
1604
1819
 
1605
- let cursorX = local.marginx;
1606
- let cursorY = topPadding + local.marginy;
1820
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1821
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1607
1822
  for (const child of childNodes) {
1608
1823
  childPositionById.set(child.id, { x: cursorX, y: cursorY });
1609
1824
  if (local.direction === 'vertical') {
@@ -1819,9 +2034,6 @@ function compileDiagramGraph(
1819
2034
  children: [],
1820
2035
  width: 160,
1821
2036
  height: 70,
1822
- hasExplicitWidth: false,
1823
- hasExplicitHeight: false,
1824
- contentTopPadding: 0,
1825
2037
  });
1826
2038
  }
1827
2039
 
@@ -1899,9 +2111,6 @@ function compileDiagramGraph(
1899
2111
  const estimated = estimateSize(node.kind, baseLabel, node.labels.length, node.style);
1900
2112
  node.width = estimated.width;
1901
2113
  node.height = estimated.height;
1902
- node.hasExplicitWidth = estimated.hasExplicitWidth;
1903
- node.hasExplicitHeight = estimated.hasExplicitHeight;
1904
- node.contentTopPadding = resolveContainerTopPadding(node, baseLabel);
1905
2114
  }
1906
2115
  for (const edge of edges) {
1907
2116
  edge.style = styleFor('edge', edge.id, edge.classes, edge.properties);
@@ -1930,68 +2139,46 @@ function estimateSize(
1930
2139
  label: string,
1931
2140
  labelCount: number,
1932
2141
  style: Record<string, unknown>
1933
- ): { width: number; height: number; hasExplicitWidth: boolean; hasExplicitHeight: boolean } {
2142
+ ): { width: number; height: number } {
1934
2143
  const styledWidth = toPositiveNumber(style.width);
1935
2144
  const styledHeight = toPositiveNumber(style.height);
1936
- if (styledWidth && styledHeight) {
1937
- return { width: styledWidth, height: styledHeight, hasExplicitWidth: true, hasExplicitHeight: true };
1938
- }
2145
+ const attrs = extractStyleAttrs(style);
2146
+ const labelAttrs = asRecord(attrs.label);
2147
+ const labelDisplay = typeof labelAttrs?.display === 'string' ? labelAttrs.display.trim().toLowerCase() : '';
2148
+ const labelOpacity = toNonNegativeNumber(labelAttrs?.opacity);
2149
+ const labelVisible = labelDisplay !== 'none' && (labelOpacity === undefined || labelOpacity > 0);
2150
+ const effectiveLabel = labelVisible ? label : '';
2151
+ const effectiveLabelCount = labelVisible ? labelCount : 0;
1939
2152
  if (kind === 'Port') {
1940
2153
  return {
1941
- width: styledWidth ?? 14,
1942
- height: styledHeight ?? 14,
1943
- hasExplicitWidth: styledWidth !== undefined,
1944
- hasExplicitHeight: styledHeight !== undefined,
2154
+ width: Math.max(14, styledWidth ?? 0),
2155
+ height: Math.max(14, styledHeight ?? 0),
1945
2156
  };
1946
2157
  }
1947
- const textWidth = Math.max(24, label.length * 7);
1948
- const baseWidth = Math.max(72, Math.min(280, textWidth + 26));
2158
+ const textWidth = effectiveLabel.length > 0 ? Math.max(24, effectiveLabel.length * 7) : 0;
2159
+ const baseWidth = effectiveLabel.length > 0 ? Math.max(72, Math.min(280, textWidth + 26)) : 0;
1949
2160
  if (kind === 'Compartment') {
1950
- const size = { width: baseWidth, height: Math.max(44, 24 + labelCount * 16) };
2161
+ const size = {
2162
+ width: baseWidth,
2163
+ height: effectiveLabelCount > 0 ? Math.max(44, 24 + effectiveLabelCount * 16) : 0,
2164
+ };
1951
2165
  return {
1952
- width: styledWidth ?? size.width,
1953
- height: styledHeight ?? size.height,
1954
- hasExplicitWidth: styledWidth !== undefined,
1955
- hasExplicitHeight: styledHeight !== undefined,
2166
+ width: Math.max(size.width, styledWidth ?? 0),
2167
+ height: Math.max(size.height, styledHeight ?? 0),
1956
2168
  };
1957
2169
  }
1958
- const size = { width: baseWidth, height: Math.max(36, 20 + labelCount * 16) };
2170
+ const size = {
2171
+ width: baseWidth,
2172
+ height: effectiveLabelCount > 0 ? Math.max(36, 20 + effectiveLabelCount * 16) : 0,
2173
+ };
1959
2174
  return {
1960
- width: styledWidth ?? size.width,
1961
- height: styledHeight ?? size.height,
1962
- hasExplicitWidth: styledWidth !== undefined,
1963
- hasExplicitHeight: styledHeight !== undefined,
2175
+ width: Math.max(size.width, styledWidth ?? 0),
2176
+ height: Math.max(size.height, styledHeight ?? 0),
1964
2177
  };
1965
2178
  }
1966
2179
 
1967
- function resolveContainerTopPadding(node: NodeSpec, labelText: string): number {
1968
- if (node.children.length === 0) {
1969
- return 0;
1970
- }
1971
- const explicitPadding = toNonNegativeNumber(node.style.paddingTop);
1972
- if (explicitPadding !== undefined) {
1973
- return Math.ceil(explicitPadding);
1974
- }
1975
- const attrs = extractStyleAttrs(node.style);
1976
- const label = asRecord(attrs.label);
1977
- if (label?.display === 'none') {
1978
- return 0;
1979
- }
1980
- const labelOpacity = toNonNegativeNumber(label?.opacity);
1981
- if (labelOpacity !== undefined && labelOpacity <= 0) {
1982
- return 0;
1983
- }
1984
- const fontSize = toPositiveNumber(label?.fontSize) ?? 12;
1985
- const lineCount = Math.max(1, labelText.split('\n').filter((line) => line.trim().length > 0).length);
1986
- return Math.ceil((fontSize * 1.2) * lineCount);
1987
- }
1988
-
1989
2180
  function localName(value: string): string {
1990
- const hash = value.lastIndexOf('#');
1991
- if (hash >= 0 && hash < value.length - 1) return value.slice(hash + 1);
1992
- const slash = value.lastIndexOf('/');
1993
- if (slash >= 0 && slash < value.length - 1) return value.slice(slash + 1);
1994
- return value;
2181
+ return displayLabelFromIri(value);
1995
2182
  }
1996
2183
 
1997
2184
  function indexTriples(rows: TripleRow[]): TripleIndex {
@@ -2046,9 +2233,7 @@ function resolveDagreLayoutOptions(options: Record<string, unknown> | undefined)
2046
2233
 
2047
2234
  const nodesep = clampLayoutNumber(layout.nodesep, 0, 400, 28);
2048
2235
  const ranksep = clampLayoutNumber(layout.ranksep, 0, 500, 64);
2049
- const marginx = clampLayoutNumber(layout.marginx, 0, 200, 16);
2050
- const marginy = clampLayoutNumber(layout.marginy, 0, 200, 16);
2051
- return { rankdir, nodesep, ranksep, marginx, marginy };
2236
+ return { rankdir, nodesep, ranksep };
2052
2237
  }
2053
2238
 
2054
2239
  function clampLayoutNumber(value: unknown, min: number, max: number, fallback: number): number {
@@ -2058,6 +2243,129 @@ function clampLayoutNumber(value: unknown, min: number, max: number, fallback: n
2058
2243
  return Math.max(min, Math.min(max, Math.round(value)));
2059
2244
  }
2060
2245
 
2246
+ function resolveRootSpacing(options: Record<string, unknown> | undefined): BoxSpacing {
2247
+ return readBoxSpacing(options, {
2248
+ marginTop: 16,
2249
+ marginBottom: 16,
2250
+ marginLeft: 16,
2251
+ marginRight: 16,
2252
+ paddingTop: 0,
2253
+ paddingBottom: 0,
2254
+ paddingLeft: 0,
2255
+ paddingRight: 0,
2256
+ });
2257
+ }
2258
+
2259
+ function resolveBoxSpacing(style: Record<string, unknown> | undefined, fallbackMargin: number): BoxSpacing {
2260
+ return readBoxSpacing(style, {
2261
+ marginTop: fallbackMargin,
2262
+ marginBottom: fallbackMargin,
2263
+ marginLeft: fallbackMargin,
2264
+ marginRight: fallbackMargin,
2265
+ paddingTop: 0,
2266
+ paddingBottom: 0,
2267
+ paddingLeft: 0,
2268
+ paddingRight: 0,
2269
+ });
2270
+ }
2271
+
2272
+ function readBoxSpacing(style: Record<string, unknown> | undefined, defaults: BoxSpacing): BoxSpacing {
2273
+ const source = style ?? {};
2274
+ return {
2275
+ ...readCssBoxSpacing(source.margin, 'margin', defaults),
2276
+ ...readCssBoxSpacing(source.padding, 'padding', defaults),
2277
+ };
2278
+ }
2279
+
2280
+ function readCssBoxSpacing(
2281
+ value: unknown,
2282
+ kind: 'margin' | 'padding',
2283
+ defaults: BoxSpacing
2284
+ ): Pick<BoxSpacing, 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft'> {
2285
+ const sides = parseCssBoxShorthand(value);
2286
+ const isMargin = kind === 'margin';
2287
+ if (isMargin) {
2288
+ return {
2289
+ marginTop: sides?.top ?? defaults.marginTop,
2290
+ marginRight: sides?.right ?? defaults.marginRight,
2291
+ marginBottom: sides?.bottom ?? defaults.marginBottom,
2292
+ marginLeft: sides?.left ?? defaults.marginLeft,
2293
+ paddingTop: defaults.paddingTop,
2294
+ paddingRight: defaults.paddingRight,
2295
+ paddingBottom: defaults.paddingBottom,
2296
+ paddingLeft: defaults.paddingLeft,
2297
+ };
2298
+ }
2299
+ return {
2300
+ marginTop: defaults.marginTop,
2301
+ marginRight: defaults.marginRight,
2302
+ marginBottom: defaults.marginBottom,
2303
+ marginLeft: defaults.marginLeft,
2304
+ paddingTop: sides?.top ?? defaults.paddingTop,
2305
+ paddingRight: sides?.right ?? defaults.paddingRight,
2306
+ paddingBottom: sides?.bottom ?? defaults.paddingBottom,
2307
+ paddingLeft: sides?.left ?? defaults.paddingLeft,
2308
+ };
2309
+ }
2310
+
2311
+ function parseCssBoxShorthand(value: unknown): { top?: number; right?: number; bottom?: number; left?: number } | undefined {
2312
+ const values = Array.isArray(value)
2313
+ ? value
2314
+ : typeof value === 'string'
2315
+ ? (value.includes(',') ? undefined : value.trim().split(/\s+/).filter((token) => token.length > 0))
2316
+ : typeof value === 'number'
2317
+ ? [value]
2318
+ : undefined;
2319
+ if (!values || values.length === 0 || values.length > 4) {
2320
+ return undefined;
2321
+ }
2322
+
2323
+ const parsed = values.map((entry) => {
2324
+ if (typeof entry === 'number' && Number.isFinite(entry)) {
2325
+ return entry;
2326
+ }
2327
+ if (typeof entry === 'string') {
2328
+ const next = Number.parseFloat(entry);
2329
+ if (Number.isFinite(next)) {
2330
+ return next;
2331
+ }
2332
+ }
2333
+ return undefined;
2334
+ });
2335
+ if (parsed.some((entry) => entry === undefined)) {
2336
+ return undefined;
2337
+ }
2338
+
2339
+ const clampCssBoxValue = (entry: number | undefined): number | undefined => {
2340
+ if (entry === undefined) return undefined;
2341
+ return Math.max(0, Math.min(200, Math.round(entry)));
2342
+ };
2343
+
2344
+ if (parsed.length === 1) {
2345
+ const single = clampCssBoxValue(parsed[0]);
2346
+ return single === undefined ? undefined : { top: single, right: single, bottom: single, left: single };
2347
+ }
2348
+ if (parsed.length === 2) {
2349
+ const vertical = clampCssBoxValue(parsed[0]);
2350
+ const horizontal = clampCssBoxValue(parsed[1]);
2351
+ if (vertical === undefined || horizontal === undefined) return undefined;
2352
+ return { top: vertical, right: horizontal, bottom: vertical, left: horizontal };
2353
+ }
2354
+ if (parsed.length === 3) {
2355
+ const top = clampCssBoxValue(parsed[0]);
2356
+ const horizontal = clampCssBoxValue(parsed[1]);
2357
+ const bottom = clampCssBoxValue(parsed[2]);
2358
+ if (top === undefined || horizontal === undefined || bottom === undefined) return undefined;
2359
+ return { top, right: horizontal, bottom, left: horizontal };
2360
+ }
2361
+ const top = clampCssBoxValue(parsed[0]);
2362
+ const right = clampCssBoxValue(parsed[1]);
2363
+ const bottom = clampCssBoxValue(parsed[2]);
2364
+ const left = clampCssBoxValue(parsed[3]);
2365
+ if (top === undefined || right === undefined || bottom === undefined || left === undefined) return undefined;
2366
+ return { top, right, bottom, left };
2367
+ }
2368
+
2061
2369
  function parseDiagramStylesheet(options: Record<string, unknown> | undefined): DiagramStyleRule[] {
2062
2370
  const stylesheet = options?.stylesheet;
2063
2371
  if (!Array.isArray(stylesheet)) {
@@ -2083,11 +2391,11 @@ function parseDiagramStylesheet(options: Record<string, unknown> | undefined): D
2083
2391
 
2084
2392
  function parseStyleSelector(
2085
2393
  selector: string
2086
- ): { elementKind: 'node' | 'compartment' | 'port' | 'edge'; className?: string; condition?: string } | undefined {
2087
- const match = /^\s*(node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
2394
+ ): { elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge'; className?: string; condition?: string } | undefined {
2395
+ const match = /^\s*(diagram|node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
2088
2396
  if (!match) return undefined;
2089
2397
  return {
2090
- elementKind: match[1].toLowerCase() as 'node' | 'compartment' | 'port' | 'edge',
2398
+ elementKind: match[1].toLowerCase() as 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2091
2399
  className: match[2]?.trim() || undefined,
2092
2400
  condition: match[3]?.trim() || undefined,
2093
2401
  };
@@ -2097,7 +2405,14 @@ function parseStyleSelector(
2097
2405
  // These pass through as top-level keys so callers can read style.layout, style.width, etc.
2098
2406
  const OML_PASSTHROUGH_STYLE_KEYS = new Set([
2099
2407
  'layout', 'shape', 'width', 'height',
2408
+ 'margin', 'padding', 'router', 'connector',
2409
+ ]);
2410
+
2411
+ const LEGACY_BOX_SPACING_KEYS = new Set([
2412
+ 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
2100
2413
  'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
2414
+ 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
2415
+ 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
2101
2416
  ]);
2102
2417
 
2103
2418
  // Record-valued top-level style keys that map directly into the attrs sub-tree under the same name.
@@ -2111,7 +2426,7 @@ const ATTRS_RECORD_STYLE_KEYS = new Set([
2111
2426
  ]);
2112
2427
 
2113
2428
  function normalizeDiagramStyle(
2114
- elementKind: 'node' | 'compartment' | 'port' | 'edge',
2429
+ elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2115
2430
  raw: Record<string, unknown>
2116
2431
  ): Record<string, unknown> {
2117
2432
  const isEdge = elementKind === 'edge';
@@ -2126,6 +2441,10 @@ function normalizeDiagramStyle(
2126
2441
  const key = rawKey.trim();
2127
2442
  if (!key || value === undefined || value === null) continue;
2128
2443
 
2444
+ if (LEGACY_BOX_SPACING_KEYS.has(key)) {
2445
+ continue;
2446
+ }
2447
+
2129
2448
  // OML node-level keys: pass through to the top level of the normalized style object
2130
2449
  if (OML_PASSTHROUGH_STYLE_KEYS.has(key)) { passthrough[key] = value; continue; }
2131
2450
 
@@ -2176,7 +2495,7 @@ function normalizeDiagramStyle(
2176
2495
  }
2177
2496
 
2178
2497
  function resolveElementStyle(
2179
- elementKind: 'node' | 'compartment' | 'port' | 'edge',
2498
+ elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2180
2499
  value: string,
2181
2500
  classes: string[],
2182
2501
  properties: Record<string, string[]>,
@@ -2414,6 +2733,19 @@ function resolveEdgeLineAttrs(style: Record<string, unknown>): Record<string, un
2414
2733
  };
2415
2734
  }
2416
2735
 
2736
+ function resolveEdgeRouter(style: Record<string, unknown>): Record<string, unknown> {
2737
+ const router = asRecord(style.router);
2738
+ return router ?? { name: 'normal' };
2739
+ }
2740
+
2741
+ function resolveEdgeConnector(style: Record<string, unknown>): Record<string, unknown> {
2742
+ const connector = asRecord(style.connector);
2743
+ return connector ?? {
2744
+ name: 'jumpover',
2745
+ args: { size: 5 },
2746
+ };
2747
+ }
2748
+
2417
2749
  function resolveEdgeLabelAttrs(
2418
2750
  style: Record<string, unknown>,
2419
2751
  placement: EdgeLabel['placement'],
@@ -2444,19 +2776,49 @@ function resolveEdgeLabelBodyAttrs(
2444
2776
  fillOpacity: 0.9,
2445
2777
  stroke: 'none',
2446
2778
  strokeWidth: 0,
2779
+ pointerEvents: 'all',
2447
2780
  ...(base ?? {}),
2448
2781
  ...(specific ?? {}),
2449
2782
  };
2450
2783
  }
2451
2784
 
2452
- function resolvePortAttrs(style: Record<string, unknown>, classes: string[], text: string): Record<string, unknown> {
2785
+ function resolvePortAttrs(
2786
+ style: Record<string, unknown>,
2787
+ classes: string[],
2788
+ text: string | undefined,
2789
+ side: PortSide = 'right',
2790
+ ownerStroke?: string
2791
+ ): Record<string, unknown> {
2453
2792
  const attrs = extractStyleAttrs(style);
2454
2793
  const body = asRecord(attrs.body);
2455
2794
  const icon = asRecord(attrs.icon);
2456
2795
  const label = asRecord(attrs.label);
2457
2796
  const imageUrl = extractImageHrefFromIcon(icon);
2797
+ const labelPosition = side === 'left'
2798
+ ? {
2799
+ textAnchor: 'end',
2800
+ x: -10,
2801
+ dy: '0.9em',
2802
+ }
2803
+ : side === 'top'
2804
+ ? {
2805
+ textAnchor: 'middle',
2806
+ x: 0,
2807
+ dy: '-0.3em',
2808
+ }
2809
+ : side === 'bottom'
2810
+ ? {
2811
+ textAnchor: 'middle',
2812
+ x: 0,
2813
+ dy: '1.4em',
2814
+ }
2815
+ : {
2816
+ textAnchor: 'start',
2817
+ x: 10,
2818
+ dy: '0.9em',
2819
+ };
2458
2820
 
2459
- return {
2821
+ const result: Record<string, unknown> = {
2460
2822
  body: {
2461
2823
  width: 12,
2462
2824
  height: 12,
@@ -2464,7 +2826,7 @@ function resolvePortAttrs(style: Record<string, unknown>, classes: string[], tex
2464
2826
  y: -6,
2465
2827
  class: ['oml-port-body', ...classes].join(' '),
2466
2828
  magnet: false,
2467
- stroke: CSS_FOCUS_BORDER,
2829
+ stroke: ownerStroke ?? CSS_EDITOR_FOREGROUND,
2468
2830
  strokeWidth: 1,
2469
2831
  fill: CSS_EDITOR_BACKGROUND,
2470
2832
  ...(body ?? {}),
@@ -2479,17 +2841,196 @@ function resolvePortAttrs(style: Record<string, unknown>, classes: string[], tex
2479
2841
  ...(icon ?? {}),
2480
2842
  ...(imageUrl ? { href: imageUrl, xlinkHref: imageUrl, 'xlink:href': imageUrl } : {}),
2481
2843
  },
2482
- label: {
2844
+ };
2845
+ if (text) {
2846
+ result.label = {
2483
2847
  text,
2484
2848
  fill: CSS_EDITOR_FOREGROUND,
2485
2849
  fontFamily: 'var(--vscode-editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
2486
2850
  fontSize: 12,
2487
- textAnchor: 'start',
2488
- x: 10,
2489
- dy: '0.9em',
2851
+ ...labelPosition,
2490
2852
  ...(label ?? {}),
2491
- },
2853
+ };
2854
+ }
2855
+ return result;
2856
+ }
2857
+
2858
+ function resolveBorderPortLabelPosition(side: PortSide): { name: PortSide } {
2859
+ return { name: side };
2860
+ }
2861
+
2862
+ function clamp(value: number, min: number, max: number): number {
2863
+ return Math.max(min, Math.min(max, value));
2864
+ }
2865
+
2866
+ function computeDefaultPortPlacements(
2867
+ graph: { nodes: NodeSpec[]; edges: EdgeSpec[]; roots: string[] },
2868
+ nodeById: Map<string, NodeSpec>,
2869
+ portsByOwner: Map<string, NodeSpec[]>,
2870
+ boxes: Map<string, NodeBox>
2871
+ ): Map<string, PortPlacement> {
2872
+ const placements = new Map<string, PortPlacement>();
2873
+ const ownerCenter = (nodeId: string): { x: number; y: number } | undefined => {
2874
+ const box = boxes.get(nodeId);
2875
+ if (!box) {
2876
+ return undefined;
2877
+ }
2878
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
2879
+ };
2880
+ const portIdsByOwner = new Map<string, Set<string>>();
2881
+ for (const [ownerId, ports] of portsByOwner.entries()) {
2882
+ portIdsByOwner.set(ownerId, new Set(ports.map((port) => port.id)));
2883
+ }
2884
+ const peerPortIdsByPort = new Map<string, string[]>();
2885
+ const peerCentersByPort = new Map<string, Array<{ x: number; y: number }>>();
2886
+ for (const edge of graph.edges) {
2887
+ const source = nodeById.get(edge.sourceId);
2888
+ const target = nodeById.get(edge.targetId);
2889
+ const sourceOwnerId = source?.kind === 'Port' ? source.parentId : source?.id;
2890
+ const targetOwnerId = target?.kind === 'Port' ? target.parentId : target?.id;
2891
+ if (!sourceOwnerId || !targetOwnerId || sourceOwnerId === targetOwnerId) {
2892
+ continue;
2893
+ }
2894
+ const sourcePeer = ownerCenter(targetOwnerId);
2895
+ const targetPeer = ownerCenter(sourceOwnerId);
2896
+ if (source?.kind === 'Port' && sourcePeer) {
2897
+ const peers = peerCentersByPort.get(source.id) ?? [];
2898
+ peers.push(sourcePeer);
2899
+ peerCentersByPort.set(source.id, peers);
2900
+ if (target?.kind === 'Port') {
2901
+ const peerPortIds = peerPortIdsByPort.get(source.id) ?? [];
2902
+ peerPortIds.push(target.id);
2903
+ peerPortIdsByPort.set(source.id, peerPortIds);
2904
+ }
2905
+ }
2906
+ if (target?.kind === 'Port' && targetPeer) {
2907
+ const peers = peerCentersByPort.get(target.id) ?? [];
2908
+ peers.push(targetPeer);
2909
+ peerCentersByPort.set(target.id, peers);
2910
+ if (source?.kind === 'Port') {
2911
+ const peerPortIds = peerPortIdsByPort.get(target.id) ?? [];
2912
+ peerPortIds.push(source.id);
2913
+ peerPortIdsByPort.set(target.id, peerPortIds);
2914
+ }
2915
+ }
2916
+ }
2917
+
2918
+ const anchorFor = (ownerId: string, side: PortSide, ratio: number): { x: number; y: number } | undefined => {
2919
+ const box = boxes.get(ownerId);
2920
+ if (!box) {
2921
+ return undefined;
2922
+ }
2923
+ const clampedRatio = clamp(ratio, 0.05, 0.95);
2924
+ if (side === 'left') {
2925
+ return { x: box.x, y: box.y + (box.height * clampedRatio) };
2926
+ }
2927
+ if (side === 'right') {
2928
+ return { x: box.x + box.width, y: box.y + (box.height * clampedRatio) };
2929
+ }
2930
+ if (side === 'top') {
2931
+ return { x: box.x + (box.width * clampedRatio), y: box.y };
2932
+ }
2933
+ return { x: box.x + (box.width * clampedRatio), y: box.y + box.height };
2934
+ };
2935
+
2936
+ const currentAnchorForPort = (portId: string): { x: number; y: number } | undefined => {
2937
+ const port = nodeById.get(portId);
2938
+ if (!port?.parentId) {
2939
+ return undefined;
2940
+ }
2941
+ const placement = placements.get(portId) ?? { side: 'right' as PortSide, ratio: 0.5 };
2942
+ return anchorFor(port.parentId, placement.side, placement.ratio);
2492
2943
  };
2944
+
2945
+ const peerAnchorsForPort = (portId: string): Array<{ x: number; y: number }> => {
2946
+ const peerAnchors: Array<{ x: number; y: number }> = [];
2947
+ for (const peerPortId of peerPortIdsByPort.get(portId) ?? []) {
2948
+ const anchor = currentAnchorForPort(peerPortId);
2949
+ if (anchor) {
2950
+ peerAnchors.push(anchor);
2951
+ }
2952
+ }
2953
+ for (const peerCenter of peerCentersByPort.get(portId) ?? []) {
2954
+ peerAnchors.push(peerCenter);
2955
+ }
2956
+ return peerAnchors;
2957
+ };
2958
+
2959
+ const candidateCost = (ownerId: string, side: PortSide, portId: string): number => {
2960
+ const owner = ownerCenter(ownerId);
2961
+ const candidate = anchorFor(ownerId, side, 0.5);
2962
+ if (!owner || !candidate) {
2963
+ return Number.POSITIVE_INFINITY;
2964
+ }
2965
+ const peerAnchors = peerAnchorsForPort(portId);
2966
+ if (peerAnchors.length === 0) {
2967
+ return side === 'right' ? 0 : 1;
2968
+ }
2969
+ let cost = 0;
2970
+ for (const peer of peerAnchors) {
2971
+ cost += Math.abs(candidate.x - peer.x) + Math.abs(candidate.y - peer.y);
2972
+ }
2973
+ return cost;
2974
+ };
2975
+
2976
+ const sideOrder: PortSide[] = ['right', 'left', 'top', 'bottom'];
2977
+ const primaryCoordinate = (side: PortSide, portId: string): number => {
2978
+ const peerAnchors = peerAnchorsForPort(portId);
2979
+ if (peerAnchors.length === 0) {
2980
+ return 0;
2981
+ }
2982
+ const values = peerAnchors.map((peer) => ((side === 'left' || side === 'right') ? peer.y : peer.x));
2983
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
2984
+ };
2985
+
2986
+ for (const [ownerId, ports] of portsByOwner.entries()) {
2987
+ const count = ports.length;
2988
+ for (let index = 0; index < count; index += 1) {
2989
+ placements.set(ports[index].id, {
2990
+ side: 'right',
2991
+ ratio: count <= 0 ? 0.5 : ((index + 1) / (count + 1)),
2992
+ });
2993
+ }
2994
+ const ownerPortIds = portIdsByOwner.get(ownerId) ?? new Set<string>();
2995
+ for (let pass = 0; pass < 2; pass += 1) {
2996
+ for (const port of ports) {
2997
+ let bestSide: PortSide = 'right';
2998
+ let bestCost = Number.POSITIVE_INFINITY;
2999
+ for (const side of sideOrder) {
3000
+ const cost = candidateCost(ownerId, side, port.id);
3001
+ if (cost < bestCost || (cost === bestCost && sideOrder.indexOf(side) < sideOrder.indexOf(bestSide))) {
3002
+ bestCost = cost;
3003
+ bestSide = side;
3004
+ }
3005
+ }
3006
+ const existing = placements.get(port.id) ?? { ratio: 0.5, side: bestSide };
3007
+ placements.set(port.id, { side: bestSide, ratio: existing.ratio });
3008
+ }
3009
+ const sideGroups = new Map<PortSide, NodeSpec[]>([
3010
+ ['left', []],
3011
+ ['right', []],
3012
+ ['top', []],
3013
+ ['bottom', []],
3014
+ ]);
3015
+ for (const port of ports) {
3016
+ sideGroups.get(placements.get(port.id)?.side ?? 'right')?.push(port);
3017
+ }
3018
+ for (const [side, sidePorts] of sideGroups.entries()) {
3019
+ sidePorts.sort((left, right) => (
3020
+ primaryCoordinate(side, left.id) - primaryCoordinate(side, right.id)
3021
+ || left.id.localeCompare(right.id)
3022
+ ));
3023
+ for (let index = 0; index < sidePorts.length; index += 1) {
3024
+ placements.set(sidePorts[index].id, {
3025
+ side,
3026
+ ratio: (index + 1) / (sidePorts.length + 1),
3027
+ });
3028
+ }
3029
+ }
3030
+ void ownerPortIds;
3031
+ }
3032
+ }
3033
+ return placements;
2493
3034
  }
2494
3035
 
2495
3036
  function toPositiveNumber(value: unknown): number | undefined {