@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
@@ -3,7 +3,8 @@
3
3
  // ============================================================
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
- import * as d3Shape from 'd3-shape';
6
+ import { appendArrowheadMarkers } from '../utils/arrow-markers';
7
+ import { fitDiagramToCanvas } from '../utils/fit-canvas';
7
8
  import { FONT_FAMILY } from '../fonts';
8
9
  import type { PaletteColors } from '../palettes';
9
10
  import { contrastText, mix, shapeFill } from '../palettes/color-utils';
@@ -11,12 +12,21 @@ import type { ParsedGraph } from './types';
11
12
  import type { LayoutResult, LayoutNode } from './layout';
12
13
  import { parseState } from './state-parser';
13
14
  import { layoutGraph } from './layout';
15
+ import { edgeSplinePath } from './edge-spline';
14
16
  import {
15
17
  TITLE_FONT_SIZE,
16
18
  TITLE_FONT_WEIGHT,
17
19
  TITLE_Y,
18
20
  } from '../utils/title-constants';
19
21
  import { ScaleContext } from '../utils/scaling';
22
+ import { measureText } from '../utils/text-measure';
23
+ import {
24
+ renderNoteBox,
25
+ renderNoteConnector,
26
+ renderNoteBadge,
27
+ noteConnectorPoints,
28
+ NOTE_BADGE_RADIUS,
29
+ } from '../utils/note-box';
20
30
 
21
31
  // ============================================================
22
32
  // Constants
@@ -64,16 +74,6 @@ function stateStroke(
64
74
  return nodeColor ?? stateDefaultColor(palette, colorOff);
65
75
  }
66
76
 
67
- // ============================================================
68
- // Edge path generator
69
- // ============================================================
70
-
71
- const lineGenerator = d3Shape
72
- .line<{ x: number; y: number }>()
73
- .x((d) => d.x)
74
- .y((d) => d.y)
75
- .curve(d3Shape.curveBasis);
76
-
77
77
  // ============================================================
78
78
  // Self-loop path
79
79
  // ============================================================
