@diagrammo/dgmo 0.25.5 → 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 (139) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4255 -2756
  3. package/dist/advanced.d.cts +285 -59
  4. package/dist/advanced.d.ts +285 -59
  5. package/dist/advanced.js +4253 -2750
  6. package/dist/auto.cjs +4051 -2589
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4051 -2589
  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 +4076 -2591
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4076 -2591
  18. package/dist/internal.cjs +4255 -2756
  19. package/dist/internal.d.cts +285 -59
  20. package/dist/internal.d.ts +285 -59
  21. package/dist/internal.js +4253 -2750
  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 +3 -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 -1
  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-types.ts +0 -1
  48. package/src/completion.ts +106 -51
  49. package/src/cycle/layout.ts +55 -72
  50. package/src/cycle/renderer.ts +11 -6
  51. package/src/d3.ts +78 -117
  52. package/src/diagnostics.ts +16 -0
  53. package/src/echarts.ts +46 -33
  54. package/src/editor/keywords.ts +4 -0
  55. package/src/er/layout.ts +114 -22
  56. package/src/er/parser.ts +28 -1
  57. package/src/er/renderer.ts +55 -2
  58. package/src/er/types.ts +3 -0
  59. package/src/gantt/renderer.ts +46 -38
  60. package/src/gantt/resolver.ts +9 -2
  61. package/src/graph/edge-spline.ts +29 -0
  62. package/src/graph/flowchart-parser.ts +35 -2
  63. package/src/graph/flowchart-renderer.ts +80 -52
  64. package/src/graph/layout.ts +206 -23
  65. package/src/graph/notes.ts +21 -0
  66. package/src/graph/state-parser.ts +26 -1
  67. package/src/graph/state-renderer.ts +80 -52
  68. package/src/graph/types.ts +13 -0
  69. package/src/index.ts +1 -1
  70. package/src/infra/layout.ts +46 -26
  71. package/src/infra/parser.ts +1 -1
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +57 -12
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1196 -90
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/parser.ts +0 -14
  101. package/src/pert/renderer.ts +30 -43
  102. package/src/pyramid/renderer.ts +4 -5
  103. package/src/raci/renderer.ts +42 -70
  104. package/src/render.ts +1 -1
  105. package/src/ring/renderer.ts +1 -2
  106. package/src/sequence/parser.ts +100 -22
  107. package/src/sequence/renderer.ts +75 -50
  108. package/src/sitemap/layout.ts +27 -19
  109. package/src/sitemap/renderer.ts +12 -5
  110. package/src/tech-radar/renderer.ts +11 -35
  111. package/src/utils/arrow-markers.ts +51 -0
  112. package/src/utils/fit-canvas.ts +64 -0
  113. package/src/utils/legend-constants.ts +8 -54
  114. package/src/utils/legend-d3.ts +10 -7
  115. package/src/utils/legend-layout.ts +7 -4
  116. package/src/utils/legend-types.ts +10 -4
  117. package/src/utils/note-box/constants.ts +25 -0
  118. package/src/utils/note-box/index.ts +11 -0
  119. package/src/utils/note-box/metrics.ts +90 -0
  120. package/src/utils/note-box/svg.ts +331 -0
  121. package/src/utils/notes/bounds.ts +30 -0
  122. package/src/utils/notes/build.ts +131 -0
  123. package/src/utils/notes/index.ts +18 -0
  124. package/src/utils/notes/model.ts +19 -0
  125. package/src/utils/notes/parse.ts +131 -0
  126. package/src/utils/notes/place.ts +177 -0
  127. package/src/utils/notes/resolve.ts +88 -0
  128. package/src/utils/number-format.ts +36 -0
  129. package/src/utils/parsing.ts +41 -0
  130. package/src/utils/reserved-key-registry.ts +4 -0
  131. package/src/utils/text-measure.ts +122 -0
  132. package/src/wireframe/layout.ts +4 -2
  133. package/src/wireframe/renderer.ts +8 -6
  134. package/src/palettes/dracula.ts +0 -68
  135. package/src/palettes/gruvbox.ts +0 -85
  136. package/src/palettes/monokai.ts +0 -68
  137. package/src/palettes/one-dark.ts +0 -70
  138. package/src/palettes/rose-pine.ts +0 -84
  139. 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,
