@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
@@ -155,6 +155,54 @@ export function mix(a: string, b: string, pct: number): string {
155
155
  return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
156
156
  }
157
157
 
158
+ // ============================================================
159
+ // Value Ramp (the shared `<metric> <low?> <high?>` coloring convention)
160
+ // ============================================================
161
+ //
162
+ // Single source of truth for value-ramp fills across chart types (map
163
+ // `region-metric`, boxes-and-lines `box-metric`, and any future ramp). Callers
164
+ // resolve the two endpoint NAMES to palette hex, then ask for the fill at a
165
+ // normalized position `t∈[0,1]`. The helper owns ONLY the low→high blend; each
166
+ // caller keeps its own RAMP_FLOOR / base remap of `t`.
167
+ //
168
+ // The blend is a straight sRGB fade between the two true palette endpoints — no
169
+ // invented intermediate hue. `t=0` is exactly `low`, `t=1` is exactly `high`,
170
+ // and everything between is a direct interpolation of those two palette colours.
171
+ // (resvg has no `color-mix()`; `mix()` pre-computes the hex.)
172
+
173
+ /**
174
+ * Value-ramp fill at normalized position `t`. PURE and order-respecting:
175
+ * `t=0` → exactly `low`, `t=1` → exactly `high`, no sorting or intent
176
+ * correction. `low`/`high` are resolved hex (the caller maps colour names →
177
+ * palette hex). A straight sRGB fade between the two palette colours — no
178
+ * synthetic midpoint hue. `_opts` is retained for call-site/theme compat.
179
+ */
180
+ export function valueRampColor(
181
+ low: string,
182
+ high: string,
183
+ t: number,
184
+ _opts: { isDark: boolean }
185
+ ): string {
186
+ const tc = Math.max(0, Math.min(1, t));
187
+ return mix(high, low, tc * 100);
188
+ }
189
+
190
+ /**
191
+ * Gradient stops that reproduce `valueRampColor` for a legend
192
+ * `<linearGradient>`. A direct two-endpoint fade needs only the two stops; the
193
+ * gradient itself interpolates between the palette colours.
194
+ */
195
+ export function valueRampStops(
196
+ low: string,
197
+ high: string,
198
+ _opts: { isDark: boolean }
199
+ ): ReadonlyArray<{ offset: number; color: string }> {
200
+ return [
201
+ { offset: 0, color: low },
202
+ { offset: 1, color: high },
203
+ ];
204
+ }
205
+
158
206
  // ============================================================
159
207
  // Contrast / Accessibility
160
208
  // ============================================================
@@ -204,7 +252,7 @@ export function contrastRatio(a: string, b: string): number {
204
252
  * tokyo-night green `#9ece6a` min 106, ratio 11.4:1 all correctly pick dark).
205
253
  * 3. **Saturated fill (min RGB < 100, luminance ≤ 0.55)** → `lightText`. At least
206
254
  * one channel near zero signals true saturation — gruvbox dark green
207
- * `#b8bb26` (min 38), one-dark blue `#4078f2` (min 64), bold red/blue
255
+ * `#b8bb26` (min 38), blueprint blue `#1f5e8c` (min 31), bold red/blue
208
256
  * (min 0), solarized blue `#268bd2` (min 38). The user consistently
209
257
  * prefers light text on these for visual punch.
210
258
  *
