@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
@@ -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';
@@ -100,7 +109,7 @@ const TYPE_IRIS: Readonly<Record<NodeKind | 'Edge', string>> = {
100
109
  };
101
110
  const LIST_ITEM_IRI = `${D}ListItem`;
102
111
  const CSS_EDITOR_FOREGROUND = 'var(--vscode-editor-foreground, var(--oml-static-foreground, #24292f))';
103
- const CSS_EDITOR_BACKGROUND = 'var(--vscode-editor-background, transparent)';
112
+ const CSS_EDITOR_BACKGROUND = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
104
113
  const CSS_CANVAS_BACKGROUND = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
105
114
  const CSS_FOCUS_BORDER = 'var(--vscode-focusBorder, var(--oml-static-link, #0969da))';
106
115
 
@@ -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,
@@ -233,10 +242,14 @@ async function renderWithX6(
233
242
  minScale: 0.4,
234
243
  maxScale: 2.5,
235
244
  factor: 1.1,
245
+ modifiers: ['meta', 'ctrl'],
236
246
  },
237
247
  connecting: {
238
248
  router: 'normal',
239
- connector: 'rounded',
249
+ connector: {
250
+ name: 'jumpover',
251
+ args: { size: 5 },
252
+ },
240
253
  allowBlank: false,
241
254
  allowNode: false,
242
255
  allowPort: false,
@@ -252,13 +265,130 @@ async function renderWithX6(
252
265
  edgeMovable: false,
253
266
  vertexMovable: false,
254
267
  arrowheadMovable: false,
255
- labelMovable: false,
268
+ labelMovable: true,
256
269
  };
257
270
  },
258
271
  background: {
259
272
  color: CSS_CANVAS_BACKGROUND,
260
273
  },
274
+ onPortRendered: ({ port, node, container }: { port: { id: string }; node: { id: string }; container: HTMLElement }) => {
275
+ if (boundPortContainers.has(container)) {
276
+ return;
277
+ }
278
+ boundPortContainers.add(container);
279
+ const portIri = String(port.id ?? '').trim();
280
+ const applyPortNativeTitle = (): void => {
281
+ if (!portIri) {
282
+ return;
283
+ }
284
+ container.setAttribute('title', portIri);
285
+ for (const element of Array.from(container.querySelectorAll<HTMLElement | SVGElement>('*'))) {
286
+ element.setAttribute('title', portIri);
287
+ if (element instanceof SVGElement) {
288
+ let titleNode: SVGTitleElement | null = null;
289
+ for (const child of Array.from(element.children)) {
290
+ if (child instanceof SVGTitleElement) {
291
+ titleNode = child;
292
+ break;
293
+ }
294
+ }
295
+ if (!titleNode) {
296
+ titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
297
+ element.insertBefore(titleNode, element.firstChild);
298
+ }
299
+ titleNode.textContent = portIri;
300
+ }
301
+ }
302
+ };
303
+ container.setAttribute('data-port-id', String(port.id));
304
+ applyPortNativeTitle();
305
+ container.style.cursor = 'grab';
306
+ container.style.touchAction = 'none';
307
+ container.addEventListener('mouseenter', (event: MouseEvent) => {
308
+ const iri = portIri;
309
+ if (!iri) {
310
+ return;
311
+ }
312
+ applyPortNativeTitle();
313
+ const rect = container.getBoundingClientRect();
314
+ liveCanvas.dispatchEvent(new CustomEvent('md-show-iri-hover', {
315
+ bubbles: true,
316
+ detail: {
317
+ iri,
318
+ previewEnabled: /^Mac/i.test(navigator.platform) ? event.metaKey : event.ctrlKey,
319
+ anchorRect: {
320
+ left: rect.left,
321
+ right: rect.right,
322
+ top: rect.top,
323
+ bottom: rect.bottom,
324
+ width: rect.width,
325
+ height: rect.height,
326
+ },
327
+ },
328
+ }));
329
+ });
330
+ container.addEventListener('mouseleave', () => {
331
+ liveCanvas.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
332
+ });
333
+ container.addEventListener('dblclick', (event: MouseEvent) => {
334
+ const iri = String(port.id ?? '').trim();
335
+ if (!iri) {
336
+ return;
337
+ }
338
+ event.preventDefault();
339
+ event.stopPropagation();
340
+ if (typeof event.stopImmediatePropagation === 'function') {
341
+ event.stopImmediatePropagation();
342
+ }
343
+ liveCanvas.dispatchEvent(new CustomEvent('md-navigate-iri', {
344
+ bubbles: true,
345
+ detail: { iri },
346
+ }));
347
+ });
348
+ container.addEventListener('pointerdown', (event: PointerEvent) => {
349
+ if (typeof graphView.cleanSelection === 'function') {
350
+ graphView.cleanSelection();
351
+ }
352
+ nodeTransform.clearWidgets();
353
+ const owner = graphView.getCellById(String(node.id));
354
+ if (!owner) {
355
+ return;
356
+ }
357
+ startPortDrag(event.clientX, event.clientY, owner, String(port.id), event.pointerId, container);
358
+ event.preventDefault();
359
+ event.stopPropagation();
360
+ if (typeof event.stopImmediatePropagation === 'function') {
361
+ event.stopImmediatePropagation();
362
+ }
363
+ if (typeof container.setPointerCapture === 'function') {
364
+ try {
365
+ container.setPointerCapture(event.pointerId);
366
+ } catch {
367
+ // Ignore capture failures; window listeners still handle the drag.
368
+ }
369
+ }
370
+ });
371
+ },
261
372
  });