@@ -15,7 +15,6 @@ export type ChartType = string;
15
15
  export interface DiagramSymbols {
16
16
  kind: ChartType;
17
17
  entities: string[]; // table names, node IDs, class names, etc.
18
- keywords: string[]; // diagram-specific reserved words
19
18
  /**
20
19
  * Map of alias-literal → canonical entity name, collected from
21
20
  * `Name as <alias>` declarations in the document. Editor surfaces
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',
@@ -648,6 +662,69 @@ export const ENTITY_TYPES = new Map<string, string[]>([
648
662
  ],
649
663
  ]);
650
664
 
665
+ // ============================================================
666
+ // Structural keywords for line-leading completion
667
+ // ============================================================
668
+
669
+ /**
670
+ * Chart-type-specific structural keywords offered on an empty/start-of-line in
671
+ * the data zone (block openers like `loop`, section headers like `containers`,
672
+ * the `tag` block declaration, etc.). This is the single source of truth for
673
+ * the editor's structural-keyword popup — every entry MUST be a token the
674
+ * corresponding parser actually recognizes (validated by the
675
+ * completion-conformance suite). Do NOT add removed/diagnostic-only tokens
676
+ * (e.g. cycle's `no-descriptions`) or tokens the parser ignores.
677
+ *
678
+ * Chart types not listed here have no structural keywords (most data charts).
679
+ */
680
+ export const STRUCTURAL_KEYWORDS = new Map<string, string[]>([
681
+ ['sequence', ['if', 'else', 'loop', 'parallel', 'note', 'tag']],
682
+ ['gantt', ['era', 'marker', 'holiday', 'workweek', 'parallel', 'tag']],
683
+ ['c4', ['containers', 'components', 'deployment', 'tag']],
684
+ ['timeline', ['era', 'marker', 'tag']],
685
+ ['org', ['tag']],
686
+ ['kanban', ['tag']],
687
+ ['sitemap', ['tag']],
688
+ ['infra', ['tag']],
689
+ ['pert', ['tag']],
690
+ ['mindmap', ['tag']],
691
+ ['boxes-and-lines', ['tag']],
692
+ ['er', ['tag']],
693
+ ['cycle', ['direction-counterclockwise', 'circle-nodes']],
694
+ ['journey-map', ['persona', 'tag']],
695
+ ['raci', ['roles']],
696
+ ['tech-radar', ['rings']],
697
+ [
698
+ 'wireframe',
699
+ [
700
+ 'nav',
701
+ 'tabs',
702
+ 'table',
703
+ 'image',
704
+ 'modal',
705
+ 'skeleton',
706
+ 'alert',
707
+ 'progress',
708
+ 'chart',
709
+ 'mobile',
710
+ 'tag',
711
+ ],
712
+ ],
713
+ ['class', ['abstract', 'interface', 'enum', 'extends', 'implements']],
714
+ ]);
715
+
716
+ /**
717
+ * Chart types that support `tag` block declarations (and thus the
718
+ * `alias`/`default` sub-keywords inside a tag block). Derived from
719
+ * STRUCTURAL_KEYWORDS so the two can never drift — a chart supports tag blocks
720
+ * iff it offers the `tag` keyword.
721
+ */
722
+ export const TAG_SUPPORTING_TYPES: ReadonlySet<string> = new Set(
723
+ [...STRUCTURAL_KEYWORDS]
724
+ .filter(([, kws]) => kws.includes('tag'))
725
+ .map(([type]) => type)
726
+ );
727
+
651
728
  // ============================================================
652
729
  // Pipe metadata for inline `| key value` on data lines
653
730
  // ============================================================
@@ -992,7 +1069,6 @@ function extractSequenceSymbols(docText: string): DiagramSymbols {
992
1069
  return {
993
1070
  kind: 'sequence',
994
1071
  entities,
995
- keywords: ['if', 'else', 'loop', 'parallel', 'note'],
996
1072
  };
997
1073
  }
998
1074
 
@@ -1031,7 +1107,7 @@ function extractStateSymbols(docText: string): DiagramSymbols {
1031
1107
  }
1032
1108
  }
1033
1109
 
1034
- return { kind: 'state', entities, keywords: [] };
1110
+ return { kind: 'state', entities };
1035
1111
  }
1036
1112
 
1037
1113
  // ============================================================
@@ -1247,7 +1323,7 @@ function extractSitemapSymbols(docText: string): DiagramSymbols {
1247
1323
  }
1248
1324
  }
1249
1325
 