@@ -280,33 +328,46 @@ export function getSeriesColors(palette: PaletteColors): string[] {
280
328
  * Generate `count` visually distinct colors for segment-based charts
281
329
  * (pie, doughnut, polar-area).
282
330
  *
283
- * Problem: several palettes have duplicate hex values for different named
284
- * colors (e.g. Solarized teal===cyan, One Dark orange===yellow), and the
285
- * 8-color modulo cycle repeats when there are more than 8 segments.
331
+ * Stays ON-PALETTE: the first pass is the palette's own series hues at full
332
+ * strength. When a chart needs more segments than the palette has distinct
333
+ * hues, additional passes reuse the SAME hues at shifted lightness — each a
334
+ * tint (mixed toward `bg`) or shade (mixed toward `text`) of a true palette
335
+ * colour. Hue is never rotated or invented; extra segments read as lighter /
336
+ * darker variants of the palette, not as wheel-generated colours.
286
337
  *
287
- * Solution: generate evenly-spaced hues using the palette's characteristic
288
- * saturation and lightness, guaranteeing every segment gets a unique,
289
- * perceptually distinct color regardless of segment count.
338
+ * (Several palettes have duplicate hex for different named colours — e.g.
339
+ * teal===cyan so the first pass is deduped before the lightness bands kick in.)
290
340
  */
291
341
  export function getSegmentColors(
292
342
  palette: PaletteColors,
293
343
  count: number
294
344
  ): string[] {
295
- const base = getSeriesColors(palette);
296
- const unique = [...new Set(base)];
297
- const hsls = unique.map(hexToHSL);
298
-
299
- const avgS = Math.round(hsls.reduce((s, c) => s + c.s, 0) / hsls.length);
300
- const avgL = Math.round(hsls.reduce((s, c) => s + c.l, 0) / hsls.length);
301
-
302
- // Start from the palette's blue hue (first in series) for consistency.
303
- // hsls has at least 1 entry because getSeriesColors always returns 8.
304
- const startHue = hsls[0]?.h ?? 0;
305
- const step = 360 / count;
345
+ if (count <= 0) return [];
346
+ const base = [...new Set(getSeriesColors(palette))];
347
+ if (count <= base.length) return base.slice(0, count);
348
+
349
+ // Lightness bands of the SAME hues alternating tint/shade, progressively
350
+ // stronger. Mixing toward the neutral bg/text keeps hue, varying only
351
+ // lightness/saturation (a tint/fade). Symmetric across light & dark themes:
352
+ // `bg` is light/dark and `text` is its inverse, so the two directions always
353
+ // diverge.
354
+ const { bg, text } = palette;
355
+ const variants: ReadonlyArray<(c: string) => string> = [
356
+ (c) => mix(c, bg, 55), // lighter
357
+ (c) => mix(c, text, 55), // darker
358
+ (c) => mix(c, bg, 35),
359
+ (c) => mix(c, text, 35),
360
+ ];
306
361
 
307
- return Array.from({ length: count }, (_, i) =>
308
- hslToHex(Math.round((startHue + i * step) % 360), avgS, avgL)
309
- );
362
+ const out = [...base];
363
+ for (let w = 0; out.length < count; w++) {
364
+ const variant = variants[w % variants.length]!;
365
+ for (const c of base) {
366
+ if (out.length >= count) break;
367
+ out.push(variant(c));
368
+ }
369
+ }
370
+ return out.slice(0, count);
310
371
  }
311
372
 
312
373
  // ============================================================
@@ -26,18 +26,11 @@ export {
26
26
  export { atlasPalette } from './atlas';
27
27
  export { blueprintPalette } from './blueprint';
28
28
  export { catppuccinPalette } from './catppuccin';
29
- export { gruvboxPalette } from './gruvbox';
30
29
  export { nordPalette } from './nord';
31
- export { oneDarkPalette } from './one-dark';
32
- export { rosePinePalette } from './rose-pine';
33
30
  export { slatePalette } from './slate';
34
- export { solarizedPalette } from './solarized';
35
31
  export { tidewaterPalette } from './tidewater';
36
32
  export { tokyoNightPalette } from './tokyo-night';
37
33
 
38
- export { draculaPalette } from './dracula';
39
- export { monokaiPalette } from './monokai';
40
-
41
34
  // ============================================================
42
35
  // Public namespace — `palettes` for use with render()
43
36
  // ============================================================
@@ -45,14 +38,8 @@ export { monokaiPalette } from './monokai';
45
38
  import { atlasPalette } from './atlas';
46
39
  import { blueprintPalette } from './blueprint';
47
40
  import { catppuccinPalette } from './catppuccin';
48
- import { draculaPalette } from './dracula';
49
- import { gruvboxPalette } from './gruvbox';
50
- import { monokaiPalette } from './monokai';
51
41
  import { nordPalette } from './nord';
52
- import { oneDarkPalette } from './one-dark';
53
- import { rosePinePalette } from './rose-pine';
54
42
  import { slatePalette } from './slate';
55
- import { solarizedPalette } from './solarized';
56
43
  import { tidewaterPalette } from './tidewater';
57
44
  import { tokyoNightPalette } from './tokyo-night';
58
45
 
@@ -74,11 +61,5 @@ export const palettes = {
74
61
  tidewater: tidewaterPalette,
75
62
  nord: nordPalette,
76
63
  catppuccin: catppuccinPalette,
77
- solarized: solarizedPalette,
78
- gruvbox: gruvboxPalette,
79
64
  tokyoNight: tokyoNightPalette,
80
- oneDark: oneDarkPalette,
81
- rosePine: rosePinePalette,
82
- dracula: draculaPalette,
83
- monokai: monokaiPalette,
84
65
  } as const satisfies Record<string, PaletteConfig>;
@@ -5,7 +5,7 @@ import type { PaletteConfig, PaletteColors } from './types';
5
5
  // ============================================================
6
6
 
7
7
  const PALETTE_REGISTRY = new Map<string, PaletteConfig>();
8
- const DEFAULT_PALETTE_ID = 'nord';
8
+ const DEFAULT_PALETTE_ID = 'slate';
9
9
 
10
10
  // ============================================================
11
11
  // Validation
@@ -77,9 +77,9 @@ export interface PaletteColors {
77
77
  * hands out the same frozen-shape object on every `getPalette(id)`.
78
78
  */
79
79
  export interface PaletteConfig {
80
- /** Registry key: 'nord', 'solarized', 'catppuccin' */
80
+ /** Registry key: 'nord', 'slate', 'catppuccin' */
81
81
  readonly id: string;
82
- /** Display name: 'Nord', 'Solarized', 'Catppuccin' */
82
+ /** Display name: 'Nord', 'Slate', 'Catppuccin' */
83
83
  readonly name: string;
84
84
  /** Light mode color definitions */
85
85
  readonly light: PaletteColors;
@@ -20,6 +20,7 @@ import {
20
20
  formatSprintCell,
21
21
  formatSlackValue,
22
22
  } from './internal';
23
+ import { measureText } from '../utils/text-measure';
23
24
 
24
25
  // Textbook 3×3 PERT/CPM box: top row [ES | dur | EF], middle row
25
26
  // [name spanning all three columns], bottom row [LS | slack | LF].
@@ -46,9 +47,8 @@ const SWIMLANE_GAP = 24;
46
47
  // Cell-sizing constants — see `computeNodeSizing` below.
47
48
  const NODE_CELL_FONT_SIZE = 11;
48
49
  const NODE_NAME_FONT_SIZE = 13;
49
- // Average char width for Inter at sans-serif weights. Conservative;
50
- // produces a small over-estimate which prevents text from clipping.
51
- const CELL_CHAR_WIDTH_RATIO = 0.55;
50
+ // Milestone name renders at 12pt with a leading `◆ ` glyph (see renderer).
51
+ const MILESTONE_NAME_FONT_SIZE = 12;
52
52
  const CELL_PAD_X = 8;
53
53
  const NAME_PAD_X = 6;
54
54
  // Anchor icon + gap (renderer reserves space to the left of the name
@@ -88,8 +88,6 @@ export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
88
88
  const sprintMode = resolved.options.sprintMode;
89
89
  const sprintNumber = resolved.options.sprintNumber ?? 1;
90
90
  const projectStart = resolved.projectStart;
91
- const cellCharW = NODE_CELL_FONT_SIZE * CELL_CHAR_WIDTH_RATIO;
92
- const nameCharW = NODE_NAME_FONT_SIZE * CELL_CHAR_WIDTH_RATIO;
93
91
 
94
92
  const fmtSchedule = (v: number | null, isTbd: boolean): string =>
95
93
  sprintMode
@@ -100,12 +98,15 @@ export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
100
98
  const fmtDur = (v: number | null, isTbd: boolean): string =>
101
99
  formatDuration(v, unit, isTbd ? '?' : null);
102
100
 
103
- let maxOuterChars = 1;
104
- let maxMidChars = 1;
105
- let maxNameChars = 1;
106
- let maxMilestoneTopChars = 1;
107
- let maxMilestoneSlackChars = 0;
108
- let maxMilestoneNameChars = 1;
101
+ // Track the widest rendered pixel width per cell category — measured at
102
+ // the exact font sizes the renderer draws with so reserved width
103
+ // matches drawn text.
104
+ let maxOuterW = 0;
105
+ let maxMidW = 0;
106
+ let maxNameW = 0;
107
+ let maxMilestoneTopW = 0;
108
+ let maxMilestoneSlackW = 0;
109
+ let maxMilestoneNameW = 0;
109
110
 
110
111
  for (const r of resolved.activities) {
111
112
  const isTbd = r.es === null;
@@ -113,17 +114,20 @@ export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
113
114
  const dateStr = fmtSchedule(r.es, isTbd);
114
115
  const slackStr = fmtSlack(r.slack, isTbd);
115
116
  const slackHidden = !isTbd && /^0[a-z]?$/.test(slackStr);
116
- maxMilestoneTopChars = Math.max(maxMilestoneTopChars, dateStr.length);
117
+ maxMilestoneTopW = Math.max(
118
+ maxMilestoneTopW,
119
+ measureText(dateStr, NODE_CELL_FONT_SIZE)
120
+ );
117
121
  if (!slackHidden) {
118
- maxMilestoneSlackChars = Math.max(
119
- maxMilestoneSlackChars,
120
- slackStr.length
122
+ maxMilestoneSlackW = Math.max(
123
+ maxMilestoneSlackW,
124
+ measureText(slackStr, NODE_CELL_FONT_SIZE)
121
125
  );
122
126
  }
123
- // The milestone glyph prefix `◆ ` (2 chars worth of width).
124
- maxMilestoneNameChars = Math.max(
125
- maxMilestoneNameChars,
126
- r.activity.name.length + 2
127
+ // The milestone glyph prefix `◆ ` precedes the name.
128
+ maxMilestoneNameW = Math.max(
129
+ maxMilestoneNameW,
130
+ measureText(`◆ ${r.activity.name}`, MILESTONE_NAME_FONT_SIZE)
127
131
  );
128
132
  continue;
129
133
  }
@@ -133,33 +137,43 @@ export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
133
137
  const lfStr = fmtSchedule(r.lf, isTbd);
134
138
  const durStr = fmtDur(r.mu, isTbd);
135
139
  const slackStr = fmtSlack(r.slack, isTbd);
136
- maxOuterChars = Math.max(
137
- maxOuterChars,
138
- esStr.length,
139
- efStr.length,
140
- lsStr.length,
141
- lfStr.length
140
+ maxOuterW = Math.max(
141
+ maxOuterW,
142
+ measureText(esStr, NODE_CELL_FONT_SIZE),
143
+ measureText(efStr, NODE_CELL_FONT_SIZE),
144
+ measureText(lsStr, NODE_CELL_FONT_SIZE),
145
+ measureText(lfStr, NODE_CELL_FONT_SIZE)
146
+ );
147
+ maxMidW = Math.max(
148
+ maxMidW,
149
+ measureText(durStr, NODE_CELL_FONT_SIZE),
150
+ measureText(slackStr, NODE_CELL_FONT_SIZE)
151
+ );
152
+ maxNameW = Math.max(
153
+ maxNameW,
154
+ measureText(r.activity.name, NODE_NAME_FONT_SIZE)
142
155
  );
143
- maxMidChars = Math.max(maxMidChars, durStr.length, slackStr.length);
144
- maxNameChars = Math.max(maxNameChars, r.activity.name.length);
145
156
  }
146
157
 
147
158
  // Also account for any collapsed-group rolled-up label width — the
148
159
  // renderer draws those with the same textbook-card chrome, so their
149
160
  // name needs to fit in the shared `activityWidth`.
150
161
  for (const rg of resolved.groups) {
151
- maxNameChars = Math.max(maxNameChars, rg.group.name.length);
162
+ maxNameW = Math.max(
163
+ maxNameW,
164
+ measureText(rg.group.name, NODE_NAME_FONT_SIZE)
165
+ );
152
166
  }
153
167
 
154
- const outerCell = Math.ceil(maxOuterChars * cellCharW) + 2 * CELL_PAD_X;
155
- const midCell = Math.ceil(maxMidChars * cellCharW) + 2 * CELL_PAD_X;
168
+ const outerCell = Math.ceil(maxOuterW) + 2 * CELL_PAD_X;
169
+ const midCell = Math.ceil(maxMidW) + 2 * CELL_PAD_X;
156
170
  const outerNeeded = Math.max(MIN_CELL_WIDTH, outerCell);
157
171
  const midNeeded = Math.max(MIN_CELL_WIDTH, midCell);
158
172
  const cellsTotalW = 2 * outerNeeded + midNeeded;
159
173
 
160
174
  // The name dictates a lower bound too — anchor-pinned cards reserve
161
175
  // a NAME_PIN_WIDTH on the left, so size for the worst case.
162
- const nameTextW = Math.ceil(maxNameChars * nameCharW);
176
+ const nameTextW = Math.ceil(maxNameW);
163
177
  const nameTotalW = nameTextW + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
164
178
 
165
179
  const activityWidth = Math.max(
@@ -183,17 +197,11 @@ export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
183
197
 
184
198
  // Milestones are independent: a one-cell-tall date row, a name row,
185
199
  // and (optionally) a slack row. Width should fit whichever is widest.
186
- const mTop = Math.ceil(maxMilestoneTopChars * cellCharW) + 2 * CELL_PAD_X;
200
+ const mTop = Math.ceil(maxMilestoneTopW) + 2 * CELL_PAD_X;
187
201
  const mSlack =
188
- maxMilestoneSlackChars > 0
189
- ? Math.ceil(maxMilestoneSlackChars * cellCharW) + 2 * CELL_PAD_X
190
- : 0;
202
+ maxMilestoneSlackW > 0 ? Math.ceil(maxMilestoneSlackW) + 2 * CELL_PAD_X : 0;
191
203
  // Milestone name renders at 12pt with anchor-pin reserve.
192
- const mNameCharW = 12 * CELL_CHAR_WIDTH_RATIO;
193
- const mName =
194
- Math.ceil(maxMilestoneNameChars * mNameCharW) +
195
- NAME_PIN_WIDTH +
196
- 2 * NAME_PAD_X;
204
+ const mName = Math.ceil(maxMilestoneNameW) + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
197
205
  const milestoneWidth = Math.max(
198
206
  MIN_MILESTONE_WIDTH,
199
207
  Math.min(MAX_MILESTONE_WIDTH, Math.max(mTop, mSlack, mName))
@@ -35,6 +35,11 @@ import { FONT_FAMILY } from '../fonts';
35
35
  import type { PaletteColors } from '../palettes';
36
36
  import { contrastText, mix, shapeFill } from '../palettes/color-utils';
37
37
  import { ScaleContext } from '../utils/scaling';
38
+ import {
39
+ measureText,
40
+ truncateText,
41
+ wrapTextToWidth,
42
+ } from '../utils/text-measure';
38
43
  import {
39
44
  TITLE_FONT_SIZE,
40
45
  TITLE_FONT_WEIGHT,
@@ -164,21 +169,23 @@ function fieldLegendRowHeight(maxDescLines: number): number {
164
169
  maxDescLines * FIELD_LEGEND_DESC_LINE_HEIGHT
165
170
  );
166
171
  }
167
- // Chars-per-line the description text wraps at for the given column
168
- // width. 0.55× char-width estimator; floors at 10 so a long single
169
- // word doesn't run off.
170
- function fieldLegendWrapChars(colW: number): number {
171
- const charW = FIELD_LEGEND_DESC_FONT_SIZE * 0.55;
172
- return Math.max(10, Math.floor((colW - 8) / charW));
172
+ // Pixel width the description text wraps within for the given column
173
+ // width (column minus 8px of horizontal padding).
174
+ function fieldLegendDescWidth(colW: number): number {
175
+ return colW - 8;
173
176
  }
174
177
  // Total height the field-legend block needs at the given outer width.
175
178
  // Accounts for the worst-case description wrap across all 6 cells.
176
179
  function fieldLegendHeightFor(width: number): number {
177
180
  const colW = width / 3;
178
- const wrapChars = fieldLegendWrapChars(colW);
181
+ const wrapW = fieldLegendDescWidth(colW);
179
182
  let maxLines = 1;
180
183
  for (const cell of FIELD_LEGEND_CELLS) {
181
- const n = wrapTextByChars(cell.desc, wrapChars).length;
184
+ const n = wrapTextToWidth(
185
+ cell.desc,
186
+ FIELD_LEGEND_DESC_FONT_SIZE,
187
+ wrapW
188
+ ).length;
182
189
  if (n > maxLines) maxLines = n;
183
190
  }
184
191
  return (
@@ -223,24 +230,6 @@ const FIELD_LEGEND_CELLS: readonly { label: string; desc: string }[] = [
223
230
  },
224
231
  ];
225
232
 
226
- function wrapTextByChars(text: string, maxChars: number): string[] {
227
- const words = text.split(/\s+/);
228
- const lines: string[] = [];
229
- let line = '';
230
- for (const word of words) {
231
- if (line.length === 0) {
232
- line = word;
233
- } else if (line.length + 1 + word.length <= maxChars) {
234
- line += ` ${word}`;
235
- } else {
236
- lines.push(line);
237
- line = word;
238
- }
239
- }
240
- if (line) lines.push(line);
241
- return lines;
242
- }
243
-
244
233
  interface ScaledNodeConstants {
245
234
  nodeRadius?: number;
246
235
  nodeStrokeWidth?: number;
@@ -2172,14 +2161,9 @@ function drawTextbookCard(g: AnySel, a: TextbookCardArgs): void {
2172
2161
  const midCenterY = midRowTop + midRowH / 2;
2173
2162
  const NAME_PAD_X = 6;
2174
2163
  const NAME_PIN_GAP = 4;
2175
- const charW = sNFS * 0.62;
2176
2164
  const pinReserve = a.pinned ? sPIW + NAME_PIN_GAP : 0;
2177
2165
  const availTextW = Math.max(0, w - 2 * NAME_PAD_X - pinReserve);
2178
- const maxChars = Math.max(1, Math.floor(availTextW / charW));
2179
- const displayName =
2180
- a.name.length > maxChars
2181
- ? a.name.slice(0, Math.max(1, maxChars - 1)) + '…'
2182
- : a.name;
2166
+ const displayName = truncateText(a.name, sNFS, availTextW);
2183
2167
  const nameColor = a.midBandLabelColor ?? a.labelColor;
2184
2168
  if (a.pinned) {
2185
2169
  drawAnchorPin(g, x + NAME_PAD_X, midCenterY, nameColor, sPIW, sPIH);
@@ -2342,7 +2326,6 @@ function drawMilestonePill(g: AnySel, a: MilestonePillArgs): void {
2342
2326
  const NAME_PAD_X = 6;
2343
2327
  const NAME_PIN_GAP = 4;
2344
2328
  const NAME_LINE_HEIGHT = 14;
2345
- const charW = nameSize * 0.62;
2346
2329
 
2347
2330
  let textAreaLeft = x + NAME_PAD_X;
2348
2331
  const textAreaRight = x + w - NAME_PAD_X;
@@ -2352,20 +2335,20 @@ function drawMilestonePill(g: AnySel, a: MilestonePillArgs): void {
2352
2335
  }
2353
2336
  const textCx = (textAreaLeft + textAreaRight) / 2;
2354
2337
  const availW = textAreaRight - textAreaLeft;
2355
- const maxChars = Math.max(1, Math.floor(availW / charW));
2356
- const lines = wrapTextByChars(a.name, maxChars).map((line) =>
2357
- line.length > maxChars
2358
- ? line.slice(0, Math.max(1, maxChars - 1)) + '…'
2359
- : line
2360
- );
2338
+ // Hard-break over-long words so a single long token still fits the
2339
+ // narrow milestone pill rather than overflowing.
2340
+ const lines = wrapTextToWidth(a.name, nameSize, availW, { hardBreak: true });
2361
2341
  const maxLines = Math.max(1, Math.floor(midRowH / NAME_LINE_HEIGHT));
2362
2342
  const visibleLines = lines.slice(0, maxLines);
2363
2343
  if (lines.length > maxLines && visibleLines.length > 0) {
2344
+ // More lines than fit — force a trailing ellipsis on the last
2345
+ // visible line to signal the truncation, fitting it to the text area.
2364
2346
  const last = visibleLines[visibleLines.length - 1]!;
2347
+ const withEllipsis = `${last}…`;
2365
2348
  visibleLines[visibleLines.length - 1] =
2366
- last.length > maxChars - 1
2367
- ? last.slice(0, Math.max(1, maxChars - 1)) + '…'
2368
- : last + '…';
2349
+ measureText(withEllipsis, nameSize) <= availW
2350
+ ? withEllipsis
2351
+ : truncateText(last, nameSize, availW);
2369
2352
  }
2370
2353
  const startCy =
2371
2354
  midCenterY - ((visibleLines.length - 1) * NAME_LINE_HEIGHT) / 2;
@@ -2853,7 +2836,11 @@ function renderFieldLegendBlock(
2853
2836
  .attr('fill-opacity', 0)
2854
2837
  .attr('pointer-events', 'all');
2855
2838
 
2856
- const descLines = wrapTextByChars(cell.desc, fieldLegendWrapChars(colW));
2839
+ const descLines = wrapTextToWidth(
2840
+ cell.desc,
2841
+ FIELD_LEGEND_DESC_FONT_SIZE,
2842
+ fieldLegendDescWidth(colW)
2843
+ );
2857
2844
  const descBlockHeight =
2858
2845
  FIELD_LEGEND_DESC_FONT_SIZE +
2859
2846
  Math.max(descLines.length - 1, 0) * FIELD_LEGEND_DESC_LINE_HEIGHT;
@@ -16,6 +16,7 @@ import {
16
16
  } from '../palettes/color-utils';
17
17
  import { resolveColor } from '../colors';
18
18
  import { renderInlineText } from '../utils/inline-markdown';
19
+ import { CHAR_WIDTH_RATIO, measureText } from '../utils/text-measure';
19
20
  import {
20
21
  wrapDescriptionLines,
21
22
  type WrappedDescLine,
@@ -45,8 +46,6 @@ const DESC_GAP = 28;
45
46
  const DESC_ACCENT_WIDTH = 3;
46
47
  /** Gap between accent bar and description text. */
47
48
  const DESC_ACCENT_GAP = 12;
48
- /** Approximate ratio of average glyph width to font size (sans-serif). */
49
- const CHAR_WIDTH_RATIO = 0.55;
50
49
  /** Pixel offset between bullet glyph column and body-text column. */
51
50
  const BULLET_BODY_INDENT = 10;
52
51
 
@@ -250,7 +249,8 @@ export function renderPyramid(
250
249
  // portion stays readable. Only when needed — wide segments don't need
251
250
  // the visual noise of a stroke.
252
251
  const labelFitsInside =
253
- Math.min(topHalf, botHalf) * 2 > layout.labelFont * 4;
252
+ Math.min(topHalf, botHalf) * 2 >
253
+ measureText(layer.label, layout.labelFont);
254
254
  const haloColor =
255
255
  textColor === palette.textOnFillLight
256
256
  ? palette.textOnFillDark
@@ -496,11 +496,10 @@ function renderLayerDescriptions(
496
496
  const bulletColRightEdge = layout.leftAccentX - DESC_ACCENT_GAP;
497
497
  const computeBulletColLeftX = (lines: WrappedDescLine[]): number => {
498
498
  if (side === 'right') return layout.rightTextX;
499
- const charW = descFont * CHAR_WIDTH_RATIO;
500
499
  let maxBodyW = 0;
501
500
  for (const l of lines) {
502
501
  if (l.kind === 'bullet-first' || l.kind === 'bullet-cont') {
503
- maxBodyW = Math.max(maxBodyW, l.text.length * charW);
502
+ maxBodyW = Math.max(maxBodyW, measureText(l.text, descFont));
504
503
  }
505
504
  }
506
505
  return bulletColRightEdge - maxBodyW - BULLET_BODY_INDENT;