373
+ const x6Mod = await import('@antv/x6');
374
+ const TransformCtor = (x6Mod as any).Transform;
375
+ if (typeof TransformCtor !== 'function') {
376
+ throw new Error('X6 Transform plugin is unavailable in @antv/x6');
377
+ }
378
+ nodeTransform = new TransformCtor({
379
+ resizing: {
380
+ enabled: (node: any) => node?.getData?.()?.kind === 'Node',
381
+ minWidth: 48,
382
+ minHeight: 32,
383
+ orthogonal: true,
384
+ restrict: false,
385
+ autoScroll: false,
386
+ preserveAspectRatio: false,
387
+ allowReverse: false,
388
+ },
389
+ rotating: false,
390
+ });
391
+ graphView.use(nodeTransform);
262
392
  const toPlainRect = (value: any): Record<string, number> | undefined => {
263
393
  if (!value) return undefined;
264
394
  const x = Number(value.x);
@@ -291,12 +421,7 @@ async function renderWithX6(
291
421
  for (const list of portsByOwner.values()) {
292
422
  list.sort((a, b) => a.id.localeCompare(b.id));
293
423
  }
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
- }
424
+ const portPlacementById = computeDefaultPortPlacements(graph, nodeById, portsByOwner, layout.boxes);
300
425
  const ordered = [...graph.nodes]
301
426
  .filter((node) => node.kind !== 'Port')
302
427
  .sort((a, b) => nodeDepth(a.id, nodeById) - nodeDepth(b.id, nodeById));
@@ -307,20 +432,41 @@ async function renderWithX6(
307
432
  const labelText = node.labels.length > 0 ? node.labels.join('\n') : localName(node.id);
308
433
  const resolvedStyle = node.style;
309
434
  const resolvedShape = resolveRenderNodeShape(resolvedStyle);
435
+ const { bodyAttrs, labelAttrs, iconSvgAttrs, iconPathAttrs, imageAttrs } = resolveNodeAttrs(resolvedStyle);
310
436
  const x = box.x;
311
437
  const y = box.y;
312
438
  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);
439
+ const portItems = ownerPorts.map((port) => {
440
+ const placement = portPlacementById.get(port.id) ?? { side: 'right' as PortSide, ratio: 0.5 };
441
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
442
+ const position = placement.side === 'left' || placement.side === 'right'
443
+ ? {
444
+ x: placement.side === 'left' ? 0 : box.width,
445
+ y: ratio * box.height,
446
+ }
447
+ : {
448
+ x: ratio * box.width,
449
+ y: placement.side === 'top' ? 0 : box.height,
450
+ };
451
+ const portText = port.labels[0];
452
+ return {
453
+ id: port.id,
454
+ group: 'boundary',
455
+ args: {
456
+ x: position.x,
457
+ y: position.y,
458
+ side: placement.side,
459
+ ratio,
460
+ },
461
+ attrs: resolvePortAttrs(
462
+ port.style,
463
+ port.classes,
464
+ portText,
465
+ placement.side,
466
+ typeof bodyAttrs.stroke === 'string' ? bodyAttrs.stroke : undefined
467
+ ),
468
+ };
469
+ });
324
470
  const iconPathSelectors = iconPathAttrs.map((_, index) => `iconPath${index}`);
325
471
  graphView.addNode({
326
472
  id: node.id,
@@ -424,7 +570,7 @@ async function renderWithX6(
424
570
  },
425
571
  items: portItems,
426
572
  } : undefined,
427
- zIndex: 10,
573
+ zIndex: 50,
428
574
  data: {
429
575
  kind: node.kind,
430
576
  ownerId: node.parentId,
@@ -561,10 +707,30 @@ async function renderWithX6(
561
707
  let maxBottom = Number.NEGATIVE_INFINITY;
562
708
  const childCells: any[] = [];
563
709
  const childDebug: Array<Record<string, unknown>> = [];
710
+ const childGeometryBounds = (child: any): { x: number; y: number; width: number; height: number } | undefined => {
711
+ if (!child || typeof child.size !== 'function') {
712
+ return undefined;
713
+ }
714
+ const size = child.size();
715
+ const width = Number(size?.width);
716
+ const height = Number(size?.height);
717
+ let position: any;
718
+ if (typeof child.getPosition === 'function') {
719
+ position = child.getPosition();
720
+ } else {
721
+ position = child.position;
722
+ }
723
+ const x = Number(position?.x);
724
+ const y = Number(position?.y);
725
+ if (![x, y, width, height].every(Number.isFinite)) {
726
+ return undefined;
727
+ }
728
+ return { x, y, width, height };
729
+ };
564
730
  for (const childId of childIds) {
565
731
  const child = graphView.getCellById(childId);
566
- if (!child || typeof child.getBBox !== 'function') continue;
567
- const absBBox = child.getBBox();
732
+ const absBBox = childGeometryBounds(child);
733
+ if (!absBBox) continue;
568
734
  const relLeft = absBBox.x - containerBBox.x;
569
735
  const relTop = absBBox.y - containerBBox.y;
570
736
  minLeft = Math.min(minLeft, relLeft);
@@ -596,10 +762,11 @@ async function renderWithX6(
596
762
  // container origin (without moving its embedded children, which keep their
597
763
  // absolute positions) and expand the size to compensate so the opposite edge
598
764
  // 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;
765
+ const spacing = resolveBoxSpacing(containerSpec.style, 0);
766
+ const minInsetX = spacing.marginLeft + spacing.paddingLeft;
767
+ const minInsetY = spacing.marginTop + spacing.paddingTop;
768
+ const shiftX = minLeft < minInsetX ? minLeft - minInsetX : 0;
769
+ const shiftY = minTop < minInsetY ? minTop - minInsetY : 0;
603
770
  // Dimensions needed relative to the (possibly shifted) new origin.
604
771
  const baseWidth = containerSize.width - shiftX;
605
772
  const baseHeight = containerSize.height - shiftY;
@@ -612,7 +779,8 @@ async function renderWithX6(
612
779
  childIds,
613
780
  containerBBox: toPlainRect(containerBBox),
614
781
  containerSize: toPlainRect({ x: 0, y: 0, ...containerSize }),
615
- topPadding,
782
+ minInsetX,
783
+ minInsetY,
616
784
  bounds: { minLeft, minTop, maxRight, maxBottom },
617
785
  shift: { shiftX, shiftY },
618
786
  childDebug,
@@ -632,6 +800,7 @@ async function renderWithX6(
632
800
  to: { width: neededWidth, height: neededHeight },
633
801
  });
634
802
  container.resize(neededWidth, neededHeight);
803
+ syncOwnedPortPositions(containerId);
635
804
  } else {
636
805
  logResize('container-no-resize', { containerId });
637
806
  }
@@ -708,106 +877,149 @@ async function renderWithX6(
708
877
  if (!isPrimaryMover(node, options)) return;
709
878
  growAncestorContainers(node);
710
879
  });
880
+ const syncOwnedPortPositions = (ownerId: string): void => {
881
+ const owner = graphView.getCellById(ownerId);
882
+ if (!owner || typeof owner.size !== 'function' || typeof owner.getPorts !== 'function' || typeof owner.setPortProp !== 'function') {
883
+ return;
884
+ }
885
+ const size = owner.size();
886
+ const portIds = (owner.getPorts() as any[])
887
+ .map((port) => String(port?.id ?? ''))
888
+ .filter((portId) => portId.length > 0);
889
+ for (const portId of portIds) {
890
+ const placement = portPlacementById.get(portId) ?? { side: 'right' as PortSide, ratio: 0.5 };
891
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
892
+ const nextArgs = placement.side === 'left' || placement.side === 'right'
893
+ ? {
894
+ x: placement.side === 'left' ? 0 : size.width,
895
+ y: ratio * size.height,
896
+ side: placement.side,
897
+ ratio,
898
+ }
899
+ : {
900
+ x: ratio * size.width,
901
+ y: placement.side === 'top' ? 0 : size.height,
902
+ side: placement.side,
903
+ ratio,
904
+ };
905
+ owner.setPortProp(portId, {
906
+ args: nextArgs,
907
+ label: {
908
+ position: resolveBorderPortLabelPosition(placement.side),
909
+ },
910
+ });
911
+ }
912
+ };
913
+ graphView.on('node:resizing', ({ node }: { node: any }) => {
914
+ syncOwnedPortPositions(String(node?.id ?? ''));
915
+ });
916
+ graphView.on('node:resized', ({ node, options }: { node: any; options?: Record<string, unknown> }) => {
917
+ if (options?.silent) return;
918
+ syncOwnedPortPositions(String(node?.id ?? ''));
919
+ growAncestorContainers(node);
920
+ });
711
921
 
712
922
  const edgeLabelPosition = (placement: EdgeLabel['placement']): number => {
713
923
  if (placement === 'begin') return 0.15;
714
924
  if (placement === 'end') return 0.85;
715
925
  return 0.5;
716
926
  };
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]));
927
+ const directedPairKey = (sourceId: string, targetId: string): string => `${sourceId}=>${targetId}`;
928
+ const undirectedPairKey = (leftId: string, rightId: string): string => (
929
+ leftId < rightId ? `${leftId}<=>${rightId}` : `${rightId}<=>${leftId}`
930
+ );
931
+ const edgeIdsByPair = new Map<string, string[]>();
724
932
  const undirectedEdgeIdsByPair = new Map<string, string[]>();
725
933
  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) ?? [];
934
+ if (!edge.sourceId || !edge.targetId) continue;
935
+ const key = directedPairKey(edge.sourceId, edge.targetId);
936
+ const ids = edgeIdsByPair.get(key) ?? [];
731
937
  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) {
938
+ edgeIdsByPair.set(key, ids);
939
+ const undirectedKey = undirectedPairKey(edge.sourceId, edge.targetId);
940
+ const pairIds = undirectedEdgeIdsByPair.get(undirectedKey) ?? [];
941
+ pairIds.push(edge.id);
942
+ undirectedEdgeIdsByPair.set(undirectedKey, pairIds);
943
+ }
944
+ const edgePointForEndpoint = (endpointId: string): { x: number; y: number } | undefined => {
945
+ const endpoint = nodeById.get(endpointId);
946
+ if (!endpoint) {
742
947
  return undefined;
743
948
  }
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);
949
+ if (endpoint.kind === 'Port' && endpoint.parentId) {
950
+ const ownerBox = layout.boxes.get(endpoint.parentId);
951
+ const placement = portPlacementById.get(endpoint.id);
952
+ if (!ownerBox || !placement) {
953
+ return undefined;
954
+ }
955
+ const ratio = clamp(placement.ratio, 0.05, 0.95);
956
+ if (placement.side === 'left') {
957
+ return { x: ownerBox.x, y: ownerBox.y + (ownerBox.height * ratio) };
958
+ }
959
+ if (placement.side === 'right') {
960
+ return { x: ownerBox.x + ownerBox.width, y: ownerBox.y + (ownerBox.height * ratio) };
961
+ }
962
+ if (placement.side === 'top') {
963
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y };
771
964
  }
965
+ return { x: ownerBox.x + (ownerBox.width * ratio), y: ownerBox.y + ownerBox.height };
772
966
  }