1250
- return { kind: 'sitemap', entities, keywords: [] };
1326
+ return { kind: 'sitemap', entities };
1251
1327
  }
1252
1328
 
1253
1329
  // ============================================================
@@ -1326,7 +1402,6 @@ function extractC4Symbols(docText: string): DiagramSymbols {
1326
1402
  return {
1327
1403
  kind: 'c4',
1328
1404
  entities,
1329
- keywords: ['containers', 'components', 'deployment'],
1330
1405
  };
1331
1406
  }
1332
1407
 
@@ -1429,7 +1504,7 @@ function extractGanttSymbols(docText: string): DiagramSymbols {
1429
1504
  }
1430
1505
  }
1431
1506
 
1432
- return { kind: 'gantt', entities, keywords: [] };
1507
+ return { kind: 'gantt', entities };
1433
1508
  }
1434
1509
 
1435
1510
  // ============================================================
@@ -1487,7 +1562,7 @@ function extractBoxesAndLinesSymbols(docText: string): DiagramSymbols {
1487
1562
  if (label && !entities.includes(label)) entities.push(label);
1488
1563
  }
1489
1564
 
1490
- return { kind: 'boxes-and-lines', entities, keywords: [] };
1565
+ return { kind: 'boxes-and-lines', entities };
1491
1566
  }
1492
1567
 
1493
1568
  // ============================================================
@@ -1540,7 +1615,7 @@ function extractOrgSymbols(docText: string): DiagramSymbols {
1540
1615
  if (label && !entities.includes(label)) entities.push(label);
1541
1616
  }
1542
1617
 
1543
- return { kind: 'org', entities, keywords: [] };
1618
+ return { kind: 'org', entities };
1544
1619
  }
1545
1620
 
1546
1621
  // ============================================================
@@ -1592,7 +1667,7 @@ function extractKanbanSymbols(docText: string): DiagramSymbols {
1592
1667
  }
1593
1668
  }
1594
1669
 
1595
- return { kind: 'kanban', entities, keywords: [] };
1670
+ return { kind: 'kanban', entities };
1596
1671
  }
1597
1672
 
1598
1673
  // ============================================================
@@ -1635,7 +1710,7 @@ function extractMindmapSymbols(docText: string): DiagramSymbols {
1635
1710
  if (label && !entities.includes(label)) entities.push(label);
1636
1711
  }
1637
1712
 
1638
- return { kind: 'mindmap', entities, keywords: [] };
1713
+ return { kind: 'mindmap', entities };
1639
1714
  }
1640
1715
 
1641
1716
  // ============================================================
@@ -1668,7 +1743,7 @@ function extractPyramidSymbols(docText: string): DiagramSymbols {
1668
1743
  if (label && !entities.includes(label)) entities.push(label);
1669
1744
  }
1670
1745
 
1671
- return { kind: 'pyramid', entities, keywords: ['inverted'] };
1746
+ return { kind: 'pyramid', entities };
1672
1747
  }
1673
1748
 
1674
1749
  // ============================================================
@@ -1701,7 +1776,7 @@ function extractRingSymbols(docText: string): DiagramSymbols {
1701
1776
  if (label && !entities.includes(label)) entities.push(label);
1702
1777
  }
1703
1778
 
1704
- return { kind: 'ring', entities, keywords: [] };
1779
+ return { kind: 'ring', entities };
1705
1780
  }
1706
1781
 
1707
1782
  // ============================================================
@@ -1736,7 +1811,7 @@ function extractArcSymbols(docText: string): DiagramSymbols {
1736
1811
  }
1737
1812
  }
1738
1813
 
1739
- return { kind: 'arc', entities, keywords: [] };
1814
+ return { kind: 'arc', entities };
1740
1815
  }
1741
1816
 
1742
1817
  // ============================================================
@@ -1775,7 +1850,7 @@ function extractSankeySymbols(docText: string): DiagramSymbols {
1775
1850
  }
1776
1851
  }
1777
1852
 
1778
- return { kind: 'sankey', entities, keywords: [] };
1853
+ return { kind: 'sankey', entities };
1779
1854
  }
1780
1855
 
1781
1856
  // ============================================================
@@ -1835,7 +1910,7 @@ function extractTimelineSymbols(docText: string): DiagramSymbols {
1835
1910
  if (label && !entities.includes(label)) entities.push(label);
1836
1911
  }
1837
1912
 