@@ -130,21 +130,23 @@ export function renderState(
130
130
 
131
131
  const diagramW = layout.width;
132
132
  const diagramH = layout.height;
133
- const availH = height - titleHeight;
134
- const scaleX = (width - sDiagramPadding * 2) / diagramW;
135
- const scaleY = (availH - sDiagramPadding * 2) / diagramH;
136
- const scale = Math.min(MAX_SCALE, scaleX, scaleY);
137
-
138
- const scaledW = diagramW * scale;
139
- const offsetX = (width - scaledW) / 2;
140
- const offsetY = titleHeight + sDiagramPadding;
133
+ const { scale, offsetX, offsetY, canvasHeight } = fitDiagramToCanvas({
134
+ width,
135
+ height,
136
+ diagramW,
137
+ diagramH,
138
+ padding: sDiagramPadding,
139
+ titleHeight,
140
+ maxScale: MAX_SCALE,
141
+ exportMode: !!exportDims,
142
+ });
141
143
 
142
144
  const svg = d3Selection
143
145
  .select(container)
144
146
  .append('svg')
145
147
  .attr('width', width)
146
- .attr('height', height)
147
- .attr('viewBox', `0 0 ${width} ${height}`)
148
+ .attr('height', canvasHeight)
149
+ .attr('viewBox', `0 0 ${width} ${canvasHeight}`)
148
150
  .attr('preserveAspectRatio', 'xMidYMin meet')
149
151
  .style('font-family', FONT_FAMILY);
150
152
 
@@ -154,35 +156,14 @@ export function renderState(
154
156
 
155
157
  const defs = svg.append('defs');
156
158
 
157
- defs
158
- .append('marker')
159
- .attr('id', 'st-arrow')
160
- .attr('viewBox', `0 0 ${sArrowheadW} ${sArrowheadH}`)
161
- .attr('refX', sArrowheadW)
162
- .attr('refY', sArrowheadH / 2)
163
- .attr('markerWidth', sArrowheadW)
164
- .attr('markerHeight', sArrowheadH)
165
- .attr('orient', 'auto')
166
- .append('polygon')
167
- .attr('points', `0,0 ${sArrowheadW},${sArrowheadH / 2} 0,${sArrowheadH}`)
168
- .attr('fill', palette.textMuted);
169
-
170
159
  const edgeColors = new Set<string>();
171
- for (const color of edgeColors) {
172
- const id = `st-arrow-${color.replace('#', '')}`;
173
- defs
174
- .append('marker')
175
- .attr('id', id)
176
- .attr('viewBox', `0 0 ${sArrowheadW} ${sArrowheadH}`)
177
- .attr('refX', sArrowheadW)
178
- .attr('refY', sArrowheadH / 2)
179
- .attr('markerWidth', sArrowheadW)
180
- .attr('markerHeight', sArrowheadH)
181
- .attr('orient', 'auto')
182
- .append('polygon')
183
- .attr('points', `0,0 ${sArrowheadW},${sArrowheadH / 2} 0,${sArrowheadH}`)
184
- .attr('fill', color);
185
- }
160
+ appendArrowheadMarkers(defs, {
161
+ idPrefix: 'st',
162
+ width: sArrowheadW,
163
+ height: sArrowheadH,
164
+ baseFill: palette.textMuted,
165
+ colors: edgeColors,
166
+ });
186
167
 
187
168
  if (showTitle) {
188
169
  const titleEl = svg
@@ -281,7 +262,6 @@ export function renderState(
281
262
  nodePositionMap.set(node.id, node);
282
263
  }
283
264
 
284
- const LABEL_CHAR_W = 7;
285
265
  const LABEL_PAD = 8;
286
266
  const LABEL_H = 16;
287
267
  const PERP_OFFSET = 10;
@@ -298,7 +278,7 @@ export function renderState(
298
278
  for (let ei = 0; ei < layout.edges.length; ei++) {
299
279
  const edge = layout.edges[ei]!;
300
280
  if (!edge.label) continue;
301
- const bgW = edge.label.length * LABEL_CHAR_W + LABEL_PAD;
281
+ const bgW = measureText(edge.label, sEdgeLabelFontSize) + LABEL_PAD;
302
282
  let lx: number, ly: number;
303
283
 
304
284
  if (edge.source === edge.target) {
@@ -389,7 +369,7 @@ export function renderState(
389
369
  }
390
370
  }
391
371
  } else if (edge.points.length >= 2) {
392
- const pathD = lineGenerator(edge.points);
372
+ const pathD = edgeSplinePath(edge.points);
393
373
  if (pathD) {
394
374
  edgeG
395
375
  .append('path')
@@ -433,6 +413,7 @@ export function renderState(
433
413
 
434
414
  const colorOff = graph.options?.['color'] === 'off';
435
415
  const solid = graph.options?.['solid-fill'] === 'on';
416
+ const noNotes = graph.options?.['no-notes'] === 'on';
436
417
  for (const node of layout.nodes) {
437
418
  const isCollapsedGroup = collapsedGroupIds.has(node.id);
438
419
 
@@ -459,6 +440,10 @@ export function renderState(
459
440
  });
460
441
  }
461
442
 
443
+ // The shape draws at its dagre position — a note never moves it, so
444
+ // its edges stay connected. The note floats beside it.
445
+ const hasNote = !!node.note && !noNotes;
446
+
462
447
  if (node.shape === 'pseudostate') {
463
448
  nodeG
464
449
  .append('circle')
@@ -562,6 +547,49 @@ export function renderState(
562
547
  .attr('font-size', sNodeFontSize)
563
548
  .text(node.label);
564
549
  }
550
+
551
+ if (hasNote && node.note) {
552
+ if (node.note.collapsed) {
553
+ // Collapsed → comment-bubble badge in the node's top-right corner.
554
+ renderNoteBadge(
555
+ nodeG,
556
+ {
557
+ x: node.width / 2 - NOTE_BADGE_RADIUS - 3,
558
+ y: -node.height / 2 + NOTE_BADGE_RADIUS + 3,
559
+ },
560
+ palette,
561
+ {
562
+ isDark,
563
+ ...(node.note.color && { color: node.note.color }),
564
+ lineNumber: node.note.lineNumber,
565
+ endLineNumber: node.note.endLineNumber,
566
+ }
567
+ );
568
+ } else {
569
+ // Solid tether from the shape edge to the floated note, on whichever
570
+ // side the collision-aware placement chose.
571
+ const [cx1, cy1, cx2, cy2] = noteConnectorPoints(node, node.note);
572
+ renderNoteConnector(nodeG, cx1, cy1, cx2, cy2, palette);
573
+ renderNoteBox(
574
+ nodeG,
575
+ {
576
+ x: node.note.x,
577
+ y: node.note.y,
578
+ width: node.note.width,
579
+ height: node.note.height,
580
+ },
581
+ node.note.lines,
582
+ palette,
583
+ {
584
+ isDark,
585
+ ...(node.note.color && { color: node.note.color }),
586
+ lineNumber: node.note.lineNumber,
587
+ endLineNumber: node.note.endLineNumber,
588
+ interactive: true,
589
+ }
590
+ );
591
+ }
592
+ }
565
593
  }
566
594
  }
567
595
 
@@ -35,6 +35,18 @@ export interface GraphGroup {
35
35
  readonly lineNumber: number;
36
36
  }
37
37
 
38
+ /**
39
+ * A generic note as authored — anchors to a node by `ref` (the
40
+ * author-typed id/label) and carries a multi-line `body`. Resolution
41
+ * to a concrete node happens at end-of-parse via `resolveNotes`; this
42
+ * model is intentionally a top-level list (ADR-1), not a field on
43
+ * `GraphNode`, so the placement pass sees the whole set at once.
44
+ */
45
+ // The graph note is now the chart-neutral `DiagramNote`; kept as a named
46
+ // alias so existing `graph/` imports of `GraphNote` stay valid.
47
+ import type { DiagramNote } from '../utils/notes/model';
48
+ export type GraphNote = DiagramNote;
49
+
38
50
  import type { DgmoError } from '../diagnostics';
39
51
 
40
52
  export interface ParsedGraph {
@@ -45,6 +57,7 @@ export interface ParsedGraph {
45
57
  readonly nodes: readonly GraphNode[];
46
58
  readonly edges: readonly GraphEdge[];
47
59
  readonly groups?: readonly GraphGroup[];
60
+ readonly notes?: readonly GraphNote[];
48
61
  readonly options: Readonly<Record<string, string>>;
49
62
  readonly diagnostics: readonly DgmoError[];
50
63
  readonly error: string | null;
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ export async function render(
68
68
  text: string,
69
69
  options?: RenderOptions
70
70
  ): Promise<RenderResult> {
71
- const palette = options?.palette ?? palettes.nord;
71
+ const palette = options?.palette ?? palettes.slate;
72
72
  const onError = options?.onError ?? 'svg';
73
73
 
74
74
  const result = await renderInternal(text, {
@@ -8,6 +8,11 @@
8
8
  import dagre from '@dagrejs/dagre';
9
9
  import type { Writable } from '../utils/brand';
10
10
  import type { ComputedInfraModel, ComputedInfraNode } from './types';
11
+ import {
12
+ measureText,
13
+ truncateText,
14
+ CHAR_WIDTH_RATIO,
15
+ } from '../utils/text-measure';
11
16
 
12
17
  // ============================================================
13
18
  // Layout types
@@ -86,8 +91,11 @@ const NODE_SEPARATOR_GAP = 4;
86
91
  const NODE_PAD_BOTTOM = 10;
87
92
  const ROLE_DOT_ROW = 12;
88
93
  const COLLAPSE_BAR_HEIGHT = 6;
89
- const CHAR_WIDTH = 7;
90
- const META_CHAR_WIDTH = 6;
94
+ // Font sizes used when sizing nodes from text — must match renderer.ts
95
+ // (NODE_FONT_SIZE / META_FONT_SIZE) so measured widths agree.
96
+ const NODE_FONT_SIZE = 13;
97
+ const META_FONT_SIZE = 10;
98
+ const EDGE_LABEL_FONT_SIZE = 11;
91
99
  const PADDING_X = 24;
92
100
  const GROUP_PADDING = 20;
93
101
  const GROUP_HEADER_HEIGHT = 24;
@@ -201,8 +209,15 @@ function computeNodeWidth(
201
209
  node.computedConcurrentInvocations === 0 && node.computedInstances > 1
202
210
  ? node.computedInstances
203
211
  : 0;
204
- const badgeLen = badgeVal > 0 ? `${badgeVal}x`.length + 2 : 0;
205
- const labelWidth = (node.label.length + badgeLen) * CHAR_WIDTH + PADDING_X;
212
+ // Badge ("3x") renders at META_FONT_SIZE at the header's right edge; reserve
213
+ // its width plus a small gap alongside the bold label at NODE_FONT_SIZE.
214
+ const badgeWidth =
215
+ badgeVal > 0
216
+ ? measureText(`${badgeVal}x`, META_FONT_SIZE) +
217
+ 2 * CHAR_WIDTH_RATIO * NODE_FONT_SIZE
218
+ : 0;
219
+ const labelWidth =
220
+ measureText(node.label, NODE_FONT_SIZE) + badgeWidth + PADDING_X;
206
221
 
207
222
  // Collect all key names (including "RPS" and computed rows) to compute aligned value column
208
223
  const allKeys: string[] = [];
@@ -269,8 +284,12 @@ function computeNodeWidth(
269
284
  }
270
285
  if (allKeys.length === 0) return Math.max(MIN_NODE_WIDTH, labelWidth);
271
286
 
272
- const maxKeyLen = Math.max(...allKeys.map((k) => k.length));
273
- // key + ": " + value
287
+ // Width of the aligned key column ("key: "), measured in pixels at the meta
288
+ // font size — mirrors renderer.ts's valueX = x + 10 + measureText("key: ").
289
+ const keyColWidth = Math.max(
290
+ ...allKeys.map((k) => measureText(`${k}: `, META_FONT_SIZE))
291
+ );
292
+ // keyColWidth + measured value width
274
293
  let maxRowWidth = 0;
275
294
  if (node.computedRps > 0) {
276
295
  // RPS row may show "29.3k / 50k" when an effective cap exists
@@ -296,7 +315,7 @@ function computeNodeWidth(
296
315
  : formatRps(node.computedRps);
297
316
  maxRowWidth = Math.max(
298
317
  maxRowWidth,
299
- (maxKeyLen + 2 + rpsVal.length) * META_CHAR_WIDTH
318
+ keyColWidth + measureText(rpsVal, META_FONT_SIZE)
300
319
  );
301
320
  }
302
321
  // Declared property value widths only when expanded
@@ -314,20 +333,20 @@ function computeNodeWidth(
314
333
  'uptime',
315
334
  'cb-error-threshold',
316
335
  ];
317
- const valLen =
336
+ const valStr =
318
337
  p.key === 'max-rps' || p.key === 'ratelimit-rps'
319
- ? formatRpsShort(numVal).length
338
+ ? formatRpsShort(numVal)
320
339
  : p.key === 'latency-ms' ||
321
340
  p.key === 'cb-latency-threshold-ms' ||
322
341
  p.key === 'duration-ms' ||
323
342
  p.key === 'cold-start-ms'
324
- ? formatMs(numVal).length
343
+ ? formatMs(numVal)
325
344
  : PCT_KEYS.includes(p.key)
326
- ? `${numVal}%`.length
327
- : String(p.value).length;
345
+ ? `${numVal}%`
346
+ : String(p.value);
328
347
  maxRowWidth = Math.max(
329
348
  maxRowWidth,
330
- (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
349
+ keyColWidth + measureText(valStr, META_FONT_SIZE)
331
350
  );
332
351
  }
333
352
  }
@@ -337,10 +356,9 @@ function computeNodeWidth(
337
356
  const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.p90];
338
357
  for (const ms of msValues) {
339
358
  if (ms > 0) {
340
- const valLen = formatMs(ms).length;
341
359
  maxRowWidth = Math.max(
342
360
  maxRowWidth,
343
- (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
361
+ keyColWidth + measureText(formatMs(ms), META_FONT_SIZE)
344
362
  );
345
363
  }
346
364
  }
@@ -358,43 +376,44 @@ function computeNodeWidth(
358
376
  const combinedVal = `${formatMs(perc.p90)} / ${formatMs(threshold)}`;
359
377
  maxRowWidth = Math.max(
360
378
  maxRowWidth,
361
- (maxKeyLen + 2 + combinedVal.length) * META_CHAR_WIDTH
379
+ keyColWidth + measureText(combinedVal, META_FONT_SIZE)
362
380
  );
363
381
  }
364
382
  }
365
383
  if (node.computedUptime < 1) {
366
- const valLen = formatUptime(node.computedUptime).length;
367
384
  maxRowWidth = Math.max(
368
385
  maxRowWidth,
369
- (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
386
+ keyColWidth +
387
+ measureText(formatUptime(node.computedUptime), META_FONT_SIZE)
370
388
  );
371
389
  }
372
390
  if (node.computedAvailability < 1) {
373
- const valLen = formatUptime(node.computedAvailability).length;
374
391
  maxRowWidth = Math.max(
375
392
  maxRowWidth,
376
- (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
393
+ keyColWidth +
394
+ measureText(formatUptime(node.computedAvailability), META_FONT_SIZE)
377
395
  );
378
396
  }
379
397
  // CB state row ("CB: OPEN") — inverted pill, use full text width
380
398
  if (node.computedCbState === 'open') {
381
399
  maxRowWidth = Math.max(
382
400
  maxRowWidth,
383
- 'CB: OPEN'.length * META_CHAR_WIDTH + 8
401
+ measureText('CB: OPEN', META_FONT_SIZE) + 8
384
402
  );
385
403
  }
386
404
  }
387
405
 
388
- const DESC_MAX_CHARS = 120;
406
+ // Pixel-width cap for description lines, derived from the legacy 120-char cap
407
+ // at META_FONT_SIZE — must match renderer.ts's DESC_MAX_WIDTH.
408
+ const DESC_MAX_WIDTH = 120 * CHAR_WIDTH_RATIO * META_FONT_SIZE;
389
409
  const descLines =
390
410
  expanded && node.description && !node.isEdge ? node.description : [];
391
411
  let descWidth = 0;
392
412
  for (const dl of descLines) {
393
- const truncated =
394
- dl.length > DESC_MAX_CHARS ? dl.slice(0, DESC_MAX_CHARS - 1) + '…' : dl;
413
+ const truncated = truncateText(dl, META_FONT_SIZE, DESC_MAX_WIDTH);
395
414
  descWidth = Math.max(
396
415
  descWidth,
397
- truncated.length * META_CHAR_WIDTH + PADDING_X
416
+ measureText(truncated, META_FONT_SIZE) + PADDING_X
398
417
  );
399
418
  }
400
419
  return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20, descWidth);
@@ -828,7 +847,8 @@ export function layoutInfra(
828
847
  const midIdx = Math.floor(edge.points.length / 2);
829
848
  const midPt = edge.points[midIdx];
830
849
  if (midPt) {
831
- const halfWidth = (edge.label.length * 6.5 + 8) / 2;
850
+ const halfWidth =
851
+ (measureText(edge.label, EDGE_LABEL_FONT_SIZE) + 8) / 2;
832
852
  if (midPt.x - halfWidth < minX) minX = midPt.x - halfWidth;
833
853
  if (midPt.x + halfWidth > maxX) maxX = midPt.x + halfWidth;
834
854
  }
@@ -1159,5 +1159,5 @@ export function extractSymbols(docText: string): DiagramSymbols {
1159
1159
  }
1160
1160
  }
1161
1161
  }
1162
- return { kind: 'infra', entities, keywords: [] };
1162
+ return { kind: 'infra', entities };
1163
1163
  }
@@ -11,6 +11,11 @@ import type { InfraTagGroup } from './types';
11
11
  import { resolveColor } from '../colors';
12
12
  import { renderInlineText } from '../utils/inline-markdown';
13
13
  import { preprocessDescriptionLine } from '../utils/description-helpers';
14
+ import {
15
+ measureText,
16
+ truncateText,
17
+ CHAR_WIDTH_RATIO,
18
+ } from '../utils/text-measure';
14
19
  import type {
15
20
  InfraLayoutResult,
16
21
  InfraLayoutNode,
@@ -706,11 +711,12 @@ const PROP_DISPLAY: Record<string, string> = {
706
711
  };
707
712
 
708
713
  const DESC_MAX_CHARS = 120;
714
+ /** Pixel-width cap for description lines, derived from the legacy char cap at META_FONT_SIZE. */
715
+ const DESC_MAX_WIDTH = DESC_MAX_CHARS * CHAR_WIDTH_RATIO * META_FONT_SIZE;
709
716
 
710
- /** Truncate description text to DESC_MAX_CHARS. */
717
+ /** Truncate description text to fit DESC_MAX_WIDTH at META_FONT_SIZE. */
711
718
  function truncateDesc(text: string): string {
712
- if (text.length <= DESC_MAX_CHARS) return text;
713
- return text.slice(0, DESC_MAX_CHARS - 1) + '…';
719
+ return truncateText(text, META_FONT_SIZE, DESC_MAX_WIDTH);
714
720
  }
715
721
 
716
722
  /** Keys whose values are RPS counts and should be formatted like RPS. */
@@ -1338,7 +1344,7 @@ function renderEdgeLabels(
1338
1344
 
1339
1345
  const g = svg.append('g').attr('class', animate ? 'infra-edge-label' : '');
1340
1346
 
1341
- const textWidth = labelText.length * 6.5 + 8;
1347
+ const textWidth = measureText(labelText, EDGE_LABEL_FONT_SIZE) + 8;
1342
1348
  g.append('rect')
1343
1349
  .attr('x', midPt.x - textWidth / 2)
1344
1350
  .attr('y', midPt.y - 8)
@@ -1687,9 +1693,12 @@ function renderNodes(
1687
1693
 
1688
1694
  const rows = [...computedSection, ...declaredSection];
1689
1695
 
1690
- // Compute max key width so values align vertically
1691
- const maxKeyLen = Math.max(...rows.map((r) => r.key.length));
1692
- const valueX = x + 10 + (maxKeyLen + 2) * (sc.sMetaFontSize * 0.6);
1696
+ // Compute max key width (pixels) so values align vertically.
1697
+ // Keys are drawn as "${key}: ", so measure that exact string.
1698
+ const maxKeyWidth = Math.max(
1699
+ ...rows.map((r) => measureText(`${r.key}: `, sc.sMetaFontSize))
1700
+ );
1701
+ const valueX = x + 10 + maxKeyWidth;
1693
1702
 
1694
1703
  let rowY = sepY + NODE_SEPARATOR_GAP + sc.sMetaFontSize;
1695
1704
  const needsSectionSep =
@@ -1,5 +1,6 @@
1
1
  import type { PaletteColors } from '../palettes';
2
2
  import { mix, shapeFill } from '../palettes/color-utils';
3
+ import { measureText, wrapTextToWidth } from '../utils/text-measure';
3
4
  import type {
4
5
  ParsedJourneyMap,
5
6
  JourneyMapPhase,
@@ -61,8 +62,6 @@ const TITLE_HEIGHT = 36;
61
62
  const PERSONA_HEIGHT = 48;
62
63
  // Must match renderer.ts persona panel width
63
64
  const PERSONA_PANEL_WIDTH = 280;
64
- // Approx char width for FONT_SIZE_TITLE 18px bold (Inter)
65
- const TITLE_HEADER_CHAR_WIDTH = 10;
66
65
  const HEADER_GAP = 24;
67
66
  const CURVE_AREA_HEIGHT = 260;
68
67
  const CARD_GAP = 8;
@@ -89,6 +88,21 @@ export function scoreToColor(score: number, palette: PaletteColors): string {
89
88
  return mix(palette.colors.red, palette.colors.green, t);
90
89
  }
91
90
 
91
+ // Vertical headroom reserved at the top of the curve area (px). Keep faces
92
+ // off the very edge while still using the full height.
93
+ const CURVE_TOP_RESERVE = 20;
94
+
95
+ // Map an emotion score (1-5) to a y coordinate within the curve area. Shared
96
+ // by the curve points, the grid lines, and the left-edge score-label faces so
97
+ // all three stay on the same scale (else the labels bunch at the bottom).
98
+ export function scoreToCurveY(score: number, curveAreaBottom: number): number {
99
+ return (
100
+ curveAreaBottom -
101
+ ((score - 1) / 4) * (CURVE_AREA_HEIGHT - CURVE_TOP_RESERVE) -
102
+ 10
103
+ );
104
+ }
105
+
92
106
  // ============================================================
93
107
  // Layout Engine
94
108
  // ============================================================
@@ -127,35 +141,41 @@ export function layoutJourneyMap(
127
141
  : parsed.steps;
128
142
 
129
143
  // Compute step card heights based on content (matches kanban card sizing).
130
- // Char-width constants MUST match the renderer's wrapText() in renderer.ts
131
- // (`fontSize * 0.6`) and the title wrap (`TITLE_CHAR_WIDTH`) otherwise the
132
- // layout reserves too little vertical space and rendered text overflows.
144
+ // Line counts route through the same `wrapTextToWidth` the renderer uses,
145
+ // at the same font sizes, so reserved height always matches rendered text.
133
146
  const annoIconIndent = ANNO_ICON_SIZE + ANNO_ICON_GAP;
134
147
  const annoTextW = STEP_CARD_WIDTH - CARD_PADDING_X * 2 - annoIconIndent;
135
148
  const descTextWidth = STEP_CARD_WIDTH - CARD_PADDING_X * 2;
136
- const FONT_SIZE_META = 10;
137
- const charWidth = FONT_SIZE_META * 0.6; // matches renderer wrapText()
149
+ const FONT_SIZE_META = 10; // renderer FONT_SIZE_META (desc/anno)
150
+ const FONT_SIZE_STEP = 12; // renderer FONT_SIZE_STEP (title)
138
151
 
139
152
  const titleTextWidth = STEP_CARD_WIDTH - CARD_PADDING_X * 2;
140
- const titleCharWidth = 6.5; // matches renderer TITLE_CHAR_WIDTH (FONT_SIZE_STEP 12px)
141
153
  const TITLE_LINE_HEIGHT = 16;
142
154
 
143
155
  const stepHeights = allSteps.map((step) => {
144
- const titleLines = wrapLineCount(
156
+ const titleLines = wrapTextToWidth(
145
157
  step.title,
146
- titleTextWidth,
147
- titleCharWidth
148
- );
158
+ FONT_SIZE_STEP,
159
+ titleTextWidth
160
+ ).length;
149
161
  let h = CARD_PADDING_Y + titleLines * TITLE_LINE_HEIGHT + CARD_PADDING_Y;
150
162
  const cardAnnos = step.annotations;
151
163
  let contentLines = 0;
152
164
  // Description may wrap
153
165
  if (step.description) {
154
- contentLines += wrapLineCount(step.description, descTextWidth, charWidth);
166
+ contentLines += wrapTextToWidth(
167
+ step.description,
168
+ FONT_SIZE_META,
169
+ descTextWidth
170
+ ).length;
155
171
  }
156
172
  // Annotations: all lines indented past icon
157
173
  for (const anno of cardAnnos) {
158
- contentLines += wrapLineCount(anno.text, annoTextW, charWidth);
174
+ contentLines += wrapTextToWidth(
175
+ anno.text,
176
+ FONT_SIZE_META,
177
+ annoTextW
178
+ ).length;
159
179
  }
160
180
  if (contentLines > 0) {
161
181
  h += contentLines * CARD_META_LINE_HEIGHT + 4; // 4px bottom padding
@@ -217,10 +237,7 @@ export function layoutJourneyMap(
217
237
  // Curve point
218
238
  if (step.score !== undefined) {
219
239
  const curveX = stepX + STEP_CARD_WIDTH / 2;
220
- const curveY =
221
- curveAreaBottom -
222
- ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 120) -
223
- 10;
240
+ const curveY = scoreToCurveY(step.score, curveAreaBottom);
224
241
  curvePoints.push({
225
242
  x: curveX,
226
243
  y: curveY,
@@ -248,10 +265,7 @@ export function layoutJourneyMap(
248
265
  stepCount === 1
249
266
  ? phaseX + phaseWidth / 2
250
267
  : phaseX + padX + (si / (stepCount - 1)) * availW;
251
- const curveY =
252
- curveAreaBottom -
253
- ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 120) -
254
- 10;
268
+ const curveY = scoreToCurveY(step.score, curveAreaBottom);
255
269
  curvePoints.push({
256
270
  x: curveX,
257
271
  y: curveY,
@@ -328,10 +342,7 @@ export function layoutJourneyMap(
328
342
 
329
343
  if (step.score !== undefined) {
330
344
  const curveX = stepX + STEP_CARD_WIDTH / 2;
331
- const curveY =
332
- curveAreaBottom -
333
- ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 20) -
334
- 10;
345
+ const curveY = scoreToCurveY(step.score, curveAreaBottom);
335
346
  curvePoints.push({
336
347
  x: curveX,
337
348
  y: curveY,
@@ -373,7 +384,7 @@ export function layoutJourneyMap(
373
384
  // this, a single-step journey produces a totalWidth that's narrower than
374
385
  // the title + persona row, and the persona panel overlaps the title.
375
386
  const headerTitleWidth = hasTitle
376
- ? parsed.title!.length * TITLE_HEADER_CHAR_WIDTH
387
+ ? measureText(parsed.title!, 18) // FONT_SIZE_TITLE (18px bold)
377
388
  : 0;
378
389
  const personaPanelWidth = parsed.persona ? PERSONA_PANEL_WIDTH : 0;
379
390
  const headerWidth =
@@ -402,25 +413,3 @@ export function layoutJourneyMap(
402
413
  hasThoughts,
403
414
  };
404
415
  }
405
-
406
- /** Count how many visual lines a text string will occupy when wrapped. */
407
- function wrapLineCount(
408
- text: string,
409
- maxWidth: number,
410
- charWidth: number
411
- ): number {
412
- const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
413
- const words = text.split(/\s+/);
414
- let lines = 1;
415
- let currentLen = 0;
416
- for (const word of words) {
417
- const needed = currentLen > 0 ? word.length + 1 : word.length;
418
- if (currentLen + needed > maxChars && currentLen > 0) {
419
- lines++;
420
- currentLen = word.length;
421
- } else {
422
- currentLen += needed;
423
- }
424
- }
425
- return lines;
426
- }