@diagrammo/dgmo 0.26.0 → 0.27.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 (136) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4182 -2704
  3. package/dist/advanced.d.cts +266 -58
  4. package/dist/advanced.d.ts +266 -58
  5. package/dist/advanced.js +4182 -2698
  6. package/dist/auto.cjs +4042 -2581
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4042 -2581
  9. package/dist/cli.cjs +172 -170
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +4067 -2583
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4067 -2583
  18. package/dist/internal.cjs +4182 -2704
  19. package/dist/internal.d.cts +266 -58
  20. package/dist/internal.d.ts +266 -58
  21. package/dist/internal.js +4182 -2698
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +1 -1
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -0
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion.ts +26 -12
  48. package/src/cycle/layout.ts +55 -72
  49. package/src/cycle/renderer.ts +11 -6
  50. package/src/d3.ts +78 -117
  51. package/src/diagnostics.ts +16 -0
  52. package/src/echarts.ts +46 -33
  53. package/src/editor/keywords.ts +4 -0
  54. package/src/er/layout.ts +114 -22
  55. package/src/er/parser.ts +28 -0
  56. package/src/er/renderer.ts +55 -2
  57. package/src/er/types.ts +3 -0
  58. package/src/gantt/renderer.ts +46 -38
  59. package/src/gantt/resolver.ts +9 -2
  60. package/src/graph/edge-spline.ts +29 -0
  61. package/src/graph/flowchart-parser.ts +34 -1
  62. package/src/graph/flowchart-renderer.ts +78 -64
  63. package/src/graph/layout.ts +206 -23
  64. package/src/graph/notes.ts +21 -0
  65. package/src/graph/state-parser.ts +26 -1
  66. package/src/graph/state-renderer.ts +78 -64
  67. package/src/graph/types.ts +13 -0
  68. package/src/index.ts +1 -1
  69. package/src/infra/layout.ts +46 -26
  70. package/src/infra/renderer.ts +16 -7
  71. package/src/journey-map/layout.ts +38 -49
  72. package/src/journey-map/renderer.ts +22 -45
  73. package/src/kanban/renderer.ts +15 -6
  74. package/src/label-layout.ts +3 -3
  75. package/src/map/completion.ts +77 -22
  76. package/src/map/context-labels.ts +57 -12
  77. package/src/map/data/PROVENANCE.json +1 -1
  78. package/src/map/data/airport-collisions.json +1 -0
  79. package/src/map/data/airports.json +1 -0
  80. package/src/map/data/types.ts +19 -0
  81. package/src/map/layout.ts +1196 -90
  82. package/src/map/legend-band.ts +2 -2
  83. package/src/map/load-data.ts +10 -1
  84. package/src/map/parser.ts +61 -32
  85. package/src/map/renderer.ts +284 -12
  86. package/src/map/resolved-types.ts +15 -1
  87. package/src/map/resolver.ts +132 -12
  88. package/src/map/types.ts +28 -8
  89. package/src/migrate/embedded.ts +9 -7
  90. package/src/mindmap/text-wrap.ts +13 -14
  91. package/src/org/layout.ts +19 -17
  92. package/src/org/renderer.ts +11 -4
  93. package/src/palettes/color-utils.ts +82 -21
  94. package/src/palettes/index.ts +0 -19
  95. package/src/palettes/registry.ts +1 -1
  96. package/src/palettes/types.ts +2 -2
  97. package/src/pert/layout.ts +48 -40
  98. package/src/pert/renderer.ts +30 -43
  99. package/src/pyramid/renderer.ts +4 -5
  100. package/src/raci/renderer.ts +34 -68
  101. package/src/render.ts +1 -1
  102. package/src/ring/renderer.ts +1 -2
  103. package/src/sequence/parser.ts +100 -22
  104. package/src/sequence/renderer.ts +75 -50
  105. package/src/sitemap/layout.ts +27 -19
  106. package/src/sitemap/renderer.ts +12 -5
  107. package/src/tech-radar/renderer.ts +11 -35
  108. package/src/utils/arrow-markers.ts +51 -0
  109. package/src/utils/fit-canvas.ts +64 -0
  110. package/src/utils/legend-constants.ts +8 -54
  111. package/src/utils/legend-d3.ts +10 -7
  112. package/src/utils/legend-layout.ts +7 -4
  113. package/src/utils/legend-types.ts +10 -4
  114. package/src/utils/note-box/constants.ts +25 -0
  115. package/src/utils/note-box/index.ts +11 -0
  116. package/src/utils/note-box/metrics.ts +90 -0
  117. package/src/utils/note-box/svg.ts +331 -0
  118. package/src/utils/notes/bounds.ts +30 -0
  119. package/src/utils/notes/build.ts +131 -0
  120. package/src/utils/notes/index.ts +18 -0
  121. package/src/utils/notes/model.ts +19 -0
  122. package/src/utils/notes/parse.ts +131 -0
  123. package/src/utils/notes/place.ts +177 -0
  124. package/src/utils/notes/resolve.ts +88 -0
  125. package/src/utils/number-format.ts +36 -0
  126. package/src/utils/parsing.ts +41 -0
  127. package/src/utils/reserved-key-registry.ts +4 -0
  128. package/src/utils/text-measure.ts +122 -0
  129. package/src/wireframe/layout.ts +4 -2
  130. package/src/wireframe/renderer.ts +8 -6
  131. package/src/palettes/dracula.ts +0 -68
  132. package/src/palettes/gruvbox.ts +0 -85
  133. package/src/palettes/monokai.ts +0 -68
  134. package/src/palettes/one-dark.ts +0 -70
  135. package/src/palettes/rose-pine.ts +0 -84
  136. package/src/palettes/solarized.ts +0 -77