1838
- return { kind: 'timeline', entities, keywords: ['era', 'marker'] };
1913
+ return { kind: 'timeline', entities };
1839
1914
  }
1840
1915
 
1841
1916
  // ============================================================
@@ -1867,7 +1942,7 @@ function extractVennSymbols(docText: string): DiagramSymbols {
1867
1942
  if (label && !entities.includes(label)) entities.push(label);
1868
1943
  }
1869
1944
 
1870
- return { kind: 'venn', entities, keywords: [] };
1945
+ return { kind: 'venn', entities };
1871
1946
  }
1872
1947
 
1873
1948
  // ============================================================
@@ -1901,7 +1976,7 @@ function extractQuadrantSymbols(docText: string): DiagramSymbols {
1901
1976
  if (label && !entities.includes(label)) entities.push(label);
1902
1977
  }
1903
1978
 
1904
- return { kind: 'quadrant', entities, keywords: [] };
1979
+ return { kind: 'quadrant', entities };
1905
1980
  }
1906
1981
 
1907
1982
  // ============================================================
@@ -1936,7 +2011,7 @@ function extractSlopeSymbols(docText: string): DiagramSymbols {
1936
2011
  if (label && !entities.includes(label)) entities.push(label);
1937
2012
  }
1938
2013
 
1939
- return { kind: 'slope', entities, keywords: ['period'] };
2014
+ return { kind: 'slope', entities };
1940
2015
  }
1941
2016
 
1942
2017
  // ============================================================
@@ -1983,7 +2058,7 @@ function extractDataChartSymbols(docText: string): DiagramSymbols {
1983
2058
  }
1984
2059
  }
1985
2060
 
1986
- return { kind: chartType, entities, keywords: [] };
2061
+ return { kind: chartType, entities };
1987
2062
  }
1988
2063
 
1989
2064
  // ============================================================
@@ -2034,23 +2109,6 @@ registerExtractor('chord', extractDataChartSymbols);
2034
2109
 
2035
2110
  function extractTechRadarSymbols(docText: string): DiagramSymbols {
2036
2111
  const entities: string[] = [];
2037
- const keywords: string[] = [
2038
- 'rings',
2039
- 'quadrant',
2040
- 'ring',
2041
- 'trend',
2042
- 'new',
2043
- 'up',
2044
- 'down',
2045
- 'stable',
2046
- 'top-left',
2047
- 'top-right',
2048
- 'bottom-left',
2049
- 'bottom-right',
2050
- 'alias',
2051
- 'aka',
2052
- 'color',
2053
- ];
2054
2112
 
2055
2113
  // Extract ring names and aliases from the rings block
2056
2114
  const lines = docText.split('\n');
@@ -2078,7 +2136,7 @@ function extractTechRadarSymbols(docText: string): DiagramSymbols {
2078
2136
  }
2079
2137
  }
2080
2138
 
2081
- return { kind: 'tech-radar', entities, keywords };
2139
+ return { kind: 'tech-radar', entities };
2082
2140
  }
2083
2141
 
2084
2142
  // ============================================================
@@ -2121,7 +2179,6 @@ function extractCycleSymbols(docText: string): DiagramSymbols {
2121
2179
  return {
2122
2180
  kind: 'cycle',
2123
2181
  entities,
2124
- keywords: ['direction-counterclockwise', 'circle-nodes'],
2125
2182
  };
2126
2183
  }
2127
2184
 
@@ -2169,7 +2226,7 @@ function extractRaciSymbols(docText: string): DiagramSymbols {
2169
2226
  continue;
2170
2227
  }
2171
2228
 
2172
- const indent = line.length - line.trimStart().length;
2229
+ const indent = measureIndent(line);
2173
2230
 
2174
2231
  // Header directives
2175
2232
  if (indent === 0) {
@@ -2218,7 +2275,6 @@ function extractRaciSymbols(docText: string): DiagramSymbols {
2218
2275
  return {
2219
2276
  kind: chartType,
2220
2277
  entities,
2221
- keywords: ['variant', 'roles'],
2222
2278
  };
2223
2279
  }
2224
2280
 
@@ -2271,6 +2327,5 @@ function extractJourneyMapSymbols(docText: string): DiagramSymbols {
2271
2327
  return {
2272
2328
  kind: 'journey-map',
2273
2329
  entities,
2274
- keywords: ['persona', 'pain', 'opportunity', 'thought', 'description'],
2275
2330
  };
2276
2331
  }
@@ -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 =