773
- // Only fan when both directions exist for the same endpoint pair.
774
- if (forwardIds.length === 0 || reverseIds.length === 0) {
967
+ const box = layout.boxes.get(endpointId);
968
+ if (!box) {
775
969
  return undefined;
776
970
  }
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;
971
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
972
+ };
973
+ const fanningVertexForEdge = (edge: EdgeSpec): Array<{ x: number; y: number }> | undefined => {
974
+ if (!edge.sourceId || !edge.targetId) {
975
+ return undefined;
789
976
  }
790
- if (directionSign === 0 || laneIndex < 0 || laneCount <= 0) {
977
+ const undirectedIds = undirectedEdgeIdsByPair.get(undirectedPairKey(edge.sourceId, edge.targetId)) ?? [];
978
+ if (undirectedIds.length <= 1) {
791
979
  return undefined;
792
980
  }
793
-
794
- const pairABox = layout.boxes.get(pairA);
795
- const pairBBox = layout.boxes.get(pairB);
796
- if (!pairABox || !pairBBox) {
981
+ const sourcePoint = edgePointForEndpoint(edge.sourceId);
982
+ const targetPoint = edgePointForEndpoint(edge.targetId);
983
+ if (!sourcePoint || !targetPoint) {
797
984
  return undefined;
798
985
  }
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);
986
+ const sx = sourcePoint.x;
987
+ const sy = sourcePoint.y;
988
+ const tx = targetPoint.x;
989
+ const ty = targetPoint.y;
803
990
  const dx = tx - sx;
804
991
  const dy = ty - sy;
805
992
  const len = Math.hypot(dx, dy);
806
993
  if (!Number.isFinite(len) || len < 1) {
807
994
  return undefined;
808
995
  }
809
- const laneOffset = (laneIndex - ((laneCount - 1) / 2));
810
- const offset = (directionSign * 16) + (laneOffset * 10);
996
+ const forwardIds = (edgeIdsByPair.get(directedPairKey(edge.sourceId, edge.targetId)) ?? [])
997
+ .slice()
998
+ .sort((left, right) => left.localeCompare(right));
999
+ const reverseIds = (edgeIdsByPair.get(directedPairKey(edge.targetId, edge.sourceId)) ?? [])
1000
+ .slice()
1001
+ .sort((left, right) => left.localeCompare(right));
1002
+ let offset = 0;
1003
+ if (forwardIds.length > 0 && reverseIds.length > 0) {
1004
+ const forwardIndex = forwardIds.indexOf(edge.id);
1005
+ const reverseIndex = reverseIds.indexOf(edge.id);
1006
+ if (forwardIndex >= 0) {
1007
+ const laneOffset = forwardIndex - ((forwardIds.length - 1) / 2);
1008
+ offset = 16 + (laneOffset * 10);
1009
+ } else if (reverseIndex >= 0) {
1010
+ const laneOffset = reverseIndex - ((reverseIds.length - 1) / 2);
1011
+ offset = -16 + (laneOffset * 10);
1012
+ } else {
1013
+ return undefined;
1014
+ }
1015
+ } else {
1016
+ const laneIndex = forwardIds.indexOf(edge.id);
1017
+ if (laneIndex < 0 || forwardIds.length <= 1) {
1018
+ return undefined;
1019
+ }
1020
+ const laneOffset = laneIndex - ((forwardIds.length - 1) / 2);
1021
+ offset = laneOffset * 16;
1022
+ }
811
1023
  if (Math.abs(offset) < 0.01) {
812
1024
  return undefined;
813
1025
  }
@@ -844,8 +1056,8 @@ async function renderWithX6(
844
1056
  id: edge.id,
845
1057
  source: resolveEndpoint(edge.sourceId),
846
1058
  target: resolveEndpoint(edge.targetId),
847
- router: { name: 'normal' },
848
- connector: { name: 'rounded' },
1059
+ router: resolveEdgeRouter(resolvedStyle),
1060
+ connector: resolveEdgeConnector(resolvedStyle),
849
1061
  attrs: {
850
1062
  line: lineAttrs,
851
1063
  },
@@ -862,7 +1074,7 @@ async function renderWithX6(
862
1074
  labelBody: resolveEdgeLabelBodyAttrs(resolvedStyle, label.placement),
863
1075
  },
864
1076
  })),
865
- zIndex: 5,
1077
+ zIndex: 50,
866
1078
  });
867
1079
  }
868
1080
 
@@ -902,49 +1114,113 @@ async function renderWithX6(
902
1114
  e.stopPropagation();
903
1115
  });
904
1116
 
905
- const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
1117
+ const sidePriority = (side: PortSide): number => {
1118
+ switch (side) {
1119
+ case 'left':
1120
+ return 0;
1121
+ case 'top':
1122
+ return 1;
1123
+ case 'right':
1124
+ return 2;
1125
+ case 'bottom':
1126
+ return 3;
1127
+ }
1128
+ };
1129
+ const projectPortPlacement = (node: any, clientX: number, clientY: number, preferredSide?: PortSide): PortPlacement | undefined => {
1130
+ if (!node || typeof node.size !== 'function') return undefined;
1131
+ const size = node.size();
1132
+ const position = typeof node.getPosition === 'function'
1133
+ ? node.getPosition()
1134
+ : { x: Number(node?.position?.x ?? 0), y: Number(node?.position?.y ?? 0) };
1135
+ const localPoint = typeof graphView.clientToGraph === 'function'
1136
+ ? graphView.clientToGraph(clientX, clientY)
1137
+ : { x: clientX, y: clientY };
1138
+ const localX = clamp(localPoint.x - position.x, 0, size.width);
1139
+ const localY = clamp(localPoint.y - position.y, 0, size.height);
1140
+ const candidates: Array<{ side: PortSide; distance: number }> = [
1141
+ { side: 'left', distance: localX },
1142
+ { side: 'right', distance: size.width - localX },
1143
+ { side: 'top', distance: localY },
1144
+ { side: 'bottom', distance: size.height - localY },
1145
+ ];
1146
+ candidates.sort((left, right) => left.distance - right.distance || sidePriority(left.side) - sidePriority(right.side));
1147
+ const closest = candidates[0];
1148
+ const preferredCandidate = preferredSide
1149
+ ? candidates.find((candidate) => candidate.side === preferredSide)
1150
+ : undefined;
1151
+ const hysteresis = 8;
1152
+ const side = preferredCandidate && preferredSide && (preferredCandidate.distance - closest.distance) <= hysteresis
1153
+ ? preferredSide
1154
+ : closest.side;
1155
+ const ratio = side === 'left' || side === 'right'
1156
+ ? (size.height > 0 ? localY / size.height : 0.5)
1157
+ : (size.width > 0 ? localX / size.width : 0.5);
1158
+ return {
1159
+ side,
1160
+ ratio: Math.max(0.05, Math.min(0.95, ratio)),
1161
+ };
1162
+ };
906
1163
  const onPointerMove = (event: PointerEvent): void => {
907
- if (!activePortDrag) return;
1164
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId) return;
908
1165
  const node = graphView.getCellById(activePortDrag.nodeId);
909
1166
  if (!node || typeof node.size !== 'function' || typeof node.getPort !== 'function' || typeof node.setPortProp !== 'function') return;
1167
+ const placement = projectPortPlacement(node, event.clientX, event.clientY, activePortDrag.side);
1168
+ if (!placement) return;
1169
+ if (placement.side === activePortDrag.side && Math.abs(placement.ratio - activePortDrag.ratio) < 0.005) {
1170
+ return;
1171
+ }
910
1172
  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));
1173
+ const nextX = placement.side === 'left'
1174
+ ? 0
1175
+ : placement.side === 'right'
1176
+ ? size.width
1177
+ : placement.ratio * size.width;
1178
+ const nextY = placement.side === 'top'
1179
+ ? 0
1180
+ : placement.side === 'bottom'
1181
+ ? size.height
1182
+ : placement.ratio * size.height;
1183
+ node.setPortProp(activePortDrag.portId, 'args/x', nextX);
913
1184
  node.setPortProp(activePortDrag.portId, 'args/y', nextY);
1185
+ node.setPortProp(activePortDrag.portId, 'args/side', placement.side);
1186
+ node.setPortProp(activePortDrag.portId, 'args/ratio', placement.ratio);
1187
+ if (placement.side !== activePortDrag.side) {
1188
+ node.setPortProp(activePortDrag.portId, 'label/position', resolveBorderPortLabelPosition(placement.side));
1189
+ }
1190
+ activePortDrag.side = placement.side;
1191
+ activePortDrag.ratio = placement.ratio;
1192
+ portPlacementById.set(activePortDrag.portId, placement);
914
1193
  };
