@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
@@ -27,6 +27,7 @@ import {
27
27
  wrapDescriptionLines,
28
28
  type WrappedDescLine,
29
29
  } from '../utils/wrapped-desc';
30
+ import { measureText, truncateText } from '../utils/text-measure';
30
31
  import { resolveSequenceTags } from './tag-resolution';
31
32
  import type { ResolvedTagMap } from './tag-resolution';
32
33
  import { resolveActiveTagGroup } from '../utils/tag-groups';
@@ -67,48 +68,57 @@ const NOTE_PAD_V = 6;
67
68
  const NOTE_FONT_SIZE = 10;
68
69
  const NOTE_LINE_H = 14;
69
70
  const NOTE_GAP = 15;
70
- const NOTE_CHAR_W = 6;
71
71
  const ACTIVATION_WIDTH = 10;
72
72
  const SELF_CALL_HEIGHT = 25;
73
73
  const SELF_CALL_WIDTH = 30;
74
74
  // Actors render their label below the stick figure (at boxH + 14). Their
75
75
  // lifeline starts this far below the box so the dashes clear the label text.
76
76
  const ACTOR_LABEL_CLEARANCE = 22;
77
- function wrapTextLines(text: string, maxChars: number): WrappedDescLine[] {
77
+ function wrapTextLines(
78
+ text: string,
79
+ maxWidth: number,
80
+ fontSize: number
81
+ ): WrappedDescLine[] {
78
82
  // Convert leading "- " to the canonical bullet prefix so the shared wrap
79
83
  // helper can split bullet lines into bullet-first / bullet-cont kinds and
80
84
  // give us hanging-indent alignment on continuation lines.
81
85
  const rawLines = text
82
86
  .split('\n')
83
87
  .map((l) => (l.startsWith('- ') ? '• ' + l.slice(2) : l));
84
- return wrapDescriptionLines(rawLines, maxChars);
88
+ // Drive the shared bullet-aware wrapper by pixel width: passing the note's
89
+ // available text width as the limit and a glyph-accurate measurer as the
90
+ // length function turns its char-count comparison into a true pixel wrap.
91
+ return wrapDescriptionLines(rawLines, maxWidth, (s) =>
92
+ measureText(s, fontSize)
93
+ );
85
94
  }
86
95
 
96
+ /**
97
+ * Available pixel width for a participant label inside a box of the given
98
+ * width (the 10px accounts for the same left/right inset the layout uses).
99
+ */
100
+ const labelTextWidth = (boxW: number): number => boxW - 10;
101
+
87
102
  /**
88
103
  * Split a participant label into multiple lines if it exceeds the box width.
89
104
  * Splits on spaces first, then dashes, then camelCase boundaries.
90
- * Approximate max chars based on font-size 13 (~7.5px per char average).
91
105
  */
92
- const LABEL_CHAR_WIDTH = 7.5;
93
- const LABEL_MAX_CHARS = Math.floor(
94
- (PARTICIPANT_BOX_WIDTH - 10) / LABEL_CHAR_WIDTH
95
- ); // ~14 chars
96
-
97
106
  function splitParticipantLabel(
98
107
  label: string,
99
- maxChars: number = LABEL_MAX_CHARS
108
+ maxWidth: number,
109
+ fontSize: number
100
110
  ): string[] {
101
- if (label.length <= maxChars) return [label];
111
+ if (measureText(label, fontSize) <= maxWidth) return [label];
102
112
 
103
113
  // Split on spaces
104
114
  if (label.includes(' ')) {
105
- return wrapLabelWords(label.split(' '), maxChars);
115
+ return wrapLabelWords(label.split(' '), maxWidth, fontSize);
106
116
  }
107
117
 
108
118
  // Split on dashes/underscores/colons/slashes
109
119
  if (/[-_:/]/.test(label)) {
110
120
  const parts = label.split(/[-_:/]+/);
111
- return wrapLabelWords(parts, maxChars);
121
+ return wrapLabelWords(parts, maxWidth, fontSize);
112
122
  }
113
123
 
114
124
  // Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
@@ -117,19 +127,23 @@ function splitParticipantLabel(
117
127
  .replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
118
128
  .split('\x00');
119
129
  if (camelParts.length > 1) {
120
- return wrapLabelWords(camelParts, maxChars);
130
+ return wrapLabelWords(camelParts, maxWidth, fontSize);
121
131
  }
122
132
 
123
133
  return [label];
124
134
  }
125
135
 
126
- /** Greedily join word parts into lines that fit within maxChars. */
127
- function wrapLabelWords(words: string[], maxChars: number): string[] {
136
+ /** Greedily join word parts into lines that fit within maxWidth pixels. */
137
+ function wrapLabelWords(
138
+ words: string[],
139
+ maxWidth: number,
140
+ fontSize: number
141
+ ): string[] {
128
142
  const lines: string[] = [];
129
143
  let current = '';
130
144
  for (const word of words) {
131
145
  const test = current ? current + word : word;
132
- if (test.length > maxChars && current) {
146
+ if (measureText(test, fontSize) > maxWidth && current) {
133
147
  lines.push(current);
134
148
  current = word;
135
149
  } else {
@@ -929,10 +943,16 @@ export function renderSequenceDiagram(
929
943
  const MAX_BOX_WIDTH = 225;
930
944
  let uniformBoxWidth = PARTICIPANT_BOX_WIDTH;
931
945
  for (const p of participants) {
932
- const lines = splitParticipantLabel(p.label, LABEL_MAX_CHARS);
946
+ const lines = splitParticipantLabel(
947
+ p.label,
948
+ labelTextWidth(PARTICIPANT_BOX_WIDTH),
949
+ LABEL_FONT_SIZE
950
+ );
933
951
  if (lines.length === 0) continue;
934
- const widest = Math.max(...lines.map((l) => l.length));
935
- const labelWidth = widest * LABEL_CHAR_WIDTH + 10;
952
+ const widest = Math.max(
953
+ ...lines.map((l) => measureText(l, LABEL_FONT_SIZE))
954
+ );
955
+ const labelWidth = widest + 10;
936
956
  uniformBoxWidth = Math.max(uniformBoxWidth, labelWidth);
937
957
  }
938
958
  uniformBoxWidth = Math.min(MAX_BOX_WIDTH, uniformBoxWidth);
@@ -964,17 +984,16 @@ export function renderSequenceDiagram(
964
984
  const sNoteFontSize = ctx.text(NOTE_FONT_SIZE);
965
985
  const sNoteLineH = ctx.structural(NOTE_LINE_H);
966
986
  const sNoteGap = ctx.structural(NOTE_GAP);
967
- const sNoteCharW = ctx.text(NOTE_CHAR_W, 4);
968
987
  const sActivationWidth = ctx.structural(ACTIVATION_WIDTH);
969
988
  const sSelfCallHeight = SELF_CALL_HEIGHT;
970
989
  const sSelfCallWidth = ctx.structural(SELF_CALL_WIDTH);
971
- const sNoteCharsPerLine = Math.floor(
972
- (sNoteMaxW - sNotePadH * 2 - sNoteFold) / sNoteCharW
973
- );
990
+ // Pixel width available for note text inside the widest allowed note box
991
+ // (box width minus the horizontal padding on both sides and the fold cut).
992
+ const sNoteTextWidthMax = sNoteMaxW - sNotePadH * 2 - sNoteFold;
974
993
  const sNoteLaneMax = sGap - sActivationWidth - sNoteGap;
975
- const sLabelCharWidth = ctx.text(LABEL_CHAR_WIDTH, 5);
976
- const sLabelMaxChars = Math.floor((sBoxW - 10) / sLabelCharWidth);
977
994
  const sLabelFontSize = ctx.text(LABEL_FONT_SIZE);
995
+ // Pixel width available for a participant label inside the scaled box.
996
+ const sLabelTextWidth = labelTextWidth(sBoxW);
978
997
 
979
998
  // Participant index lookup — used to clamp note width within one lane
980
999
  const participantIndexMap = new Map<string, number>();
@@ -1017,8 +1036,10 @@ export function renderSequenceDiagram(
1017
1036
  return Math.min(sNoteMaxW, laneMax);
1018
1037
  };
1019
1038
 
1020
- const charsForWidth = (maxW: number): number =>
1021
- Math.floor((maxW - sNotePadH * 2 - sNoteFold) / sNoteCharW);
1039
+ // Pixel width available for note text inside a note box of the given outer
1040
+ // width outer width minus horizontal padding (both sides) and the fold cut.
1041
+ const noteTextWidth = (maxW: number): number =>
1042
+ maxW - sNotePadH * 2 - sNoteFold;
1022
1043
 
1023
1044
  const activationsOff = parsedOptions['activations']?.toLowerCase() === 'off';
1024
1045
 
@@ -1249,9 +1270,9 @@ export function renderSequenceDiagram(
1249
1270
  const NOTE_TRAILING_GAP = ctx.aesthetic(35);
1250
1271
  const computeNoteHeight = (
1251
1272
  text: string,
1252
- maxChars: number = sNoteCharsPerLine
1273
+ textWidth: number = sNoteTextWidthMax
1253
1274
  ): number => {
1254
- const lines = wrapTextLines(text, maxChars);
1275
+ const lines = wrapTextLines(text, textWidth, sNoteFontSize);
1255
1276
  return lines.length * sNoteLineH + sNotePadV * 2;
1256
1277
  };
1257
1278
  let trailingNoteSpace = 0; // extra space for notes at the end with no following message
@@ -1273,7 +1294,7 @@ export function renderSequenceDiagram(
1273
1294
  const note = els[j]! as SequenceNote;
1274
1295
  const sc = isNoteAfterSelfCall(note);
1275
1296
  const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
1276
- const noteH = computeNoteHeight(note.text, charsForWidth(maxW));
1297
+ const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
1277
1298
  totalExtent += noteH + NOTE_OFFSET_BELOW;
1278
1299
  j++;
1279
1300
  }
@@ -1571,7 +1592,7 @@ export function renderSequenceDiagram(
1571
1592
  );
1572
1593
  const prevNoteH = computeNoteHeight(
1573
1594
  prevNote.text,
1574
- charsForWidth(prevMaxW)
1595
+ noteTextWidth(prevMaxW)
1575
1596
  );
1576
1597
  noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
1577
1598
  } else {
@@ -1615,7 +1636,7 @@ export function renderSequenceDiagram(
1615
1636
  effectiveNotePosition(note),
1616
1637
  isNoteAfterSelfCall(note)
1617
1638
  );
1618
- const noteH = computeNoteHeight(note.text, charsForWidth(maxW));
1639
+ const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
1619
1640
  contentBottomY = Math.max(
1620
1641
  contentBottomY,
1621
1642
  noteTopY + noteH + NOTE_TRAILING_GAP
@@ -1651,7 +1672,8 @@ export function renderSequenceDiagram(
1651
1672
  if (step.from === step.to && step.from === rightmostId) {
1652
1673
  const selfProj = sActivationWidth + sSelfCallWidth;
1653
1674
  let labelProj = 0;
1654
- if (step.label) labelProj = step.label.length * sLabelCharWidth + 15;
1675
+ // Self-call labels render at the fixed 12px message-label size.
1676
+ if (step.label) labelProj = measureText(step.label, 12) + 15;
1655
1677
  rightProjection = Math.max(rightProjection, selfProj + labelProj);
1656
1678
  }
1657
1679
  }
@@ -1726,12 +1748,11 @@ export function renderSequenceDiagram(
1726
1748
  // Post-layout content scan: detect labels/notes that overflow the SVG boundaries.
1727
1749
  // Message labels render at a fixed 12px font (unscaled) so they can extend past
1728
1750
  // the scaled participant grid at small scale factors.
1729
- const MSG_LABEL_CHAR_W = 7;
1730
1751
  let contentLeft = 0;
1731
1752
  let contentRight = svgWidth;
1732
1753
  for (const step of renderSteps) {
1733
1754
  if (!step.label) continue;
1734
- const labelW = step.label.length * MSG_LABEL_CHAR_W;
1755
+ const labelW = measureText(step.label, 12);
1735
1756
  if (step.from === step.to) {
1736
1757
  const px = participantX.get(step.from);
1737
1758
  if (px !== undefined) {
@@ -2101,7 +2122,7 @@ export function renderSequenceDiagram(
2101
2122
  solid,
2102
2123
  sBoxW,
2103
2124
  sBoxH,
2104
- sLabelMaxChars,
2125
+ sLabelTextWidth,
2105
2126
  sLabelFontSize
2106
2127
  );
2107
2128
 
@@ -2651,7 +2672,8 @@ export function renderSequenceDiagram(
2651
2672
 
2652
2673
  // Transparent hit area scoped to the label so the toggle stays clickable
2653
2674
  // without the band swallowing clicks across the full diagram width.
2654
- const labelHitW = Math.max(80, labelText.length * 7 + 24);
2675
+ // Label renders at the fixed 11px section-label size.
2676
+ const labelHitW = Math.max(80, measureText(labelText, 11) + 24);
2655
2677
  sectionG
2656
2678
  .append('rect')
2657
2679
  .attr('x', labelX - labelHitW / 2)
@@ -2915,13 +2937,15 @@ export function renderSequenceDiagram(
2915
2937
  position,
2916
2938
  afterSelfCall
2917
2939
  );
2918
- const maxChars = charsForWidth(maxW);
2919
- const wrappedLines = wrapTextLines(el.text, maxChars);
2940
+ const textWidth = noteTextWidth(maxW);
2941
+ const wrappedLines = wrapTextLines(el.text, textWidth, sNoteFontSize);
2920
2942
  const noteH = wrappedLines.length * sNoteLineH + sNotePadV * 2;
2921
- const maxLineLen = Math.max(...wrappedLines.map((l) => l.text.length));
2943
+ const maxLineW = Math.max(
2944
+ ...wrappedLines.map((l) => measureText(l.text, sNoteFontSize))
2945
+ );
2922
2946
  const noteW = Math.min(
2923
2947
  maxW,
2924
- Math.max(80, maxLineLen * sNoteCharW + sNotePadH * 2 + sNoteFold)
2948
+ Math.max(80, maxLineW + sNotePadH * 2 + sNoteFold)
2925
2949
  );
2926
2950
  // Shift notes past self-call loopback when applicable
2927
2951
  const rightOffset =
@@ -3089,7 +3113,7 @@ function renderParticipant(
3089
3113
  solid?: boolean,
3090
3114
  boxW: number = W,
3091
3115
  boxH: number = H,
3092
- labelMaxChars: number = LABEL_MAX_CHARS,
3116
+ labelTextW: number = labelTextWidth(W),
3093
3117
  labelFontSize: number = LABEL_FONT_SIZE
3094
3118
  ): void {
3095
3119
  const g = svg
@@ -3124,7 +3148,11 @@ function renderParticipant(
3124
3148
 
3125
3149
  // Render label — below the shape for actors, centered inside for others
3126
3150
  const isActor = participant.type === 'actor';
3127
- const labelLines = splitParticipantLabel(participant.label, labelMaxChars);
3151
+ const labelLines = splitParticipantLabel(
3152
+ participant.label,
3153
+ labelTextW,
3154
+ labelFontSize
3155
+ );
3128
3156
  const fontSize = labelFontSize;
3129
3157
  const lineHeight = fontSize + 2;
3130
3158
  const labelFill = isActor
@@ -3142,12 +3170,9 @@ function renderParticipant(
3142
3170
  .attr('font-size', fontSize)
3143
3171
  .attr('font-weight', 500);
3144
3172
 
3145
- const maxLabelW = boxW - 10;
3146
- const truncLine = (text: string): string => {
3147
- if (text.length * (fontSize * 0.6) <= maxLabelW) return text;
3148
- const maxCharsEst = Math.floor(maxLabelW / (fontSize * 0.6));
3149
- return maxCharsEst > 2 ? text.slice(0, maxCharsEst - 1) + '…' : text;
3150
- };
3173
+ const maxLabelW = labelTextWidth(boxW);
3174
+ const truncLine = (text: string): string =>
3175
+ truncateText(text, fontSize, maxLabelW);
3151
3176
 
3152
3177
  if (labelLines.length === 1) {
3153
3178
  textEl
@@ -10,8 +10,24 @@ import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
10
10
  import {
11
11
  LEGEND_PILL_FONT_SIZE,
12
12
  LEGEND_ENTRY_FONT_SIZE,
13
+ LEGEND_HEIGHT,
14
+ LEGEND_PILL_PAD,
15
+ LEGEND_CAPSULE_PAD,
16
+ LEGEND_DOT_R,
17
+ LEGEND_ENTRY_DOT_GAP,
18
+ LEGEND_ENTRY_TRAIL,
19
+ LEGEND_GROUP_GAP,
20
+ LEGEND_EYE_SIZE,
21
+ LEGEND_EYE_GAP,
13
22
  measureLegendText,
14
23
  } from '../utils/legend-constants';
24
+ import { measureText } from '../utils/text-measure';
25
+
26
+ // Font sizes — must match the renderer (renderer.ts) so card sizing here
27
+ // agrees pixel-for-pixel with what gets drawn.
28
+ const LABEL_FONT_SIZE = 13;
29
+ const META_FONT_SIZE = 11;
30
+ const CONTAINER_LABEL_FONT_SIZE = 13;
15
31
 
16
32
  // ============================================================
17
33
  // Types
@@ -119,7 +135,6 @@ function clipToRectBorder(
119
135
  // Constants
120
136
  // ============================================================
121
137
 
122
- const CHAR_WIDTH = 7.5;
123
138
  const META_LINE_HEIGHT = 16;
124
139
  const HEADER_HEIGHT = 28;
125
140
  const SEPARATOR_GAP = 6;
@@ -134,15 +149,6 @@ const CONTAINER_LABEL_HEIGHT = 28;
134
149
  const CONTAINER_META_LINE_HEIGHT = 16;
135
150
 
136
151
  // Legend (kanban-style pills)
137
- const LEGEND_HEIGHT = 28;
138
- const LEGEND_PILL_PAD = 16;
139
- const LEGEND_CAPSULE_PAD = 4;
140
- const LEGEND_DOT_R = 4;
141
- const LEGEND_ENTRY_DOT_GAP = 4;
142
- const LEGEND_ENTRY_TRAIL = 8;
143
- const LEGEND_GROUP_GAP = 12;
144
- const LEGEND_EYE_SIZE = 14;
145
- const LEGEND_EYE_GAP = 6;
146
152
 
147
153
  // ============================================================
148
154
  // Helpers
@@ -167,20 +173,22 @@ function computeCardWidth(
167
173
  meta: Record<string, string>,
168
174
  descLines?: readonly string[]
169
175
  ): number {
170
- let maxChars = label.length;
176
+ // Measure each text element at the font size the renderer draws it with so
177
+ // the card never under-sizes its content.
178
+ let maxContentWidth = measureText(label, LABEL_FONT_SIZE);
171
179
  for (const [key, value] of Object.entries(meta)) {
172
- const lineChars = key.length + 2 + value.length;
173
- if (lineChars > maxChars) maxChars = lineChars;
180
+ // Renderer draws meta as "key:" then the value, both at META_FONT_SIZE,
181
+ // with a space separating them.
182
+ const lineWidth = measureText(`${key}: ${value}`, META_FONT_SIZE);
183
+ if (lineWidth > maxContentWidth) maxContentWidth = lineWidth;
174
184
  }
175
185
  if (descLines) {
176
186
  for (const dl of descLines) {
177
- if (dl.length > maxChars) maxChars = dl.length;
187
+ const dlWidth = measureText(dl, META_FONT_SIZE);
188
+ if (dlWidth > maxContentWidth) maxContentWidth = dlWidth;
178
189
  }
179
190
  }
180
- return Math.max(
181
- MIN_CARD_WIDTH,
182
- Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
183
- );
191
+ return Math.max(MIN_CARD_WIDTH, Math.ceil(maxContentWidth) + CARD_H_PAD * 2);
184
192
  }
185
193
 
186
194
  function computeCardHeight(
@@ -301,7 +309,7 @@ function flattenNodes(
301
309
  fullMeta: { ...node.metadata },
302
310
  width: Math.max(
303
311
  MIN_CARD_WIDTH,
304
- node.label.length * CHAR_WIDTH + CARD_H_PAD * 2
312
+ measureText(node.label, CONTAINER_LABEL_FONT_SIZE) + CARD_H_PAD * 2
305
313
  ),
306
314
  height: labelHeight + CONTAINER_PAD_BOTTOM,
307
315
  });
@@ -11,6 +11,7 @@ import type { ParsedSitemap } from './types';
11
11
  import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
12
12
  import { renderInlineText } from '../utils/inline-markdown';
13
13
  import { preprocessDescriptionLine } from '../utils/description-helpers';
14
+ import { measureText } from '../utils/text-measure';
14
15
  import {
15
16
  LEGEND_HEIGHT,
16
17
  LEGEND_GROUP_GAP,
@@ -347,8 +348,12 @@ export function renderSitemap(
347
348
  const metaDisplayKeys = metaEntries.map(
348
349
  ([k]) => displayNames.get(k) ?? k
349
350
  );
350
- const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
351
- const valueX = 10 + (maxKeyLen + 2) * (sContainerMetaFontSize * 0.6);
351
+ const maxKeyWidth = Math.max(
352
+ ...metaDisplayKeys.map((k) =>
353
+ measureText(`${k}: `, sContainerMetaFontSize)
354
+ )
355
+ );
356
+ const valueX = 10 + maxKeyWidth;
352
357
  const metaStartY = sContainerHeaderHeight + sContainerMetaFontSize - 2;
353
358
 
354
359
  for (let i = 0; i < metaEntries.length; i++) {
@@ -429,7 +434,7 @@ export function renderSitemap(
429
434
  // Edge label with background badge
430
435
  if (edge.label && edge.points.length >= 2) {
431
436
  const mid = edge.points[Math.floor(edge.points.length / 2)]!;
432
- const labelW = edge.label.length * sEdgeLabelFontSize * 0.6 + 10;
437
+ const labelW = measureText(edge.label, sEdgeLabelFontSize) + 10;
433
438
  const labelH = sEdgeLabelFontSize + 6;
434
439
 
435
440
  edgeG
@@ -529,8 +534,10 @@ export function renderSitemap(
529
534
  const metaDisplayKeys = metaEntries.map(
530
535
  ([k]) => displayNames.get(k) ?? k
531
536
  );
532
- const maxKeyLen = Math.max(...metaDisplayKeys.map((k) => k.length));
533
- const valueX = 10 + (maxKeyLen + 2) * (sMetaFontSize * 0.6);
537
+ const maxKeyWidth = Math.max(
538
+ ...metaDisplayKeys.map((k) => measureText(`${k}: `, sMetaFontSize))
539
+ );
540
+ const valueX = 10 + maxKeyWidth;
534
541
 
535
542
  for (let i = 0; i < metaEntries.length; i++) {
536
543
  // In-bounds by loop guard.
@@ -6,6 +6,11 @@ import type { D3ExportDimensions } from '../utils/d3-types';
6
6
  import type { CompactViewState } from '../sharing';
7
7
  import { parseInlineMarkdown } from '../utils/inline-markdown';
8
8
  import { safeHref } from '../utils/safe-href';
9
+ import {
10
+ measureText,
11
+ truncateText,
12
+ wrapTextToWidth,
13
+ } from '../utils/text-measure';
9
14
  import type {
10
15
  ParsedTechRadar,
11
16
  QuadrantPosition,
@@ -780,7 +785,7 @@ function renderBlipListing(
780
785
  const textX = colX + sListingBlipR * 2 + 6;
781
786
  const availableWidth = colWidth - sListingBlipR * 2 - 8;
782
787
  const fullLabel = `${blip.name} (${blip.ring})`;
783
- const label = truncateLabel(fullLabel, availableWidth, sListingFontSize);
788
+ const label = truncateText(fullLabel, sListingFontSize, availableWidth);
784
789
 
785
790
  itemGroup
786
791
  .append('text')
@@ -834,20 +839,6 @@ function renderBlipListing(
834
839
  }
835
840
  }
836
841
 
837
- /** Estimate max characters that fit in `availablePx` at the given font size. */
838
- function truncateLabel(
839
- text: string,
840
- availablePx: number,
841
- fontSize: number
842
- ): string {
843
- // Average character width ≈ 0.58 × fontSize for Helvetica/Inter
844
- const avgCharWidth = fontSize * 0.58;
845
- const maxChars = Math.floor(availablePx / avgCharWidth);
846
- if (maxChars <= 0) return '';
847
- if (text.length <= maxChars) return text;
848
- return text.substring(0, maxChars - 1) + '\u2026';
849
- }
850
-
851
842
  // ============================================================
852
843
  // Ring×Quadrant Hover Interactivity
853
844
  // ============================================================
@@ -1164,28 +1155,13 @@ function renderQuadrantLabel(
1164
1155
  maxWidth: number,
1165
1156
  baseFontSize = QUADRANT_LABEL_FONT_SIZE
1166
1157
  ): void {
1167
- const avgCharWidth = baseFontSize * 0.58;
1168
- const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
1169
-
1170
- // Split into words and wrap
1171
- const words = name.split(/\s+/);
1172
- const lines: string[] = [];
1173
- let currentLine = '';
1174
-
1175
- for (const word of words) {
1176
- const testLine = currentLine ? `${currentLine} ${word}` : word;
1177
- if (testLine.length > maxCharsPerLine && currentLine) {
1178
- lines.push(currentLine);
1179
- currentLine = word;
1180
- } else {
1181
- currentLine = testLine;
1182
- }
1183
- }
1184
- if (currentLine) lines.push(currentLine);
1158
+ // Greedy word-wrap to the available pixel width.
1159
+ const lines = wrapTextToWidth(name, baseFontSize, maxWidth);
1185
1160
 
1186
1161
  // Scale font down if any line is still too wide
1187
- const longestLine = Math.max(...lines.map((l) => l.length));
1188
- const estimatedWidth = longestLine * avgCharWidth;
1162
+ const estimatedWidth = Math.max(
1163
+ ...lines.map((l) => measureText(l, baseFontSize))
1164
+ );
1189
1165
  const fontSize =
1190
1166
  estimatedWidth > maxWidth
1191
1167
  ? Math.max(12, baseFontSize * (maxWidth / estimatedWidth))
@@ -0,0 +1,51 @@
1
+ // ============================================================
2
+ // Shared arrowhead <marker> defs
3
+ // ============================================================
4
+ // The flowchart and state renderers carried a byte-identical block
5
+ // that appends a base arrowhead marker plus one per edge color. This
6
+ // is the single source so they can't drift.
7
+
8
+ import type { BaseType, Selection } from 'd3-selection';
9
+
10
+ export interface ArrowheadMarkerOptions {
11
+ /** Marker id prefix, e.g. 'fc' → `fc-arrow`, `fc-arrow-<hex>`. */
12
+ idPrefix: string;
13
+ /** Marker width (already scaled by the caller's ScaleContext). */
14
+ width: number;
15
+ /** Marker height. */
16
+ height: number;
17
+ /** Fill for the base (uncolored) arrowhead. */
18
+ baseFill: string;
19
+ /** Edge colors needing their own tinted marker (hex strings). */
20
+ colors?: Iterable<string>;
21
+ }
22
+
23
+ /**
24
+ * Append an arrowhead `<marker>` (id `${idPrefix}-arrow`) to `defs`, plus
25
+ * one tinted marker per edge color (id `${idPrefix}-arrow-<hex-without-#>`).
26
+ */
27
+ export function appendArrowheadMarkers<GElement extends BaseType>(
28
+ defs: Selection<GElement, unknown, null, undefined>,
29
+ opts: ArrowheadMarkerOptions
30
+ ): void {
31
+ const { idPrefix, width, height, baseFill, colors } = opts;
32
+ const appendMarker = (id: string, fill: string): void => {
33
+ defs
34
+ .append('marker')
35
+ .attr('id', id)
36
+ .attr('viewBox', `0 0 ${width} ${height}`)
37
+ .attr('refX', width)
38
+ .attr('refY', height / 2)
39
+ .attr('markerWidth', width)
40
+ .attr('markerHeight', height)
41
+ .attr('orient', 'auto')
42
+ .append('polygon')
43
+ .attr('points', `0,0 ${width},${height / 2} 0,${height}`)
44
+ .attr('fill', fill);
45
+ };
46
+
47
+ appendMarker(`${idPrefix}-arrow`, baseFill);
48
+ for (const color of colors ?? []) {
49
+ appendMarker(`${idPrefix}-arrow-${color.replace('#', '')}`, color);
50
+ }
51
+ }
@@ -0,0 +1,64 @@
1
+ // ============================================================
2
+ // Diagram → canvas fit
3
+ // ============================================================
4
+ // Shared by renderers that top-anchor a diagram and fit it to the
5
+ // canvas with the same scale model. Each renderer with a *different*
6
+ // model (legend-band reserves, vertical centering, content-padded
7
+ // scale) keeps its own math on purpose — this is only for the ones
8
+ // that were byte-identical.
9
+
10
+ export interface CanvasFit {
11
+ scale: number;
12
+ offsetX: number;
13
+ offsetY: number;
14
+ canvasHeight: number;
15
+ }
16
+
17
+ export interface FitDiagramParams {
18
+ /** Canvas width. */
19
+ width: number;
20
+ /** Canvas height (interactive pane height; ignored for canvasHeight in export mode). */
21
+ height: number;
22
+ /** Laid-out diagram width / height. */
23
+ diagramW: number;
24
+ diagramH: number;
25
+ /** Scaled padding around the diagram. */
26
+ padding: number;
27
+ /** Reserved height above the diagram (title band), 0 if none. */
28
+ titleHeight: number;
29
+ /** Upper bound on scale. */
30
+ maxScale: number;
31
+ /** True when rendering to a fixed export canvas. */
32
+ exportMode: boolean;
33
+ }
34
+
35
+ /**
36
+ * Fit a top-anchored diagram into the canvas.
37
+ *
38
+ * Export renders a fixed canvas (e.g. 1200×800); fitting a small graph into it
39
+ * and top-anchoring leaves a tall dead band below. In export mode we scale to
40
+ * width (capped by maxScale) and size the canvas to the scaled content height.
41
+ * The interactive preview keeps fit-to-pane (min of width/height scale) so a
42
+ * small graph still fills its pane.
43
+ */
44
+ export function fitDiagramToCanvas(p: FitDiagramParams): CanvasFit {
45
+ const scaleX = (p.width - p.padding * 2) / p.diagramW;
46
+ let scale: number;
47
+ let canvasHeight: number;
48
+ if (p.exportMode) {
49
+ scale = Math.min(p.maxScale, scaleX);
50
+ canvasHeight = p.titleHeight + p.diagramH * scale + p.padding * 2;
51
+ } else {
52
+ const availH = p.height - p.titleHeight;
53
+ const scaleY = (availH - p.padding * 2) / p.diagramH;
54
+ scale = Math.min(p.maxScale, scaleX, scaleY);
55
+ canvasHeight = p.height;
56
+ }
57
+ const scaledW = p.diagramW * scale;
58
+ return {
59
+ scale,
60
+ offsetX: (p.width - scaledW) / 2,
61
+ offsetY: p.titleHeight + p.padding,
62
+ canvasHeight,
63
+ };
64
+ }