@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/c4/layout.ts CHANGED
@@ -21,8 +21,15 @@ type MutableC4LayoutEdge = Writable<C4LayoutEdge> & {
21
21
  import {
22
22
  LEGEND_PILL_FONT_SIZE,
23
23
  LEGEND_ENTRY_FONT_SIZE,
24
+ LEGEND_HEIGHT,
25
+ LEGEND_PILL_PAD,
26
+ LEGEND_DOT_R,
27
+ LEGEND_ENTRY_DOT_GAP,
28
+ LEGEND_ENTRY_TRAIL,
29
+ LEGEND_CAPSULE_PAD,
24
30
  measureLegendText,
25
31
  } from '../utils/legend-constants';
32
+ import { measureText, wrapTextToWidth } from '../utils/text-measure';
26
33
 
27
34
  /** dagre node label shape after layout(). */
28
35
  interface DagreNodeLabel {
@@ -115,29 +122,23 @@ export interface C4LayoutResult {
115
122
  // Constants
116
123
  // ============================================================
117
124
 
118
- const CHAR_WIDTH = 8;
119
125
  const MIN_NODE_WIDTH = 160;
120
126
  const MAX_NODE_WIDTH = 260;
121
127
  const TYPE_LABEL_HEIGHT = 18;
122
128
  const DIVIDER_GAP = 6;
123
129
  const NAME_HEIGHT = 20;
130
+ const NAME_FONT_SIZE = 14;
124
131
  const DESC_LINE_HEIGHT = 16;
125
- const DESC_CHAR_WIDTH = 6.5;
132
+ const DESC_FONT_SIZE = 11;
126
133
  const CARD_V_PAD = 14;
127
134
  const CARD_H_PAD = 20;
128
135
  const META_LINE_HEIGHT = 16;
129
- const META_CHAR_WIDTH = 6.5;
136
+ const META_FONT_SIZE = 11;
130
137
  const MARGIN = 40;
131
138
  const BOUNDARY_PAD = 40;
132
139
  const GROUP_BOUNDARY_PAD = 24;
133
140
 
134
141
  // Legend constants (match org)
135
- const LEGEND_HEIGHT = 28;
136
- const LEGEND_PILL_PAD = 16;
137
- const LEGEND_DOT_R = 4;
138
- const LEGEND_ENTRY_DOT_GAP = 4;
139
- const LEGEND_ENTRY_TRAIL = 8;
140
- const LEGEND_CAPSULE_PAD = 4;
141
142
 
142
143
  // ============================================================
143
144
  // Post-Layout Crossing Reduction
@@ -640,24 +641,6 @@ function resolveNodeColor(
640
641
  // Node Sizing
641
642
  // ============================================================
642
643
 
643
- function wrapText(text: string, maxWidth: number, charWidth: number): string[] {
644
- const words = text.split(/\s+/);
645
- const lines: string[] = [];
646
- let current = '';
647
-
648
- for (const word of words) {
649
- const test = current ? `${current} ${word}` : word;
650
- if (test.length * charWidth > maxWidth && current) {
651
- lines.push(current);
652
- current = word;
653
- } else {
654
- current = test;
655
- }
656
- }
657
- if (current) lines.push(current);
658
- return lines;
659
- }
660
-
661
644
  /** Keys to exclude from the below-divider metadata display. */
662
645
  const META_EXCLUDE_KEYS = new Set([
663
646
  'description',
@@ -689,7 +672,7 @@ export function computeC4NodeDimensions(
689
672
  options?: { showTechnology?: boolean }
690
673
  ): { width: number; height: number } {
691
674
  // Width: based on name length, clamped
692
- const nameWidth = el.name.length * CHAR_WIDTH + CARD_H_PAD * 2;
675
+ const nameWidth = measureText(el.name, NAME_FONT_SIZE) + CARD_H_PAD * 2;
693
676
  let width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, nameWidth));
694
677
 
695
678
  if (options?.showTechnology) {
@@ -700,7 +683,7 @@ export function computeC4NodeDimensions(
700
683
  const desc = el.description?.join('\n');
701
684
  if (desc) {
702
685
  const contentWidth = width - CARD_H_PAD * 2;
703
- const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
686
+ const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
704
687
  height += lines.length * DESC_LINE_HEIGHT;
705
688
  }
706
689
 
@@ -713,8 +696,7 @@ export function computeC4NodeDimensions(
713
696
  const maxMetaWidth = Math.max(
714
697
  ...metaEntries.map(
715
698
  (e) =>
716
- (e.key.length + 2 + e.value.length) * META_CHAR_WIDTH +
717
- CARD_H_PAD * 2
699
+ measureText(`${e.key}: ${e.value}`, META_FONT_SIZE) + CARD_H_PAD * 2
718
700
  )
719
701
  );
720
702
  if (maxMetaWidth > width) width = Math.min(MAX_NODE_WIDTH, maxMetaWidth);
@@ -730,7 +712,7 @@ export function computeC4NodeDimensions(
730
712
  const desc = el.description?.join('\n');
731
713
  if (desc) {
732
714
  const contentWidth = width - CARD_H_PAD * 2;
733
- const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
715
+ const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
734
716
  height += lines.length * DESC_LINE_HEIGHT;
735
717
  }
736
718
 
package/src/c4/parser.ts CHANGED
@@ -63,11 +63,15 @@ const C4_IS_A_RE =
63
63
  /** Matches relationship arrows: `->`, `~>`, `<->`, `<~>` */
64
64
  const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s*(.+)$/;
65
65
 
66
- /** Labeled arrow relationships: -label->, ~label~>, <-label->, <~label~> */
67
- const C4_LABELED_SYNC_RE = /^-(.+)->\s*(.+)$/;
68
- const C4_LABELED_ASYNC_RE = /^~(.+)~>\s*(.+)$/;
69
- const C4_LABELED_BIDI_SYNC_RE = /^<-(.+)->\s*(.+)$/;
70
- const C4_LABELED_BIDI_ASYNC_RE = /^<~(.+)~>\s*(.+)$/;
66
+ /**
67
+ * Labeled arrow relationships: -label->, ~label~>, <-label->, <~label~>.
68
+ * Label captured lazily so a line splits at the FIRST arrow — matches every
69
+ * other chart parser (which use lazy `.+?`); greedy `.+` split at the last.
70
+ */
71
+ const C4_LABELED_SYNC_RE = /^-(.+?)->\s*(.+)$/;
72
+ const C4_LABELED_ASYNC_RE = /^~(.+?)~>\s*(.+)$/;
73
+ const C4_LABELED_BIDI_SYNC_RE = /^<-(.+?)->\s*(.+)$/;
74
+ const C4_LABELED_BIDI_ASYNC_RE = /^<~(.+?)~>\s*(.+)$/;
71
75
 
72
76
  /** Matches section headers: `containers`, `components`, `deployment` (bare keyword) */
73
77
  const SECTION_HEADER_RE = /^(containers|components|deployment)\s*$/i;
@@ -9,6 +9,7 @@ import type { PaletteColors } from '../palettes';
9
9
  import { contrastText, mix, shapeFill } from '../palettes/color-utils';
10
10
  import { renderInlineText } from '../utils/inline-markdown';
11
11
  import { preprocessDescriptionLine } from '../utils/description-helpers';
12
+ import { measureText, wrapTextToWidth } from '../utils/text-measure';
12
13
  import type { ParsedC4 } from './types';
13
14
  import type { C4LayoutResult, C4LayoutEdge } from './layout';
14
15
  import { parseC4 } from './parser';
@@ -35,7 +36,6 @@ const TYPE_FONT_SIZE = 10;
35
36
  const NAME_FONT_SIZE = 14;
36
37
  const DESC_FONT_SIZE = 11;
37
38
  const DESC_LINE_HEIGHT = 16;
38
- const DESC_CHAR_WIDTH = 6.5;
39
39
  const EDGE_LABEL_FONT_SIZE = 11;
40
40
  const TECH_FONT_SIZE = 10;
41
41
  const EDGE_STROKE_WIDTH = 1.5;
@@ -47,7 +47,6 @@ const TYPE_LABEL_HEIGHT = 18;
47
47
  const DIVIDER_GAP = 6;
48
48
  const NAME_HEIGHT = 20;
49
49
  const META_FONT_SIZE = 11;
50
- const META_CHAR_WIDTH = 6.5;
51
50
  const META_LINE_HEIGHT = 16;
52
51
  const BOUNDARY_LABEL_FONT_SIZE = 12;
53
52
  const BOUNDARY_STROKE_WIDTH = 1.5;
@@ -111,28 +110,6 @@ function nodeStroke(
111
110
  return typeColor(type, palette, nodeColor);
112
111
  }
113
112
 
114
- // ============================================================
115
- // Text wrapping helper
116
- // ============================================================
117
-
118
- function wrapText(text: string, maxWidth: number, charWidth: number): string[] {
119
- const words = text.split(/\s+/);
120
- const lines: string[] = [];
121
- let current = '';
122
-
123
- for (const word of words) {
124
- const test = current ? `${current} ${word}` : word;
125
- if (test.length * charWidth > maxWidth && current) {
126
- lines.push(current);
127
- current = word;
128
- } else {
129
- current = test;
130
- }
131
- }
132
- if (current) lines.push(current);
133
- return lines;
134
- }
135
-
136
113
  // ============================================================
137
114
  // Edge path generator
138
115
  // ============================================================
@@ -394,8 +371,11 @@ export function renderC4Context(
394
371
  const techText = edge.technology ? `[${edge.technology}]` : '';
395
372
 
396
373
  // Background rect
397
- const textLen = Math.max(labelText.length, techText.length);
398
- const bgW = textLen * 7 + 12;
374
+ const textW = Math.max(
375
+ measureText(labelText, EDGE_LABEL_FONT_SIZE),
376
+ measureText(techText, TECH_FONT_SIZE)
377
+ );
378
+ const bgW = textW + 12;
399
379
  const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
400
380
 
401
381
  edgeG
@@ -522,8 +502,7 @@ export function renderC4Context(
522
502
  // Name (bold) — above divider
523
503
  if (node.type === 'person') {
524
504
  // Person icon to the left of name
525
- const nameCharWidth = NAME_FONT_SIZE * 0.6;
526
- const textWidth = node.name.length * nameCharWidth;
505
+ const textWidth = measureText(node.name, NAME_FONT_SIZE);
527
506
  const gap = 6;
528
507
  const totalWidth = PERSON_ICON_W + gap + textWidth;
529
508
  const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
@@ -577,7 +556,11 @@ export function renderC4Context(
577
556
  // Description (wrapping, inline markdown) — must contrast against fill
578
557
  if (node.description) {
579
558
  const contentWidth = w - CARD_H_PAD * 2;
580
- const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
559
+ const lines = wrapTextToWidth(
560
+ node.description,
561
+ DESC_FONT_SIZE,
562
+ contentWidth
563
+ );
581
564
  for (const line of lines) {
582
565
  const textEl = nodeG
583
566
  .append('text')
@@ -840,8 +823,11 @@ function renderEdges(
840
823
  if (edge.label || edge.technology) {
841
824
  const labelText = edge.label ?? '';
842
825
  const techText = edge.technology ? `[${edge.technology}]` : '';
843
- const textLen = Math.max(labelText.length, techText.length);
844
- const bgW = textLen * 7 + 12;
826
+ const textW = Math.max(
827
+ measureText(labelText, EDGE_LABEL_FONT_SIZE),
828
+ measureText(techText, TECH_FONT_SIZE)
829
+ );
830
+ const bgW = textW + 12;
845
831
  const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
846
832
 
847
833
  pendingLabels.push({
@@ -1508,12 +1494,12 @@ export function renderC4Containers(
1508
1494
  if (layout.boundary) {
1509
1495
  const b = layout.boundary;
1510
1496
  const labelText = `${b.label} \u2014 ${b.typeLabel}`;
1511
- const w = labelText.length * 7 + 12;
1497
+ const w = measureText(labelText, BOUNDARY_LABEL_FONT_SIZE) + 12;
1512
1498
  const h = BOUNDARY_LABEL_FONT_SIZE + 4;
1513
1499
  boundaryLabelObstacles.push({ x: b.x + 12, y: b.y + 16 - h + 4, w, h });
1514
1500
  }
1515
1501
  for (const gb of layout.groupBoundaries) {
1516
- const w = gb.label.length * 7 + 12;
1502
+ const w = measureText(gb.label, BOUNDARY_LABEL_FONT_SIZE) + 12;
1517
1503
  const h = BOUNDARY_LABEL_FONT_SIZE + 4;
1518
1504
  boundaryLabelObstacles.push({ x: gb.x + 10, y: gb.y + 14 - h + 4, w, h });
1519
1505
  }
@@ -1621,8 +1607,7 @@ export function renderC4Containers(
1621
1607
 
1622
1608
  // Name (bold)
1623
1609
  if (node.type === 'person') {
1624
- const nameCharWidth = NAME_FONT_SIZE * 0.6;
1625
- const textWidth = node.name.length * nameCharWidth;
1610
+ const textWidth = measureText(node.name, NAME_FONT_SIZE);
1626
1611
  const gap = 6;
1627
1612
  const totalWidth = PERSON_ICON_W + gap + textWidth;
1628
1613
  const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
@@ -1666,7 +1651,11 @@ export function renderC4Containers(
1666
1651
  // Description (above divider, inline markdown) — contrast against fill
1667
1652
  if (node.description) {
1668
1653
  const contentWidth = w - CARD_H_PAD * 2;
1669
- const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1654
+ const lines = wrapTextToWidth(
1655
+ node.description,
1656
+ DESC_FONT_SIZE,
1657
+ contentWidth
1658
+ );
1670
1659
  for (const line of lines) {
1671
1660
  const textEl = nodeG
1672
1661
  .append('text')
@@ -1702,8 +1691,10 @@ export function renderC4Containers(
1702
1691
 
1703
1692
  yPos += DIVIDER_GAP;
1704
1693
 
1705
- const maxKeyLen = Math.max(...metaEntries.map((e) => e.key.length));
1706
- const valueX = -w / 2 + CARD_H_PAD + (maxKeyLen + 2) * META_CHAR_WIDTH;
1694
+ const maxKeyWidth = Math.max(
1695
+ ...metaEntries.map((e) => measureText(`${e.key}: `, META_FONT_SIZE))
1696
+ );
1697
+ const valueX = -w / 2 + CARD_H_PAD + maxKeyWidth;
1707
1698
 
1708
1699
  for (const entry of metaEntries) {
1709
1700
  // Key — contrast against fill (textMuted is illegible on solid fills)
@@ -1750,7 +1741,11 @@ export function renderC4Containers(
1750
1741
  // Description (inline markdown) — contrast against fill
1751
1742
  if (node.description) {
1752
1743
  const contentWidth = w - CARD_H_PAD * 2;
1753
- const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1744
+ const lines = wrapTextToWidth(
1745
+ node.description,
1746
+ DESC_FONT_SIZE,
1747
+ contentWidth
1748
+ );
1754
1749
  for (const line of lines) {
1755
1750
  const textEl = nodeG
1756
1751
  .append('text')
@@ -1,4 +1,11 @@
1
1
  import dagre from '@dagrejs/dagre';
2
+ import { measureText } from '../utils/text-measure';
3
+ import {
4
+ resolveNotes,
5
+ buildPlacedNotes,
6
+ noteCanvasShift,
7
+ type PlacedNote,
8
+ } from '../utils/notes';
2
9
  import type { ParsedClassDiagram, ClassNode, RelationshipType } from './types';
3
10
 
4
11
  // ============================================================
@@ -13,6 +20,16 @@ export interface ClassLayoutNode extends ClassNode {
13
20
  readonly headerHeight: number;
14
21
  readonly fieldsHeight: number;
15
22
  readonly methodsHeight: number;
23
+ /** A note floated beside this class (never moves the box). */
24
+ readonly note?: PlacedNote;
25
+ }
26
+
27
+ export interface ClassLayoutOptions {
28
+ /**
29
+ * 1-based source line numbers of notes the user has collapsed. A
30
+ * collapsed note renders as a corner badge and reserves no space.
31
+ */
32
+ collapsedNotes?: ReadonlySet<number>;
16
33
  }
17
34
 
18
35
  export interface ClassLayoutEdge {
@@ -36,9 +53,12 @@ export interface ClassLayoutResult {
36
53
  // ============================================================
37
54
 
38
55
  const MIN_WIDTH = 140;
39
- const CHAR_WIDTH = 7.5;
40
56
  const PADDING_X = 24;
41
57
  const HEADER_BASE = 36;
58
+ // Font sizes mirror the renderer: class name at CLASS_FONT_SIZE, members and
59
+ // the modifier badge at MEMBER_FONT_SIZE. Keep these in sync with renderer.ts.
60
+ const CLASS_FONT_SIZE = 13;
61
+ const MEMBER_FONT_SIZE = 11;
42
62
  const MODIFIER_BADGE = 16; // extra height for <<interface>> etc.
43
63
  const MEMBER_LINE_HEIGHT = 18;
44
64
  const COMPARTMENT_PADDING_Y = 8;
@@ -59,10 +79,15 @@ function computeNodeDimensions(node: ClassNode): {
59
79
  const methods = node.members.filter((m) => m.isMethod);
60
80
  const isEnum = node.modifier === 'enum';
61
81
 
62
- // Width: max of class name, member text lengths
63
- let maxTextLen = node.name.length;
82
+ // Width: max rendered pixel width of class name, modifier badge, and members.
83
+ // Measure each at the font size the renderer draws it with.
84
+ let maxTextWidth = measureText(node.name, CLASS_FONT_SIZE);
64
85
  if (node.modifier) {
65
- maxTextLen = Math.max(maxTextLen, `<<${node.modifier}>>`.length);
86
+ // Renderer draws the badge as «modifier» at MEMBER_FONT_SIZE.
87
+ maxTextWidth = Math.max(
88
+ maxTextWidth,
89
+ measureText(`«${node.modifier}»`, MEMBER_FONT_SIZE)
90
+ );
66
91
  }
67
92
  for (const m of node.members) {
68
93
  let memberText = m.name;
@@ -74,9 +99,12 @@ function computeNodeDimensions(node: ClassNode): {
74
99
  }
75
100
  // Add visibility prefix width
76
101
  memberText = `+ ${memberText}`;
77
- maxTextLen = Math.max(maxTextLen, memberText.length);
102
+ maxTextWidth = Math.max(
103
+ maxTextWidth,
104
+ measureText(memberText, MEMBER_FONT_SIZE)
105
+ );
78
106
  }
79
- const width = Math.max(MIN_WIDTH, maxTextLen * CHAR_WIDTH + PADDING_X);
107
+ const width = Math.max(MIN_WIDTH, maxTextWidth + PADDING_X);
80
108
 
81
109
  // Header height
82
110
  const headerHeight = HEADER_BASE + (node.modifier ? MODIFIER_BADGE : 0);
@@ -130,7 +158,8 @@ function computeNodeDimensions(node: ClassNode): {
130
158
  // ============================================================
131
159
 
132
160
  export function layoutClassDiagram(
133
- parsed: ParsedClassDiagram
161
+ parsed: ParsedClassDiagram,
162
+ options?: ClassLayoutOptions
134
163
  ): ClassLayoutResult {
135
164
  if (parsed.classes.length === 0) {
136
165
  return { nodes: [], edges: [], width: 0, height: 0 };
@@ -175,10 +204,40 @@ export function layoutClassDiagram(
175
204
  // Run layout
176
205
  dagre.layout(g);
177
206
 
207
+ // ── Notes ──────────────────────────────────────────────────
208
+ // Resolve note anchors (no diagnostics here — the parser already emitted
209
+ // them). `no-notes` drops the reserved footprint so layout matches an
210
+ // un-annotated diagram. The note floats beside its class WITHOUT moving
211
+ // it (class TB layout → default side right).
212
+ const notesSuppressed = parsed.options?.['no-notes'] === 'on';
213
+ const noteByNode =
214
+ notesSuppressed || !parsed.notes
215
+ ? new Map()
216
+ : resolveNotes(
217
+ parsed.notes,
218
+ parsed.classes.map((c) => ({ id: c.id, label: c.name }))
219
+ );
220
+ const placedNotes = buildPlacedNotes(
221
+ parsed.classes.map((node) => {
222
+ const pos = g.node(node.id);
223
+ return {
224
+ id: node.id,
225
+ x: pos.x,
226
+ y: pos.y,
227
+ width: pos.width,
228
+ height: pos.height,
229
+ };
230
+ }),
231
+ noteByNode,
232
+ 'TB',
233
+ options?.collapsedNotes
234
+ );
235
+
178
236
  // Extract positioned nodes
179
237
  const layoutNodes: ClassLayoutNode[] = parsed.classes.map((node) => {
180
238
  const pos = g.node(node.id);
181
239
  const dims = dimMap.get(node.id)!;
240
+ const note = placedNotes.get(node.id);
182
241
  return {
183
242
  ...node,
184
243
  x: pos.x,
@@ -188,6 +247,7 @@ export function layoutClassDiagram(
188
247
  headerHeight: dims.headerHeight,
189
248
  fieldsHeight: dims.fieldsHeight,
190
249
  methodsHeight: dims.methodsHeight,
250
+ ...(note && { note }),
191
251
  };
192
252
  });
193
253
 
@@ -205,21 +265,61 @@ export function layoutClassDiagram(
205
265
  return layoutEdge;
206
266
  });
207
267
 
208
- // Compute total dimensions
209
- let totalWidth = 0;
210
- let totalHeight = 0;
268
+ // Content bbox over nodes and their (floated) notes. A note placed
269
+ // above/left can land at negative coords; when it does, shift everything
270
+ // so nothing clips. Un-annotated diagrams keep min ≥ 0 → no shift.
271
+ let bbMinX = Infinity;
272
+ let bbMinY = Infinity;
273
+ let bbMaxX = -Infinity;
274
+ let bbMaxY = -Infinity;
275
+ const extend = (l: number, t: number, r: number, b: number): void => {
276
+ if (l < bbMinX) bbMinX = l;
277
+ if (t < bbMinY) bbMinY = t;
278
+ if (r > bbMaxX) bbMaxX = r;
279
+ if (b > bbMaxY) bbMaxY = b;
280
+ };
211
281
  for (const node of layoutNodes) {
212
- const right = node.x + node.width / 2;
213
- const bottom = node.y + node.height / 2;
214
- if (right > totalWidth) totalWidth = right;
215
- if (bottom > totalHeight) totalHeight = bottom;
282
+ extend(
283
+ node.x - node.width / 2,
284
+ node.y - node.height / 2,
285
+ node.x + node.width / 2,
286
+ node.y + node.height / 2
287
+ );
288
+ if (node.note && !node.note.collapsed) {
289
+ extend(
290
+ node.x + node.note.x,
291
+ node.y + node.note.y,
292
+ node.x + node.note.x + node.note.width,
293
+ node.y + node.note.y + node.note.height
294
+ );
295
+ }
296
+ }
297
+ if (!Number.isFinite(bbMinX)) {
298
+ bbMinX = 0;
299
+ bbMinY = 0;
300
+ bbMaxX = 0;
301
+ bbMaxY = 0;
216
302
  }
217
- totalWidth += 40;
218
- totalHeight += 40;
303
+
304
+ const { shiftX, shiftY } = noteCanvasShift(bbMinX, bbMinY);
305
+ const shifted = shiftX !== 0 || shiftY !== 0;
306
+ const finalNodes = shifted
307
+ ? layoutNodes.map((n) => ({ ...n, x: n.x + shiftX, y: n.y + shiftY }))
308
+ : layoutNodes;
309
+ const finalEdges = shifted
310
+ ? layoutEdges.map((e) => ({
311
+ ...e,
312
+ points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
313
+ }))
314
+ : layoutEdges;
315
+
316
+ // Totals: max extent (incl. notes) + the prior 40 px margin.
317
+ const totalWidth = bbMaxX + shiftX + 40;
318
+ const totalHeight = bbMaxY + shiftY + 40;
219
319
 
220
320
  return {
221
- nodes: layoutNodes,
222
- edges: layoutEdges,
321
+ nodes: finalNodes,
322
+ edges: finalEdges,
223
323
  width: totalWidth,
224
324
  height: totalHeight,
225
325
  };
@@ -14,6 +14,7 @@ import {
14
14
  tryParseSharedOption,
15
15
  } from '../utils/parsing';
16
16
  import { normalizeName, displayName } from '../utils/name-normalize';
17
+ import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
17
18
  import type { Writable } from '../utils/brand';
18
19
  import type {
19
20
  ParsedClassDiagram,
@@ -192,6 +193,7 @@ export function parseClassDiagram(
192
193
  };
193
194
 
194
195
  const classMap = new Map<string, Writable<ClassNode>>();
196
+ const notes: DiagramNote[] = [];
195
197
 
196
198
  // Per-parse alias literal → canonical class id (TD-18). Per C8.
197
199
  const nameAliasMap = new Map<string, string>();
@@ -266,6 +268,27 @@ export function parseClassDiagram(
266
268
  // Skip comments
267
269
  if (trimmed.startsWith('//')) continue;
268
270
 
271
+ // Note annotation (top-level): `note <ClassName> [inline body]` + an
272
+ // optional indented body. Checked before options so a note is never
273
+ // swallowed as an option; gated to indent 0 so an indented member
274
+ // starting with "note" stays a member. `note -> X` is excluded.
275
+ if (indent === 0) {
276
+ const noteResult = tryCollectNote(
277
+ lines,
278
+ i,
279
+ indent,
280
+ palette,
281
+ result.diagnostics
282
+ );
283
+ if (noteResult) {
284
+ currentClass = null;
285
+ contentStarted = true;
286
+ if (noteResult.note) notes.push(noteResult.note);
287
+ i = noteResult.lastIndex;
288
+ continue;
289
+ }
290
+ }
291
+
269
292
  // First line: bare chart type + optional title (new syntax)
270
293
  if (!contentStarted && indent === 0 && i === 0) {
271
294
  const firstLine = parseFirstLine(trimmed);
@@ -439,6 +462,18 @@ export function parseClassDiagram(
439
462
  );
440
463
  }
441
464
 
465
+ // Resolve note refs against class names (forward refs OK — runs after all
466
+ // classes parsed). The id→note binding is recomputed in layout; this pass
467
+ // only surfaces unknown/ambiguous/duplicate diagnostics.
468
+ if (notes.length > 0) {
469
+ result.notes = notes;
470
+ resolveNotes(
471
+ notes,
472
+ result.classes.map((c) => ({ id: c.id, label: c.name })),
473
+ result.diagnostics
474
+ );
475
+ }
476
+
442
477
  // Validation
443
478
  if (result.classes.length === 0 && !result.error) {
444
479
  const diag = makeDgmoError(
@@ -28,6 +28,16 @@ import type { ClassLayoutResult } from './layout';
28
28
  import { parseClassDiagram } from './parser';
29
29
  import { layoutClassDiagram } from './layout';
30
30
  import { ScaleContext } from '../utils/scaling';
31
+ import { measureText } from '../utils/text-measure';
32
+ import {
33
+ renderNoteBox,
34
+ renderNoteConnector,
35
+ renderNoteBadge,
36
+ noteConnectorPoints,
37
+ NOTE_BADGE_RADIUS,
38
+ } from '../utils/note-box';
39
+
40
+ type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
31
41
 
32
42
  // ============================================================
33
43
  // Constants
@@ -468,8 +478,7 @@ export function renderClassDiagram(
468
478
  const midIdx = Math.floor(edge.points.length / 2);
469
479
  // Edges are always non-empty (drawn from a valid graph layout).
470
480
  const midPt = edge.points[midIdx]!;
471
- const labelLen = edge.label.length;
472
- const bgW = labelLen * 7 + 8;
481
+ const bgW = measureText(edge.label, sEdgeLabelFontSize) + 8;
473
482
  const bgH = 16;
474
483
 
475
484
  edgeG
@@ -693,6 +702,53 @@ export function renderClassDiagram(
693
702
  }
694
703
  }
695
704
  }
705
+
706
+ // ── Note (floated beside the class, or a collapsed corner badge) ──
707
+ // The class keeps its layout position; the note floats in adjacent
708
+ // space chosen by the collision-aware placer. Coords are node-center
709
+ // -local (the node `<g>` is translated to the center).
710
+ if (node.note) {
711
+ if (node.note.collapsed) {
712
+ renderNoteBadge(
713
+ nodeG as GSelection,
714
+ {
715
+ x: w / 2 - NOTE_BADGE_RADIUS - 3,
716
+ y: -h / 2 + NOTE_BADGE_RADIUS + 3,
717
+ },
718
+ palette,
719
+ {
720
+ isDark,
721
+ ...(node.note.color && { color: node.note.color }),
722
+ lineNumber: node.note.lineNumber,
723
+ endLineNumber: node.note.endLineNumber,
724
+ }
725
+ );
726
+ } else {
727
+ const [cx1, cy1, cx2, cy2] = noteConnectorPoints(
728
+ { width: w, height: h },
729
+ node.note
730
+ );
731
+ renderNoteConnector(nodeG as GSelection, cx1, cy1, cx2, cy2, palette);
732
+ renderNoteBox(
733
+ nodeG as GSelection,
734
+ {
735
+ x: node.note.x,
736
+ y: node.note.y,
737
+ width: node.note.width,
738
+ height: node.note.height,
739
+ },
740
+ node.note.lines,
741
+ palette,
742
+ {
743
+ isDark,
744
+ ...(node.note.color && { color: node.note.color }),
745
+ lineNumber: node.note.lineNumber,
746
+ endLineNumber: node.note.endLineNumber,
747
+ interactive: true,
748
+ }
749
+ );
750
+ }
751
+ }
696
752
  }
697
753
  }
698
754
 
@@ -42,6 +42,7 @@ export interface ClassRelationship {
42
42
  }
43
43
 
44
44
  import type { DgmoError } from '../diagnostics';
45
+ import type { DiagramNote } from '../utils/notes';
45
46
 
46
47
  export interface ParsedClassDiagram {
47
48
  readonly type: 'class';
@@ -50,6 +51,8 @@ export interface ParsedClassDiagram {
50
51
  readonly classes: readonly ClassNode[];
51
52
  readonly relationships: readonly ClassRelationship[];
52
53
  readonly options: Readonly<Record<string, string>>;
54
+ /** Generic node notes (`note <ClassName> …`); resolved in layout. */
55
+ readonly notes?: readonly DiagramNote[];
53
56
  readonly diagnostics: readonly DgmoError[];
54
57
  readonly error: string | null;
55
58
  }