915
- const onPointerUp = (): void => {
1194
+ const onPointerUp = (event: PointerEvent): void => {
1195
+ if (!activePortDrag || event.pointerId !== activePortDrag.pointerId) return;
1196
+ if (typeof activePortDrag.container.releasePointerCapture === 'function') {
1197
+ try {
1198
+ activePortDrag.container.releasePointerCapture(activePortDrag.pointerId);
1199
+ } catch {
1200
+ // Ignore capture release failures.
1201
+ }
1202
+ }
916
1203
  activePortDrag = undefined;
917
1204
  };
918
1205
  window.addEventListener('pointermove', onPointerMove);
919
1206
  window.addEventListener('pointerup', onPointerUp);
920
- const startPortDrag = (clientY: number, node: any, portId: string): void => {
1207
+ const startPortDrag = (clientX: number, clientY: number, node: any, portId: string, pointerId: number, container: Element): void => {
921
1208
  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);
1209
+ const placement = projectPortPlacement(node, clientX, clientY, portPlacementById.get(portId)?.side ?? 'right')
1210
+ ?? portPlacementById.get(portId)
1211
+ ?? { side: 'right' as PortSide, ratio: 0.5 };
924
1212
  activePortDrag = {
925
1213
  nodeId: String(node.id),
926
1214
  portId: String(portId),
927
- startClientY: clientY,
928
- startPortY,
1215
+ pointerId,
1216
+ side: placement.side,
1217
+ ratio: placement.ratio,
1218
+ container,
929
1219
  };
930
1220
  };
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
1221
  liveCanvas.addEventListener('DOMNodeRemovedFromDocument', () => {
945
1222
  window.removeEventListener('pointermove', onPointerMove);
946
1223
  window.removeEventListener('pointerup', onPointerUp);
947
- liveCanvas.removeEventListener('pointerdown', onCanvasPointerDown, true);
948
1224
  }, { once: true });
949
1225
 
950
1226
  const resultContainer = liveCanvas.closest('.oml-md-result');
@@ -999,10 +1275,109 @@ async function renderWithX6(
999
1275
  resizeHandle.addEventListener('pointerup', onResizePointerEnd);
1000
1276
  resizeHandle.addEventListener('pointercancel', onResizePointerEnd);
1001
1277
 
1278
+ installDiagramNodeInteractions(liveCanvas, graphView);
1002
1279
  installDiagramToolbar(liveCanvas, graphView, graph, actions);
1003
1280
 
1004
1281
  }
1005
1282
 
1283
+ function installDiagramNodeInteractions(canvas: HTMLElement, graphView: any): void {
1284
+ const ensureSvgNativeTitle = (element: SVGElement, iri: string): void => {
1285
+ let titleNode: SVGTitleElement | null = null;
1286
+ for (const child of Array.from(element.children)) {
1287
+ if (child instanceof SVGTitleElement) {
1288
+ titleNode = child;
1289
+ break;
1290
+ }
1291
+ }
1292
+ if (!titleNode) {
1293
+ titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
1294
+ element.insertBefore(titleNode, element.firstChild);
1295
+ }
1296
+ titleNode.textContent = iri;
1297
+ };
1298
+
1299
+ const applyNativeTooltipTitle = (container: Element | undefined, iri: string, eventTarget?: EventTarget | null): void => {
1300
+ if (!iri) {
1301
+ return;
1302
+ }
1303
+ if (container instanceof HTMLElement || container instanceof SVGElement) {
1304
+ container.setAttribute('title', iri);
1305
+ }
1306
+ if (container instanceof Element) {
1307
+ for (const element of Array.from(container.querySelectorAll<HTMLElement | SVGElement>('*'))) {
1308
+ element.setAttribute('title', iri);
1309
+ if (element instanceof SVGElement) {
1310
+ ensureSvgNativeTitle(element, iri);
1311
+ }
1312
+ }
1313
+ if (container instanceof SVGElement) {
1314
+ ensureSvgNativeTitle(container, iri);
1315
+ }
1316
+ }
1317
+ if (eventTarget instanceof HTMLElement || eventTarget instanceof SVGElement) {
1318
+ eventTarget.setAttribute('title', iri);
1319
+ if (eventTarget instanceof SVGElement) {
1320
+ ensureSvgNativeTitle(eventTarget, iri);
1321
+ }
1322
+ }
1323
+ };
1324
+
1325
+ const clearHover = (): void => {
1326
+ canvas.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
1327
+ };
1328
+
1329
+ graphView.on('node:mouseenter', ({ node, e }: { node: any; e?: MouseEvent }) => {
1330
+ const iri = String(node?.id ?? '');
1331
+ if (iri) {
1332
+ const view = graphView.findViewByCell?.(node);
1333
+ const bbox = view?.container?.getBoundingClientRect?.();
1334
+ applyNativeTooltipTitle(view?.container as Element | undefined, iri, e?.target ?? null);
1335
+ if (!bbox) {
1336
+ return;
1337
+ }
1338
+ canvas.dispatchEvent(new CustomEvent('md-show-iri-hover', {
1339
+ bubbles: true,
1340
+ detail: {
1341
+ iri,
1342
+ previewEnabled: !!e && (/^Mac/i.test(navigator.platform) ? e.metaKey : e.ctrlKey),
1343
+ anchorRect: {
1344
+ left: bbox.left,
1345
+ right: bbox.right,
1346
+ top: bbox.top,
1347
+ bottom: bbox.bottom,
1348
+ width: bbox.width,
1349
+ height: bbox.height,
1350
+ },
1351
+ },
1352
+ }));
1353
+ }
1354
+ });
1355
+
1356
+ graphView.on('node:mouseleave', () => {
1357
+ clearHover();
1358
+ });
1359
+
1360
+ graphView.on('blank:mousemove', () => {
1361
+ clearHover();
1362
+ });
1363
+
1364
+ graphView.on('node:dblclick', ({ node, e }: { node: any; e: MouseEvent }) => {
1365
+ const iri = String(node?.id ?? '');
1366
+ if (!iri) {
1367
+ return;
1368
+ }
1369
+ e.preventDefault();
1370
+ e.stopPropagation();
1371
+ if (typeof (e as any).stopImmediatePropagation === 'function') {
1372
+ (e as any).stopImmediatePropagation();
1373
+ }
1374
+ canvas.dispatchEvent(new CustomEvent('md-navigate-iri', {
1375
+ bubbles: true,
1376
+ detail: { iri },
1377
+ }));
1378
+ });
1379
+ }
1380
+
1006
1381
  function installDiagramToolbar(
1007
1382
  graphRoot: HTMLElement,
1008
1383
  graphView: any,
@@ -1348,7 +1723,8 @@ async function loadDagreLib(): Promise<any> {
1348
1723
 
1349
1724
  async function layoutGraphDagre(
1350
1725
  graph: { nodes: NodeSpec[]; edges: EdgeSpec[]; roots: string[] },
1351
- options: DagreLayoutOptions
1726
+ options: DagreLayoutOptions,
1727
+ rootSpacing: BoxSpacing
1352
1728
  ): Promise<NativeLayoutResult> {
1353
1729
  const dagre = await loadDagreLib();
1354
1730
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
@@ -1385,8 +1761,6 @@ async function layoutGraphDagre(
1385
1761
  stack: {
1386
1762
  direction: styleLayout.direction === 'horizontal' ? 'horizontal' : 'vertical',
1387
1763
  gap: clampLayoutNumber(styleLayout.gap, 0, 400, 0),
1388
- marginx: clampLayoutNumber(styleLayout.marginx, 0, 200, 0),
1389
- marginy: clampLayoutNumber(styleLayout.marginy, 0, 200, 0),
1390
1764
  stretch: typeof styleLayout.stretch === 'boolean' ? styleLayout.stretch : true,
1391
1765
  },
1392
1766
  };
@@ -1406,29 +1780,40 @@ async function layoutGraphDagre(
1406
1780
  rankdir,
1407
1781
  nodesep: clampLayoutNumber(styleLayout.nodesep, 0, 400, options.nodesep),
1408
1782
  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
1783
  },
1412
1784
  };
1413
1785
  };
1414
1786
 
1415
1787
  const childPositionById = new Map<string, { x: number; y: number }>();
1788
+ const branchChildForParent = (parentId: string | undefined, nodeId: string): string | undefined => {
1789
+ let cursorId: string | undefined = nodeId;
1790
+ while (cursorId) {
1791
+ const node = nodeById.get(cursorId);
1792
+ if (!node) {
1793
+ return undefined;
1794
+ }
1795
+ if (node.parentId === parentId) {
1796
+ return node.id;
1797
+ }
1798
+ cursorId = node.parentId;
1799
+ }
1800
+ return undefined;
1801
+ };
1416
1802
  const edgesBetweenChildren = (parentId: string | undefined, childSet: Set<string>): Array<{ source: string; target: string }> => {
1417
1803
  const seen = new Set<string>();
1418
1804
  const links: Array<{ source: string; target: string }> = [];
1419
1805
  for (const edge of graph.edges) {
1420
1806
  const sourceId = endpointOwner(edge.sourceId);
1421
1807
  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}`;
1808
+ if (!sourceId || !targetId) continue;
1809
+ const sourceBranchId = branchChildForParent(parentId, sourceId);
1810
+ const targetBranchId = branchChildForParent(parentId, targetId);
1811
+ if (!sourceBranchId || !targetBranchId || sourceBranchId === targetBranchId) continue;
1812
+ if (!childSet.has(sourceBranchId) || !childSet.has(targetBranchId)) continue;
1813
+ const key = `${sourceBranchId}=>${targetBranchId}`;
1429
1814
  if (seen.has(key)) continue;
1430
1815
  seen.add(key);
1431
- links.push({ source: sourceId, target: targetId });
1816
+ links.push({ source: sourceBranchId, target: targetBranchId });
1432
1817
  }
1433
1818
  return links;
1434
1819
  };
@@ -1443,8 +1828,8 @@ async function layoutGraphDagre(
1443
1828
  }
1444
1829
 
1445
1830
  const parent = parentId ? nodeById.get(parentId) : undefined;
1446
- const topPadding = parent?.contentTopPadding ?? 0;
1447
1831
  const localLayout = layoutOptionsForParent(parentId);
1832
+ const spacing = parent ? resolveBoxSpacing(parent.style, 0) : rootSpacing;
1448
1833
  let totalWidth = 0;
1449
1834
  let totalHeight = 0;
1450
1835
  if (localLayout.type === 'stack') {
@@ -1452,13 +1837,13 @@ async function layoutGraphDagre(
1452
1837
  const childNodes = childIds.map((childId) => nodeById.get(childId)).filter((node): node is NodeSpec => !!node);
1453
1838
  const maxChildWidth = childNodes.reduce((max, child) => Math.max(max, child.width), 0);
1454
1839
  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));
1840
+ const parentContentWidth = Math.max(0, (parent?.width ?? 0) - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1841
+ const parentContentHeight = Math.max(0, (parent?.height ?? 0) - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1457
1842
  const availableWidth = Math.max(maxChildWidth, parentContentWidth);
1458
1843
  const availableHeight = Math.max(maxChildHeight, parentContentHeight);
1459
1844
  const isSingleChild = childNodes.length === 1;
1460
- let cursorX = local.marginx;
1461
- let cursorY = topPadding + local.marginy;
1845
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1846
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1462
1847
  for (const child of childNodes) {
1463
1848
  if (local.stretch && isSingleChild) {
1464
1849
  child.width = Math.max(0, availableWidth);
@@ -1481,16 +1866,16 @@ async function layoutGraphDagre(
1481
1866
  childNodes.reduce((sum, child) => sum + child.height, 0) + (Math.max(0, childNodes.length - 1) * local.gap)
1482
1867
  );
1483
1868
  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;
1869
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1870
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1486
1871
  } else {
1487
1872
  const contentWidth = Math.max(
1488
1873
  0,
1489
1874
  childNodes.reduce((sum, child) => sum + child.width, 0) + (Math.max(0, childNodes.length - 1) * local.gap)
1490
1875
  );
1491
1876
  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;
1877
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1878
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1494
1879
  }
1495
1880
  } else {
1496
1881
  const localOptions = localLayout.dagre;
@@ -1546,23 +1931,19 @@ async function layoutGraphDagre(
1546
1931
  const left = laid.x - (width / 2);
1547
1932
  const top = laid.y - (height / 2);
1548
1933
  childPositionById.set(childId, {
1549
- x: localOptions.marginx + (left - minX),
1550
- y: topPadding + localOptions.marginy + (top - minY),
1934
+ x: spacing.marginLeft + spacing.paddingLeft + (left - minX),
1935
+ y: spacing.marginTop + spacing.paddingTop + (top - minY),
1551
1936
  });
1552
1937
  }
1553
1938
 
1554
1939
  const contentWidth = Math.max(0, maxX - minX);
1555
1940
  const contentHeight = Math.max(0, maxY - minY);
1556
- totalWidth = (localOptions.marginx * 2) + contentWidth;
1557
- totalHeight = topPadding + (localOptions.marginy * 2) + contentHeight;
1941
+ totalWidth = spacing.marginLeft + spacing.paddingLeft + contentWidth + spacing.paddingRight + spacing.marginRight;
1942
+ totalHeight = spacing.marginTop + spacing.paddingTop + contentHeight + spacing.paddingBottom + spacing.marginBottom;
1558
1943
  }
1559
1944
  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
- }
1945
+ parent.width = Math.max(parent.width, totalWidth);
1946
+ parent.height = Math.max(parent.height, totalHeight);
1566
1947
  }
1567
1948
  return { width: totalWidth, height: totalHeight };
1568
1949
  };
@@ -1582,9 +1963,9 @@ async function layoutGraphDagre(
1582
1963
  if (localLayout.type === 'stack') {
1583
1964
  const local = localLayout.stack;
1584
1965
  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));
1966
+ const spacing = resolveBoxSpacing(parent.style, 0);
1967
+ const contentWidth = Math.max(0, parent.width - spacing.marginLeft - spacing.marginRight - spacing.paddingLeft - spacing.paddingRight);
1968
+ const contentHeight = Math.max(0, parent.height - spacing.marginTop - spacing.marginBottom - spacing.paddingTop - spacing.paddingBottom);
1588
1969
  const isSingleChild = childNodes.length === 1;
1589
1970
 
1590
1971
  if (local.stretch) {
@@ -1602,8 +1983,8 @@ async function layoutGraphDagre(
1602
1983
  }
1603
1984
  }
1604
1985
 
1605
- let cursorX = local.marginx;
1606
- let cursorY = topPadding + local.marginy;
1986
+ let cursorX = spacing.marginLeft + spacing.paddingLeft;
1987
+ let cursorY = spacing.marginTop + spacing.paddingTop;
1607
1988
  for (const child of childNodes) {
1608
1989
  childPositionById.set(child.id, { x: cursorX, y: cursorY });
1609
1990
  if (local.direction === 'vertical') {
@@ -1819,9 +2200,6 @@ function compileDiagramGraph(
1819
2200
  children: [],
1820
2201
  width: 160,
1821
2202
  height: 70,
1822
- hasExplicitWidth: false,
1823
- hasExplicitHeight: false,
1824
- contentTopPadding: 0,
1825
2203
  });
1826
2204
  }
1827
2205
 
@@ -1899,9 +2277,6 @@ function compileDiagramGraph(
1899
2277
  const estimated = estimateSize(node.kind, baseLabel, node.labels.length, node.style);
1900
2278
  node.width = estimated.width;
1901
2279
  node.height = estimated.height;
1902
- node.hasExplicitWidth = estimated.hasExplicitWidth;
1903
- node.hasExplicitHeight = estimated.hasExplicitHeight;
1904
- node.contentTopPadding = resolveContainerTopPadding(node, baseLabel);
1905
2280
  }
1906
2281
  for (const edge of edges) {
1907
2282
  edge.style = styleFor('edge', edge.id, edge.classes, edge.properties);
@@ -1930,62 +2305,44 @@ function estimateSize(
1930
2305
  label: string,
1931
2306
  labelCount: number,
1932
2307
  style: Record<string, unknown>
1933
- ): { width: number; height: number; hasExplicitWidth: boolean; hasExplicitHeight: boolean } {
2308
+ ): { width: number; height: number } {
1934
2309
  const styledWidth = toPositiveNumber(style.width);
1935
2310
  const styledHeight = toPositiveNumber(style.height);
1936
- if (styledWidth && styledHeight) {
1937
- return { width: styledWidth, height: styledHeight, hasExplicitWidth: true, hasExplicitHeight: true };
1938
- }
2311
+ const attrs = extractStyleAttrs(style);
2312
+ const labelAttrs = asRecord(attrs.label);
2313
+ const labelDisplay = typeof labelAttrs?.display === 'string' ? labelAttrs.display.trim().toLowerCase() : '';
2314
+ const labelOpacity = toNonNegativeNumber(labelAttrs?.opacity);
2315
+ const labelVisible = labelDisplay !== 'none' && (labelOpacity === undefined || labelOpacity > 0);
2316
+ const effectiveLabel = labelVisible ? label : '';
2317
+ const effectiveLabelCount = labelVisible ? labelCount : 0;
1939
2318
  if (kind === 'Port') {
1940
2319
  return {
1941
- width: styledWidth ?? 14,
1942
- height: styledHeight ?? 14,
1943
- hasExplicitWidth: styledWidth !== undefined,
1944
- hasExplicitHeight: styledHeight !== undefined,
2320
+ width: Math.max(14, styledWidth ?? 0),
2321
+ height: Math.max(14, styledHeight ?? 0),
1945
2322
  };
1946
2323
  }
1947
- const textWidth = Math.max(24, label.length * 7);
1948
- const baseWidth = Math.max(72, Math.min(280, textWidth + 26));
2324
+ const textWidth = effectiveLabel.length > 0 ? Math.max(24, effectiveLabel.length * 7) : 0;
2325
+ const baseWidth = effectiveLabel.length > 0 ? Math.max(72, Math.min(280, textWidth + 26)) : 0;
1949
2326
  if (kind === 'Compartment') {
1950
- const size = { width: baseWidth, height: Math.max(44, 24 + labelCount * 16) };
2327
+ const size = {
2328
+ width: baseWidth,
2329
+ height: effectiveLabelCount > 0 ? Math.max(44, 24 + effectiveLabelCount * 16) : 0,
2330
+ };
1951
2331
  return {
1952
- width: styledWidth ?? size.width,
1953
- height: styledHeight ?? size.height,
1954
- hasExplicitWidth: styledWidth !== undefined,
1955
- hasExplicitHeight: styledHeight !== undefined,
2332
+ width: Math.max(size.width, styledWidth ?? 0),
2333
+ height: Math.max(size.height, styledHeight ?? 0),
1956
2334
  };
1957
2335
  }
1958
- const size = { width: baseWidth, height: Math.max(36, 20 + labelCount * 16) };
2336
+ const size = {
2337
+ width: baseWidth,
2338
+ height: effectiveLabelCount > 0 ? Math.max(36, 20 + effectiveLabelCount * 16) : 0,
2339
+ };
1959
2340
  return {
1960
- width: styledWidth ?? size.width,
1961
- height: styledHeight ?? size.height,
1962
- hasExplicitWidth: styledWidth !== undefined,
1963
- hasExplicitHeight: styledHeight !== undefined,
2341
+ width: Math.max(size.width, styledWidth ?? 0),
2342
+ height: Math.max(size.height, styledHeight ?? 0),
1964
2343
  };
1965
2344
  }
1966
2345
 
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
2346
  function localName(value: string): string {
1990
2347
  return displayLabelFromIri(value);
1991
2348
  }
@@ -2042,9 +2399,7 @@ function resolveDagreLayoutOptions(options: Record<string, unknown> | undefined)
2042
2399
 
2043
2400
  const nodesep = clampLayoutNumber(layout.nodesep, 0, 400, 28);
2044
2401
  const ranksep = clampLayoutNumber(layout.ranksep, 0, 500, 64);
2045
- const marginx = clampLayoutNumber(layout.marginx, 0, 200, 16);
2046
- const marginy = clampLayoutNumber(layout.marginy, 0, 200, 16);
2047
- return { rankdir, nodesep, ranksep, marginx, marginy };
2402
+ return { rankdir, nodesep, ranksep };
2048
2403
  }
2049
2404
 
2050
2405
  function clampLayoutNumber(value: unknown, min: number, max: number, fallback: number): number {
@@ -2054,6 +2409,129 @@ function clampLayoutNumber(value: unknown, min: number, max: number, fallback: n
2054
2409
  return Math.max(min, Math.min(max, Math.round(value)));
2055
2410
  }
2056
2411
 
2412
+ function resolveRootSpacing(options: Record<string, unknown> | undefined): BoxSpacing {
2413
+ return readBoxSpacing(options, {
2414
+ marginTop: 16,
2415
+ marginBottom: 16,
2416
+ marginLeft: 16,
2417
+ marginRight: 16,
2418
+ paddingTop: 0,
2419
+ paddingBottom: 0,
2420
+ paddingLeft: 0,
2421
+ paddingRight: 0,
2422
+ });
2423
+ }
2424
+
2425
+ function resolveBoxSpacing(style: Record<string, unknown> | undefined, fallbackMargin: number): BoxSpacing {
2426
+ return readBoxSpacing(style, {
2427
+ marginTop: fallbackMargin,
2428
+ marginBottom: fallbackMargin,
2429
+ marginLeft: fallbackMargin,
2430
+ marginRight: fallbackMargin,
2431
+ paddingTop: 0,
2432
+ paddingBottom: 0,
2433
+ paddingLeft: 0,
2434
+ paddingRight: 0,
2435
+ });
2436
+ }
2437
+
2438
+ function readBoxSpacing(style: Record<string, unknown> | undefined, defaults: BoxSpacing): BoxSpacing {
2439
+ const source = style ?? {};
2440
+ return {
2441
+ ...readCssBoxSpacing(source.margin, 'margin', defaults),
2442
+ ...readCssBoxSpacing(source.padding, 'padding', defaults),
2443
+ };
2444
+ }
2445
+
2446
+ function readCssBoxSpacing(
2447
+ value: unknown,
2448
+ kind: 'margin' | 'padding',
2449
+ defaults: BoxSpacing
2450
+ ): Pick<BoxSpacing, 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft'> {
2451
+ const sides = parseCssBoxShorthand(value);
2452
+ const isMargin = kind === 'margin';
2453
+ if (isMargin) {
2454
+ return {
2455
+ marginTop: sides?.top ?? defaults.marginTop,
2456
+ marginRight: sides?.right ?? defaults.marginRight,
2457
+ marginBottom: sides?.bottom ?? defaults.marginBottom,
2458
+ marginLeft: sides?.left ?? defaults.marginLeft,
2459
+ paddingTop: defaults.paddingTop,
2460
+ paddingRight: defaults.paddingRight,
2461
+ paddingBottom: defaults.paddingBottom,
2462
+ paddingLeft: defaults.paddingLeft,
2463
+ };
2464
+ }
2465
+ return {
2466
+ marginTop: defaults.marginTop,
2467
+ marginRight: defaults.marginRight,
2468
+ marginBottom: defaults.marginBottom,
2469
+ marginLeft: defaults.marginLeft,
2470
+ paddingTop: sides?.top ?? defaults.paddingTop,
2471
+ paddingRight: sides?.right ?? defaults.paddingRight,
2472
+ paddingBottom: sides?.bottom ?? defaults.paddingBottom,
2473
+ paddingLeft: sides?.left ?? defaults.paddingLeft,
2474
+ };
2475
+ }
2476
+
2477
+ function parseCssBoxShorthand(value: unknown): { top?: number; right?: number; bottom?: number; left?: number } | undefined {
2478
+ const values = Array.isArray(value)
2479
+ ? value
2480
+ : typeof value === 'string'
2481
+ ? (value.includes(',') ? undefined : value.trim().split(/\s+/).filter((token) => token.length > 0))
2482
+ : typeof value === 'number'
2483
+ ? [value]
2484
+ : undefined;
2485
+ if (!values || values.length === 0 || values.length > 4) {
2486
+ return undefined;
2487
+ }
2488
+
2489
+ const parsed = values.map((entry) => {
2490
+ if (typeof entry === 'number' && Number.isFinite(entry)) {
2491
+ return entry;
2492
+ }
2493
+ if (typeof entry === 'string') {
2494
+ const next = Number.parseFloat(entry);
2495
+ if (Number.isFinite(next)) {
2496
+ return next;
2497
+ }
2498
+ }
2499
+ return undefined;
2500
+ });
2501
+ if (parsed.some((entry) => entry === undefined)) {
2502
+ return undefined;
2503
+ }
2504
+
2505
+ const clampCssBoxValue = (entry: number | undefined): number | undefined => {
2506
+ if (entry === undefined) return undefined;
2507
+ return Math.max(0, Math.min(200, Math.round(entry)));
2508
+ };
2509
+
2510
+ if (parsed.length === 1) {
2511
+ const single = clampCssBoxValue(parsed[0]);
2512
+ return single === undefined ? undefined : { top: single, right: single, bottom: single, left: single };
2513
+ }
2514
+ if (parsed.length === 2) {
2515
+ const vertical = clampCssBoxValue(parsed[0]);
2516
+ const horizontal = clampCssBoxValue(parsed[1]);
2517
+ if (vertical === undefined || horizontal === undefined) return undefined;
2518
+ return { top: vertical, right: horizontal, bottom: vertical, left: horizontal };
2519
+ }
2520
+ if (parsed.length === 3) {
2521
+ const top = clampCssBoxValue(parsed[0]);
2522
+ const horizontal = clampCssBoxValue(parsed[1]);
2523
+ const bottom = clampCssBoxValue(parsed[2]);
2524
+ if (top === undefined || horizontal === undefined || bottom === undefined) return undefined;
2525
+ return { top, right: horizontal, bottom, left: horizontal };
2526
+ }
2527
+ const top = clampCssBoxValue(parsed[0]);
2528
+ const right = clampCssBoxValue(parsed[1]);
2529
+ const bottom = clampCssBoxValue(parsed[2]);
2530
+ const left = clampCssBoxValue(parsed[3]);
2531
+ if (top === undefined || right === undefined || bottom === undefined || left === undefined) return undefined;
2532
+ return { top, right, bottom, left };
2533
+ }
2534
+
2057
2535
  function parseDiagramStylesheet(options: Record<string, unknown> | undefined): DiagramStyleRule[] {
2058
2536
  const stylesheet = options?.stylesheet;
2059
2537
  if (!Array.isArray(stylesheet)) {
@@ -2079,11 +2557,11 @@ function parseDiagramStylesheet(options: Record<string, unknown> | undefined): D
2079
2557
 
2080
2558
  function parseStyleSelector(
2081
2559
  selector: string
2082
- ): { elementKind: 'node' | 'compartment' | 'port' | 'edge'; className?: string; condition?: string } | undefined {
2083
- const match = /^\s*(node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
2560
+ ): { elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge'; className?: string; condition?: string } | undefined {
2561
+ const match = /^\s*(diagram|node|compartment|port|edge)(?:\.([A-Za-z0-9_-]+))?(?:\s*\[(.+)\]\s*)?$/i.exec(selector);
2084
2562
  if (!match) return undefined;
2085
2563
  return {
2086
- elementKind: match[1].toLowerCase() as 'node' | 'compartment' | 'port' | 'edge',
2564
+ elementKind: match[1].toLowerCase() as 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2087
2565
  className: match[2]?.trim() || undefined,
2088
2566
  condition: match[3]?.trim() || undefined,
2089
2567
  };
@@ -2093,7 +2571,14 @@ function parseStyleSelector(
2093
2571
  // These pass through as top-level keys so callers can read style.layout, style.width, etc.
2094
2572
  const OML_PASSTHROUGH_STYLE_KEYS = new Set([
2095
2573
  'layout', 'shape', 'width', 'height',
2574
+ 'margin', 'padding', 'router', 'connector',
2575
+ ]);
2576
+
2577
+ const LEGACY_BOX_SPACING_KEYS = new Set([
2578
+ 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
2096
2579
  'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
2580
+ 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
2581
+ 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
2097
2582
  ]);
2098
2583
 
2099
2584
  // Record-valued top-level style keys that map directly into the attrs sub-tree under the same name.
@@ -2107,7 +2592,7 @@ const ATTRS_RECORD_STYLE_KEYS = new Set([
2107
2592
  ]);
2108
2593
 
2109
2594
  function normalizeDiagramStyle(
2110
- elementKind: 'node' | 'compartment' | 'port' | 'edge',
2595
+ elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2111
2596
  raw: Record<string, unknown>
2112
2597
  ): Record<string, unknown> {
2113
2598
  const isEdge = elementKind === 'edge';
@@ -2122,6 +2607,10 @@ function normalizeDiagramStyle(
2122
2607
  const key = rawKey.trim();
2123
2608
  if (!key || value === undefined || value === null) continue;
2124
2609
 
2610
+ if (LEGACY_BOX_SPACING_KEYS.has(key)) {
2611
+ continue;
2612
+ }
2613
+
2125
2614
  // OML node-level keys: pass through to the top level of the normalized style object
2126
2615
  if (OML_PASSTHROUGH_STYLE_KEYS.has(key)) { passthrough[key] = value; continue; }
2127
2616
 
@@ -2172,7 +2661,7 @@ function normalizeDiagramStyle(
2172
2661
  }
2173
2662
 
2174
2663
  function resolveElementStyle(
2175
- elementKind: 'node' | 'compartment' | 'port' | 'edge',
2664
+ elementKind: 'diagram' | 'node' | 'compartment' | 'port' | 'edge',
2176
2665
  value: string,
2177
2666
  classes: string[],
2178
2667
  properties: Record<string, string[]>,
@@ -2410,6 +2899,19 @@ function resolveEdgeLineAttrs(style: Record<string, unknown>): Record<string, un
2410
2899
  };
2411
2900
  }
2412
2901
 
2902
+ function resolveEdgeRouter(style: Record<string, unknown>): Record<string, unknown> {
2903
+ const router = asRecord(style.router);
2904
+ return router ?? { name: 'normal' };
2905
+ }
2906
+
2907
+ function resolveEdgeConnector(style: Record<string, unknown>): Record<string, unknown> {
2908
+ const connector = asRecord(style.connector);
2909
+ return connector ?? {
2910
+ name: 'jumpover',
2911
+ args: { size: 5 },
2912
+ };
2913
+ }
2914
+
2413
2915
  function resolveEdgeLabelAttrs(
2414
2916
  style: Record<string, unknown>,
2415
2917
  placement: EdgeLabel['placement'],
@@ -2440,19 +2942,49 @@ function resolveEdgeLabelBodyAttrs(
2440
2942
  fillOpacity: 0.9,
2441
2943
  stroke: 'none',
2442
2944
  strokeWidth: 0,
2945
+ pointerEvents: 'all',
2443
2946
  ...(base ?? {}),
2444
2947
  ...(specific ?? {}),
2445
2948
  };
2446
2949
  }
2447
2950
 
2448
- function resolvePortAttrs(style: Record<string, unknown>, classes: string[], text: string): Record<string, unknown> {
2951
+ function resolvePortAttrs(
2952
+ style: Record<string, unknown>,
2953
+ classes: string[],
2954
+ text: string | undefined,
2955
+ side: PortSide = 'right',
2956
+ ownerStroke?: string
2957
+ ): Record<string, unknown> {
2449
2958
  const attrs = extractStyleAttrs(style);
2450
2959
  const body = asRecord(attrs.body);
2451
2960
  const icon = asRecord(attrs.icon);
2452
2961
  const label = asRecord(attrs.label);
2453
2962
  const imageUrl = extractImageHrefFromIcon(icon);
2963
+ const labelPosition = side === 'left'
2964
+ ? {
2965
+ textAnchor: 'end',
2966
+ x: -10,
2967
+ dy: '0.9em',
2968
+ }
2969
+ : side === 'top'
2970
+ ? {
2971
+ textAnchor: 'middle',
2972
+ x: 0,
2973
+ dy: '-0.3em',
2974
+ }
2975
+ : side === 'bottom'
2976
+ ? {
2977
+ textAnchor: 'middle',
2978
+ x: 0,
2979
+ dy: '1.4em',
2980
+ }
2981
+ : {
2982
+ textAnchor: 'start',
2983
+ x: 10,
2984
+ dy: '0.9em',
2985
+ };
2454
2986
 
2455
- return {
2987
+ const result: Record<string, unknown> = {
2456
2988
  body: {
2457
2989
  width: 12,
2458
2990
  height: 12,
@@ -2460,7 +2992,7 @@ function resolvePortAttrs(style: Record<string, unknown>, classes: string[], tex
2460
2992
  y: -6,
2461
2993
  class: ['oml-port-body', ...classes].join(' '),
2462
2994
  magnet: false,
2463
- stroke: CSS_FOCUS_BORDER,
2995
+ stroke: ownerStroke ?? CSS_EDITOR_FOREGROUND,
2464
2996
  strokeWidth: 1,
2465
2997
  fill: CSS_EDITOR_BACKGROUND,
2466
2998
  ...(body ?? {}),
@@ -2475,17 +3007,196 @@ function resolvePortAttrs(style: Record<string, unknown>, classes: string[], tex
2475
3007
  ...(icon ?? {}),
2476
3008
  ...(imageUrl ? { href: imageUrl, xlinkHref: imageUrl, 'xlink:href': imageUrl } : {}),
2477
3009
  },
2478
- label: {
3010
+ };
3011
+ if (text) {
3012
+ result.label = {
2479
3013
  text,
2480
3014
  fill: CSS_EDITOR_FOREGROUND,
2481
3015
  fontFamily: 'var(--vscode-editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
2482
3016
  fontSize: 12,
2483
- textAnchor: 'start',
2484
- x: 10,
2485
- dy: '0.9em',
3017
+ ...labelPosition,
2486
3018
  ...(label ?? {}),
2487
- },
3019
+ };
3020
+ }
3021
+ return result;
3022
+ }
3023
+
3024
+ function resolveBorderPortLabelPosition(side: PortSide): { name: PortSide } {
3025
+ return { name: side };
3026
+ }
3027
+
3028
+ function clamp(value: number, min: number, max: number): number {
3029
+ return Math.max(min, Math.min(max, value));
3030
+ }
3031
+
3032
+ function computeDefaultPortPlacements(
3033
+ graph: { nodes: NodeSpec[]; edges: EdgeSpec[]; roots: string[] },
3034
+ nodeById: Map<string, NodeSpec>,
3035
+ portsByOwner: Map<string, NodeSpec[]>,
3036
+ boxes: Map<string, NodeBox>
3037
+ ): Map<string, PortPlacement> {
3038
+ const placements = new Map<string, PortPlacement>();
3039
+ const ownerCenter = (nodeId: string): { x: number; y: number } | undefined => {
3040
+ const box = boxes.get(nodeId);
3041
+ if (!box) {
3042
+ return undefined;
3043
+ }
3044
+ return { x: box.x + (box.width / 2), y: box.y + (box.height / 2) };
3045
+ };
3046
+ const portIdsByOwner = new Map<string, Set<string>>();
3047
+ for (const [ownerId, ports] of portsByOwner.entries()) {
3048
+ portIdsByOwner.set(ownerId, new Set(ports.map((port) => port.id)));
3049
+ }
3050
+ const peerPortIdsByPort = new Map<string, string[]>();
3051
+ const peerCentersByPort = new Map<string, Array<{ x: number; y: number }>>();
3052
+ for (const edge of graph.edges) {
3053
+ const source = nodeById.get(edge.sourceId);
3054
+ const target = nodeById.get(edge.targetId);
3055
+ const sourceOwnerId = source?.kind === 'Port' ? source.parentId : source?.id;
3056
+ const targetOwnerId = target?.kind === 'Port' ? target.parentId : target?.id;
3057
+ if (!sourceOwnerId || !targetOwnerId || sourceOwnerId === targetOwnerId) {
3058
+ continue;
3059
+ }
3060
+ const sourcePeer = ownerCenter(targetOwnerId);
3061
+ const targetPeer = ownerCenter(sourceOwnerId);
3062
+ if (source?.kind === 'Port' && sourcePeer) {
3063
+ const peers = peerCentersByPort.get(source.id) ?? [];
3064
+ peers.push(sourcePeer);
3065
+ peerCentersByPort.set(source.id, peers);
3066
+ if (target?.kind === 'Port') {
3067
+ const peerPortIds = peerPortIdsByPort.get(source.id) ?? [];
3068
+ peerPortIds.push(target.id);
3069
+ peerPortIdsByPort.set(source.id, peerPortIds);
3070
+ }
3071
+ }
3072
+ if (target?.kind === 'Port' && targetPeer) {
3073
+ const peers = peerCentersByPort.get(target.id) ?? [];
3074
+ peers.push(targetPeer);
3075
+ peerCentersByPort.set(target.id, peers);
3076
+ if (source?.kind === 'Port') {
3077
+ const peerPortIds = peerPortIdsByPort.get(target.id) ?? [];
3078
+ peerPortIds.push(source.id);
3079
+ peerPortIdsByPort.set(target.id, peerPortIds);
3080
+ }
3081
+ }
3082
+ }
3083
+
3084
+ const anchorFor = (ownerId: string, side: PortSide, ratio: number): { x: number; y: number } | undefined => {
3085
+ const box = boxes.get(ownerId);
3086
+ if (!box) {
3087
+ return undefined;
3088
+ }
3089
+ const clampedRatio = clamp(ratio, 0.05, 0.95);
3090
+ if (side === 'left') {
3091
+ return { x: box.x, y: box.y + (box.height * clampedRatio) };
3092
+ }
3093
+ if (side === 'right') {
3094
+ return { x: box.x + box.width, y: box.y + (box.height * clampedRatio) };
3095
+ }
3096
+ if (side === 'top') {
3097
+ return { x: box.x + (box.width * clampedRatio), y: box.y };
3098
+ }
3099
+ return { x: box.x + (box.width * clampedRatio), y: box.y + box.height };
3100
+ };
3101
+
3102
+ const currentAnchorForPort = (portId: string): { x: number; y: number } | undefined => {
3103
+ const port = nodeById.get(portId);
3104
+ if (!port?.parentId) {
3105
+ return undefined;
3106
+ }
3107
+ const placement = placements.get(portId) ?? { side: 'right' as PortSide, ratio: 0.5 };
3108
+ return anchorFor(port.parentId, placement.side, placement.ratio);
3109
+ };
3110
+
3111
+ const peerAnchorsForPort = (portId: string): Array<{ x: number; y: number }> => {
3112
+ const peerAnchors: Array<{ x: number; y: number }> = [];
3113
+ for (const peerPortId of peerPortIdsByPort.get(portId) ?? []) {
3114
+ const anchor = currentAnchorForPort(peerPortId);
3115
+ if (anchor) {
3116
+ peerAnchors.push(anchor);
3117
+ }
3118
+ }
3119
+ for (const peerCenter of peerCentersByPort.get(portId) ?? []) {
3120
+ peerAnchors.push(peerCenter);
3121
+ }
3122
+ return peerAnchors;
3123
+ };
3124
+
3125
+ const candidateCost = (ownerId: string, side: PortSide, portId: string): number => {
3126
+ const owner = ownerCenter(ownerId);
3127
+ const candidate = anchorFor(ownerId, side, 0.5);
3128
+ if (!owner || !candidate) {
3129
+ return Number.POSITIVE_INFINITY;
3130
+ }
3131
+ const peerAnchors = peerAnchorsForPort(portId);
3132
+ if (peerAnchors.length === 0) {
3133
+ return side === 'right' ? 0 : 1;
3134
+ }
3135
+ let cost = 0;
3136
+ for (const peer of peerAnchors) {
3137
+ cost += Math.abs(candidate.x - peer.x) + Math.abs(candidate.y - peer.y);
3138
+ }
3139
+ return cost;
2488
3140
  };
3141
+
3142
+ const sideOrder: PortSide[] = ['right', 'left', 'top', 'bottom'];
3143
+ const primaryCoordinate = (side: PortSide, portId: string): number => {
3144
+ const peerAnchors = peerAnchorsForPort(portId);
3145
+ if (peerAnchors.length === 0) {
3146
+ return 0;
3147
+ }
3148
+ const values = peerAnchors.map((peer) => ((side === 'left' || side === 'right') ? peer.y : peer.x));
3149
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
3150
+ };
3151
+
3152
+ for (const [ownerId, ports] of portsByOwner.entries()) {
3153
+ const count = ports.length;
3154
+ for (let index = 0; index < count; index += 1) {
3155
+ placements.set(ports[index].id, {
3156
+ side: 'right',
3157
+ ratio: count <= 0 ? 0.5 : ((index + 1) / (count + 1)),
3158
+ });
3159
+ }
3160
+ const ownerPortIds = portIdsByOwner.get(ownerId) ?? new Set<string>();
3161
+ for (let pass = 0; pass < 2; pass += 1) {
3162
+ for (const port of ports) {
3163
+ let bestSide: PortSide = 'right';
3164
+ let bestCost = Number.POSITIVE_INFINITY;
3165
+ for (const side of sideOrder) {
3166
+ const cost = candidateCost(ownerId, side, port.id);
3167
+ if (cost < bestCost || (cost === bestCost && sideOrder.indexOf(side) < sideOrder.indexOf(bestSide))) {
3168
+ bestCost = cost;
3169
+ bestSide = side;
3170
+ }
3171
+ }
3172
+ const existing = placements.get(port.id) ?? { ratio: 0.5, side: bestSide };
3173
+ placements.set(port.id, { side: bestSide, ratio: existing.ratio });
3174
+ }
3175
+ const sideGroups = new Map<PortSide, NodeSpec[]>([
3176
+ ['left', []],
3177
+ ['right', []],
3178
+ ['top', []],
3179
+ ['bottom', []],
3180
+ ]);
3181
+ for (const port of ports) {
3182
+ sideGroups.get(placements.get(port.id)?.side ?? 'right')?.push(port);
3183
+ }
3184
+ for (const [side, sidePorts] of sideGroups.entries()) {
3185
+ sidePorts.sort((left, right) => (
3186
+ primaryCoordinate(side, left.id) - primaryCoordinate(side, right.id)
3187
+ || left.id.localeCompare(right.id)
3188
+ ));
3189
+ for (let index = 0; index < sidePorts.length; index += 1) {
3190
+ placements.set(sidePorts[index].id, {
3191
+ side,
3192
+ ratio: (index + 1) / (sidePorts.length + 1),
3193
+ });
3194
+ }
3195
+ }
3196
+ void ownerPortIds;
3197
+ }
3198
+ }
3199
+ return placements;
2489
3200
  }
2490
3201
 
2491
3202
  function toPositiveNumber(value: unknown): number | undefined {