package/src/cli.ts CHANGED
@@ -136,7 +136,7 @@ Key options:
136
136
  - \`-o <file>\` — output file; format inferred from extension (\`.svg\` → SVG, else PNG)
137
137
  - \`-o url\` — output a shareable diagrammo.app URL
138
138
  - \`--theme <theme>\` — \`light\` (default), \`dark\`, \`transparent\`
139
- - \`--palette <name>\` — \`nord\` (default), \`atlas\`, \`blueprint\`, \`slate\`, \`tidewater\`, \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`dracula\`, \`monokai\`
139
+ - \`--palette <name>\` — \`slate\` (default), \`atlas\`, \`blueprint\`, \`nord\`, \`tidewater\`, \`catppuccin\`, \`tokyo-night\`
140
140
  - \`--copy\` — copy the URL to clipboard (use with \`-o url\`)
141
141
  - \`--chart-types\` — list all supported chart types
142
142
 
@@ -309,7 +309,7 @@ end ❌ not needed — indentation closes blocks in sequence dia
309
309
 
310
310
  ## Tips
311
311
 
312
- - Default theme: \`light\`, default palette: \`nord\` — ask the user their preference before a final export.
312
+ - Default theme: \`light\`, default palette: \`slate\` — ask the user their preference before a final export.
313
313
  - Stdin mode for quick renders: \`echo "..." | dgmo -o out.png\`
314
314
  - For C4, \`--c4-level\` drills from context → containers → components → deployment.
315
315
  - When auto-detection picks the wrong chart type, add an explicit \`chart:\` directive.
@@ -477,7 +477,7 @@ Options:
477
477
  Use -o url to output a shareable diagrammo.app URL
478
478
  With stdin and no -o, PNG is written to stdout
479
479
  --theme <theme> Theme: ${THEMES.join(', ')} (default: light)
480
- --palette <name> Palette: ${PALETTES.join(', ')} (default: nord)
480
+ --palette <name> Palette: ${PALETTES.join(', ')} (default: slate)
481
481
  --copy Copy URL to clipboard (only with -o url)
482
482
  --json Output structured JSON to stdout
483
483
  --chart-types List all supported chart types
@@ -528,7 +528,7 @@ function parseArgs(argv: string[]): {
528
528
  input: undefined as string | undefined,
529
529
  output: undefined as string | undefined,
530
530
  theme: 'light' as (typeof THEMES)[number],
531
- palette: 'nord',
531
+ palette: 'slate',
532
532
  help: false,
533
533
  version: false,
534
534
  copy: false,
package/src/completion.ts CHANGED
@@ -16,7 +16,11 @@ import { extractSymbols as extractFlowchartSymbols } from './graph/flowchart-par
16
16
  import { extractSymbols as extractInfraSymbols } from './infra/parser';
17
17
  import { extractSymbols as extractClassSymbols } from './class/parser';
18
18
  import { extractPertSymbols } from './pert/parser';
19
- import { parseFirstLine, ALL_CHART_TYPES } from './utils/parsing';
19
+ import {
20
+ parseFirstLine,
21
+ ALL_CHART_TYPES,
22
+ measureIndent,
23
+ } from './utils/parsing';
20
24
  import { RECOGNIZED_COLOR_NAMES } from './colors';
21
25
 
22
26
  const RECOGNIZED_COLOR_SET: ReadonlySet<string> = new Set(
@@ -92,14 +96,8 @@ const GLOBAL_DIRECTIVES: Record<string, DirectiveValueSpec> = {
92
96
  description: 'Color palette name',
93
97
  values: [
94
98
  'nord',
95
- 'solarized',
96
99
  'catppuccin',
97
- 'rose-pine',
98
- 'gruvbox',
99
100
  'tokyo-night',
100
- 'one-dark',
101
- 'dracula',
102
- 'monokai',
103
101
  'atlas',
104
102
  'blueprint',
105
103
  'slate',
@@ -289,12 +287,13 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
289
287
  ],
290
288
  [
291
289
  'flowchart',
292
- // Spec §5 §4.6: direction-lr, orientation-vertical, solid-fill
290
+ // Spec §5 §4.6: direction-lr, orientation-vertical, solid-fill, no-notes
293
291
  withGlobals({
294
292
  'direction-lr': { description: 'Switch to left-to-right layout' },
295
293
  'orientation-vertical': {
296
294
  description: 'Use vertical orientation for ranks',
297
295
  },
296
+ 'no-notes': { description: 'Suppress all node note boxes' },
298
297
  }),
299
298
  ],
300
299
  ['class', withGlobals({})],
@@ -363,9 +362,10 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
363
362
  ],
364
363
  [
365
364
  'state',
366
- // Spec §6 §5.5: direction-tb, solid-fill.
365
+ // Spec §6 §5.6: direction-tb, solid-fill, no-notes.
367
366
  withGlobals({
368
367
  'direction-tb': { description: 'Switch to top-to-bottom layout' },
368
+ 'no-notes': { description: 'Suppress all state note boxes' },
369
369
  }),
370
370
  ],
371
371
  [
@@ -447,7 +447,10 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
447
447
  direction: { description: 'Layout direction', values: ['LR', 'TB'] },
448
448
  'active-tag': { description: 'Active tag group name' },
449
449
  hide: { description: 'Hide tag:value pairs' },
450
- 'box-metric': { description: 'Metric label for the value ramp' },
450
+ 'box-metric': {
451
+ description:
452
+ 'Metric label for the value ramp, with an optional trailing [low] [high] color pair',
453
+ },
451
454
  'show-values': { description: 'Print box values as text' },
452
455
  }),
453
456
  ],
@@ -519,7 +522,10 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
519
522
  // content keywords, not directives; metadata keys (value/label/style) live
520
523
  // in the reserved-key registry.
521
524
  withGlobals({
522
- 'region-metric': { description: 'Label for the region value ramp' },
525
+ 'region-metric': {
526
+ description:
527
+ 'Label for the region value ramp, with an optional trailing [low] [high] color pair',
528
+ },
523
529
  'poi-metric': {
524
530
  description: 'Label for the POI value (marker size) channel',
525
531
  },
@@ -548,11 +554,19 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
548
554
  'no-region-labels': {
549
555
  description: 'Turn off subdivision name labels (on by default)',
550
556
  },
557
+ 'no-region-value': {
558
+ description:
559
+ 'Turn off the metric value shown under each region (on by default)',
560
+ },
551
561
  'no-poi-labels': { description: 'Turn off POI labels (on by default)' },
552
562
  'no-colorize': {
553
563
  description:
554
564
  'Force plain green-land reference dress (regions are auto-coloured by default)',
555
565
  },
566
+ 'no-cities': {
567
+ description:
568
+ 'Turn off the subtle city dots scattered across the basemap (on by default)',
569
+ },
556
570
  'no-cluster-pois': {
557
571
  description:
558
572
  'Always fan out coincident POI markers instead of collapsing them into a count badge',
@@ -2212,7 +2226,7 @@ function extractRaciSymbols(docText: string): DiagramSymbols {
2212
2226
  continue;
2213
2227
  }
2214
2228
 
2215
- const indent = line.length - line.trimStart().length;
2229
+ const indent = measureIndent(line);
2216
2230
 
2217
2231
  // Header directives
2218
2232
  if (indent === 0) {
@@ -16,23 +16,18 @@ import {
16
16
  wrapDescriptionLines,
17
17
  type WrappedDescLine,
18
18
  } from '../utils/wrapped-desc';
19
+ import { measureText, wrapTextToWidth } from '../utils/text-measure';
19
20
 
20
21
  /** Minimum arc angle in radians (~15°) to keep arcs readable. */
21
22
  const MIN_ARC_ANGLE = (15 * Math.PI) / 180;
22
23
 
23
- /** Estimated character width at 13px label font. */
24
- const LABEL_CHAR_W = 8;
25
-
26
- /** Estimated character width at 16px circle label font. */
27
- const CIRCLE_LABEL_CHAR_W = 10;
28
-
29
- /**
30
- * Estimated character width at 11px description font (Inter).
31
- * Average glyph width is ~5.5–6.0 px for typical English text — using 6.0
32
- * gives us a small margin of safety for wide glyphs (m, w) without leaving
33
- * obvious dead space on the right side of the rectangle.
34
- */
35
- const DESC_CHAR_W = 6.0;
24
+ // ── Font sizes (must match cycle/renderer.ts) ──
25
+ // Layout sizes nodes/labels from text; the renderer draws them. Both measure
26
+ // the same string at the same font size so reserved space matches ink.
27
+ const LABEL_FONT_SIZE = 13;
28
+ const CIRCLE_LABEL_FONT_SIZE = 16;
29
+ const DESC_FONT_SIZE = 11;
30
+ const EDGE_LABEL_FONT_SIZE = 11;
36
31
 
37
32
  /** Minimum node width. */
38
33
  const MIN_NODE_WIDTH = 70;
@@ -105,7 +100,7 @@ export function computeCycleLayout(
105
100
  const hasDesc = !hideDescriptions && node.description.length > 0;
106
101
  const labelWidth = Math.max(
107
102
  MIN_NODE_WIDTH,
108
- node.label.length * LABEL_CHAR_W + NODE_PAD_X * 2
103
+ measureText(node.label, LABEL_FONT_SIZE) + NODE_PAD_X * 2
109
104
  );
110
105
 
111
106
  if (circleNodes) {
@@ -422,9 +417,13 @@ function wrapDescForWidth(
422
417
  description: readonly string[],
423
418
  nodeWidth: number
424
419
  ): WrappedDescLine[] {
425
- const textWidth = nodeWidth - NODE_PAD_X * 2;
426
- const charsPerLine = Math.max(8, Math.floor(textWidth / DESC_CHAR_W));
427
- return wrapDescriptionLines([...description], charsPerLine);
420
+ // Wrap to a real pixel budget. `wrapDescriptionLines` is bullet-aware (it
421
+ // emits bullet-first/cont kinds the renderer relies on), so feed it a pixel
422
+ // `lengthFn` + pixel limit instead of a character count.
423
+ const textWidth = Math.max(40, nodeWidth - NODE_PAD_X * 2);
424
+ return wrapDescriptionLines([...description], textWidth, (s) =>
425
+ measureText(s, DESC_FONT_SIZE)
426
+ );
428
427
  }
429
428
 
430
429
  // ── Renderer-aligned font/line-height clamps ──
@@ -463,11 +462,12 @@ function renderedDescNodeHeight(numLines: number, scale: number): number {
463
462
  // ── Edge-label wrapping (shared with renderer) ──
464
463
 
465
464
  /**
466
- * Maximum characters per line for edge labels and edge descriptions.
465
+ * Maximum pixel width per line for edge labels and edge descriptions.
467
466
  * Long single-line text gets wrapped to multiple lines so it doesn't
468
- * shoot off-canvas when positioned at a cycle quadrant.
467
+ * shoot off-canvas when positioned at a cycle quadrant. ~197px ≈ the old
468
+ * 32-char budget at the 11px edge-label font.
469
469
  */
470
- export const EDGE_LABEL_MAX_CHARS = 32;
470
+ export const EDGE_LABEL_MAX_WIDTH = 197;
471
471
 
472
472
  /**
473
473
  * Wrap an edge label string + description lines into rendered lines.
@@ -478,37 +478,18 @@ export const EDGE_LABEL_MAX_CHARS = 32;
478
478
  export function wrapEdgeLabelText(
479
479
  label: string | undefined,
480
480
  description: readonly string[],
481
- maxChars: number = EDGE_LABEL_MAX_CHARS
481
+ maxWidth: number = EDGE_LABEL_MAX_WIDTH
482
482
  ): { labelLines: string[]; descLines: string[] } {
483
- const labelLines = label ? wrapLines([label], maxChars) : [];
483
+ const labelLines = label
484
+ ? wrapTextToWidth(label, EDGE_LABEL_FONT_SIZE, maxWidth)
485
+ : [];
484
486
  const descLines: string[] = [];
485
487
  for (const d of description) {
486
- descLines.push(...wrapLines([d], maxChars));
488
+ descLines.push(...wrapTextToWidth(d, EDGE_LABEL_FONT_SIZE, maxWidth));
487
489
  }
488
490
  return { labelLines, descLines };
489
491
  }
490
492
 
491
- // ── Helper: word-wrap lines ──
492
-
493
- function wrapLines(lines: readonly string[], charsPerLine: number): string[] {
494
- const result: string[] = [];
495
- for (const line of lines) {
496
- const words = line.split(/\s+/);
497
- let current = '';
498
- for (const word of words) {
499
- const test = current ? `${current} ${word}` : word;
500
- if (test.length > charsPerLine && current) {
501
- result.push(current);
502
- current = word;
503
- } else {
504
- current = test;
505
- }
506
- }
507
- if (current) result.push(current);
508
- }
509
- return result;
510
- }
511
-
512
493
  // ── Helper: circle node dimensions ──
513
494
 
514
495
  function computeCircleNodeDims(
@@ -516,7 +497,7 @@ function computeCircleNodeDims(
516
497
  hasDesc: boolean
517
498
  ): { width: number; height: number; wrappedDesc: WrappedDescLine[] } {
518
499
  if (!hasDesc) {
519
- const textW = node.label.length * CIRCLE_LABEL_CHAR_W;
500
+ const textW = measureText(node.label, CIRCLE_LABEL_FONT_SIZE);
520
501
  const r = Math.max(MIN_CIRCLE_RADIUS, textW / 2 + CIRCLE_PAD);
521
502
  return { width: r * 2, height: r * 2, wrappedDesc: [] };
522
503
  }
@@ -529,7 +510,7 @@ function computeCircleNodeDims(
529
510
  const textBlockH = totalLines * DESC_LINE_HEIGHT + CIRCLE_PAD;
530
511
 
531
512
  if (textBlockH / 2 <= r * 0.85) {
532
- const labelW = node.label.length * CIRCLE_LABEL_CHAR_W;
513
+ const labelW = measureText(node.label, CIRCLE_LABEL_FONT_SIZE);
533
514
  const labelY = -textBlockH / 2 + DESC_LINE_HEIGHT;
534
515
  const availW = 2 * Math.sqrt(Math.max(0, r * r - labelY * labelY));
535
516
  if (labelW <= availW - CIRCLE_PAD) {
@@ -563,29 +544,32 @@ function wrapLinesForCircle(
563
544
  descriptions: readonly string[],
564
545
  radius: number
565
546
  ): string[] {
566
- // First pass: wrap with center-width to estimate line count
547
+ // First pass: wrap to the center pixel-width to estimate line count.
567
548
  const centerWidth = radius * 2 * 0.75;
568
- const centerChars = Math.max(8, Math.floor(centerWidth / DESC_CHAR_W));
569
- const roughWrapped = wrapLines(descriptions, centerChars);
549
+ const roughWrapped = descriptions.flatMap((d) =>
550
+ wrapTextToWidth(d, DESC_FONT_SIZE, centerWidth)
551
+ );
570
552
  const totalLines = 1 + roughWrapped.length; // +1 for label line
571
553
  const blockH = totalLines * DESC_LINE_HEIGHT;
572
554
 
573
- // Second pass: re-wrap each source line with position-aware width
555
+ // Second pass: re-wrap each source line with a position-aware pixel width
556
+ // wider near the circle's vertical center, narrower toward the edges.
574
557
  const result: string[] = [];
575
558
  let lineIdx = 1; // start after label line
576
559
  for (const srcLine of descriptions) {
577
- const words = srcLine.split(/\s+/);
560
+ const words = srcLine.split(/\s+/).filter((w) => w.length > 0);
578
561
  let current = '';
579
562
  for (const word of words) {
580
- // Compute available width at this line's y position
563
+ // Compute available pixel width at this line's y position.
581
564
  const y = -blockH / 2 + (lineIdx + 0.5) * DESC_LINE_HEIGHT;
582
565
  const rSq = radius * radius;
583
- const availPx =
584
- y * y < rSq ? 2 * Math.sqrt(rSq - y * y) - CIRCLE_PAD * 2 : centerWidth;
585
- const maxChars = Math.max(6, Math.floor(availPx / DESC_CHAR_W));
566
+ const availPx = Math.max(
567
+ 20,
568
+ y * y < rSq ? 2 * Math.sqrt(rSq - y * y) - CIRCLE_PAD * 2 : centerWidth
569
+ );
586
570
 
587
571
  const test = current ? `${current} ${word}` : word;
588
- if (test.length > maxChars && current) {
572
+ if (measureText(test, DESC_FONT_SIZE) > availPx && current) {
589
573
  result.push(current);
590
574
  lineIdx++;
591
575
  current = word;
@@ -725,16 +709,18 @@ function computeEdgePaths(
725
709
  edge.description
726
710
  );
727
711
  const lineCount = labelLines.length + descLines.length;
728
- let maxCharLen = 0;
729
- for (const l of labelLines) maxCharLen = Math.max(maxCharLen, l.length);
730
- for (const l of descLines) maxCharLen = Math.max(maxCharLen, l.length);
712
+ let labelPxW = 0;
713
+ for (const l of labelLines)
714
+ labelPxW = Math.max(labelPxW, measureText(l, EDGE_LABEL_FONT_SIZE));
715
+ for (const l of descLines)
716
+ labelPxW = Math.max(labelPxW, measureText(l, EDGE_LABEL_FONT_SIZE));
731
717
  const { labelX, labelY, labelAngle } = computeEdgeLabelPosition(
732
718
  midAngle,
733
719
  radius,
734
720
  cx,
735
721
  cy,
736
722
  lineCount,
737
- maxCharLen,
723
+ labelPxW,
738
724
  layoutNodes
739
725
  );
740
726
  const layoutEdge: CycleLayoutEdge = {
@@ -769,10 +755,10 @@ function computeEdgeLabelPosition(
769
755
  cx: number,
770
756
  cy: number,
771
757
  lineCount: number,
772
- maxCharLen: number,
758
+ labelPxW: number,
773
759
  layoutNodes: CycleLayoutNode[]
774
760
  ): { labelX: number; labelY: number; labelAngle: number } {
775
- if (lineCount === 0 || maxCharLen === 0) {
761
+ if (lineCount === 0 || labelPxW === 0) {
776
762
  return {
777
763
  labelX: cx + radius * Math.cos(midAngle),
778
764
  labelY: cy + radius * Math.sin(midAngle),
@@ -781,7 +767,7 @@ function computeEdgeLabelPosition(
781
767
  }
782
768
 
783
769
  const EDGE_LABEL_CORNER_OFFSET = 10;
784
- const labelW = maxCharLen * EDGE_LABEL_CHAR_W;
770
+ const labelW = labelPxW;
785
771
  const labelH = lineCount * 15;
786
772
  const cosT = Math.cos(midAngle);
787
773
  const sinT = Math.sin(midAngle);
@@ -867,9 +853,6 @@ function computeEdgeLabelPosition(
867
853
  };
868
854
  }
869
855
 
870
- /** Estimated character width at 11px edge label font. */
871
- const EDGE_LABEL_CHAR_W = 7;
872
-
873
856
  /**
874
857
  * Check if edge labels overflow the canvas and return a reduced radius if needed.
875
858
  * Returns null if everything fits.
@@ -910,12 +893,12 @@ function fitToCanvas(
910
893
  le.label,
911
894
  edge.description
912
895
  );
913
- let maxLineLen = 0;
914
- for (const l of labelLines) maxLineLen = Math.max(maxLineLen, l.length);
915
- for (const l of descLines) maxLineLen = Math.max(maxLineLen, l.length);
916
- if (maxLineLen === 0) continue;
917
-
918
- const textWidth = maxLineLen * EDGE_LABEL_CHAR_W;
896
+ let textWidth = 0;
897
+ for (const l of labelLines)
898
+ textWidth = Math.max(textWidth, measureText(l, EDGE_LABEL_FONT_SIZE));
899
+ for (const l of descLines)
900
+ textWidth = Math.max(textWidth, measureText(l, EDGE_LABEL_FONT_SIZE));
901
+ if (textWidth === 0) continue;
919
902
 
920
903
  // Determine text-anchor direction from label angle (mirrors renderer logic)
921
904
  const normAngle =
@@ -31,6 +31,7 @@ import {
31
31
  } from './types';
32
32
  import { computeCycleLayout, wrapEdgeLabelText } from './layout';
33
33
  import { ScaleContext } from '../utils/scaling';
34
+ import { measureText } from '../utils/text-measure';
34
35
 
35
36
  // ── Constants ────────────────────────────────────────────────
36
37
  const NODE_FONT_SIZE = 13;
@@ -502,12 +503,16 @@ export function renderCycle(
502
503
  const anchor = isRight ? 'start' : isLeft ? 'end' : 'middle';
503
504
 
504
505
  const lineCount = labelLines.length + descLines.length;
505
- let maxCharLen = 0;
506
- for (const l of labelLines) maxCharLen = Math.max(maxCharLen, l.length);
507
- for (const l of descLines) maxCharLen = Math.max(maxCharLen, l.length);
508
-
509
- const edgeCharW = Math.max(4, 7 * layout.scale);
510
- const bgW = maxCharLen * edgeCharW + 12;
506
+ // Measure the widest rendered line in pixels at the scaled edge-label font
507
+ // so the background box matches the actual ink (same measurer the layout
508
+ // uses to size + place the label).
509
+ let maxLineW = 0;
510
+ for (const l of labelLines)
511
+ maxLineW = Math.max(maxLineW, measureText(l, scaledEdgeLabelFont));
512
+ for (const l of descLines)
513
+ maxLineW = Math.max(maxLineW, measureText(l, scaledDescFont));
514
+
515
+ const bgW = maxLineW + 12;
511
516
  const bgH = lineCount * scaledEdgeLineH + 6;
512
517
  const bgX = isRight
513
518
  ? le.labelX - 4