@diagrammo/dgmo 0.26.0 → 0.28.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 (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  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 +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  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 +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  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 +101 -25
  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 +1212 -96
  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/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
@@ -26,7 +26,7 @@ import {
26
26
  extractColor,
27
27
  parseFirstLine,
28
28
  OPTION_NOCOLON_RE,
29
- peelTrailingColorName,
29
+ peelRampColors,
30
30
  splitNameAndMeta,
31
31
  tryParseSharedOption,
32
32
  warnUnknownMetaKeys,
@@ -35,6 +35,8 @@ import {
35
35
  BOXES_AND_LINES_REGISTRY,
36
36
  withTagAliases,
37
37
  } from '../utils/reserved-key-registry';
38
+ import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
39
+ import type { PaletteColors } from '../palettes';
38
40
 
39
41
  const MAX_GROUP_DEPTH = 2;
40
42
 
@@ -113,8 +115,12 @@ type MutBLGroup = Omit<Writable<BLGroup>, 'metadata' | 'children'> & {
113
115
  children: string[];
114
116
  };
115
117
 
116
- export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
118
+ export function parseBoxesAndLines(
119
+ content: string,
120
+ palette?: PaletteColors
121
+ ): ParsedBoxesAndLines {
117
122
  const options: Record<string, string> = {};
123
+ const notes: DiagramNote[] = [];
118
124
  const initialHiddenTagValues = new Map<string, Set<string>>();
119
125
  const nodes: MutBLNode[] = [];
120
126
  const edges: MutBLEdge[] = [];
@@ -305,11 +311,10 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
305
311
  const metricMatch = trimmed.match(/^box-metric\s+(.+)$/i);
306
312
  if (metricMatch) {
307
313
  // Regex capture group present after successful match.
308
- const { label, colorName } = peelTrailingColorName(
309
- metricMatch[1]!.trim()
310
- );
314
+ const { label, low, high } = peelRampColors(metricMatch[1]!.trim());
311
315
  result.boxMetric = label;
312
- if (colorName !== undefined) result.boxMetricColor = colorName;
316
+ if (high !== undefined) result.boxMetricColor = high;
317
+ if (low !== undefined) result.boxMetricLowColor = low;
313
318
  continue;
314
319
  }
315
320
  if (/^show-values$/i.test(trimmed)) {
@@ -336,6 +341,24 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
336
341
  }
337
342
  }
338
343
 
344
+ // Note annotation (top-level): `note <Box> [inline body]` + an optional
345
+ // indented body. Checked before tag/group/node/edge matching so a note is
346
+ // never mistaken for a box; gated to indent 0. `note -> X` is excluded.
347
+ if (indent === 0) {
348
+ const noteResult = tryCollectNote(
349
+ lines,
350
+ i,
351
+ indent,
352
+ palette,
353
+ result.diagnostics
354
+ );
355
+ if (noteResult) {
356
+ if (noteResult.note) notes.push(noteResult.note);
357
+ i = noteResult.lastIndex;
358
+ continue;
359
+ }
360
+ }
361
+
339
362
  // Tag group heading — must be checked BEFORE group/node/edge matching
340
363
  const tagBlockMatch = matchTagBlockHeading(trimmed);
341
364
  if (tagBlockMatch && indent === 0) {
@@ -721,6 +744,17 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
721
744
  }
722
745
  result.edges = validEdges;
723
746
 
747
+ // Resolve note refs against box labels (forward refs OK). The id→note
748
+ // binding is recomputed in layout; this pass surfaces diagnostics.
749
+ if (notes.length > 0) {
750
+ result.notes = notes;
751
+ resolveNotes(
752
+ notes,
753
+ result.nodes.map((n) => ({ id: n.label, label: n.label })),
754
+ result.diagnostics
755
+ );
756
+ }
757
+
724
758
  // Post-parse: inject default tag metadata and validate tag values
725
759
  if (result.tagGroups.length > 0) {
726
760
  injectDefaultTagMetadata(result.nodes, result.tagGroups);
@@ -1007,8 +1041,9 @@ function parseEdgeLine(
1007
1041
  };
1008
1042
  }
1009
1043
 
1010
- // Check for labeled arrow: `Source -label-> Target`
1011
- const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
1044
+ // Check for labeled arrow: `Source -label-> Target` (label lazy → split
1045
+ // at the first arrow, consistent with the other parsers).
1046
+ const labeledMatch = trimmed.match(/^(.+?)\s+-(.+?)->\s*(.+)$/);
1012
1047
  if (labeledMatch) {
1013
1048
  // Regex capture groups present after successful match.
1014
1049
  const rawSource = labeledMatch[1]!.trim();
@@ -3,6 +3,13 @@
3
3
  // ============================================================
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
+ import {
7
+ renderNoteBox,
8
+ renderNoteConnector,
9
+ renderNoteBadge,
10
+ noteConnectorPoints,
11
+ NOTE_BADGE_RADIUS,
12
+ } from '../utils/note-box';
6
13
  import * as d3Shape from 'd3-shape';
7
14
  import { FONT_FAMILY } from '../fonts';
8
15
  import { renderLegendD3 } from '../utils/legend-d3';
@@ -19,7 +26,13 @@ import {
19
26
  TITLE_FONT_WEIGHT,
20
27
  TITLE_Y,
21
28
  } from '../utils/title-constants';
22
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
29
+ import {
30
+ contrastText,
31
+ mix,
32
+ relativeLuminance,
33
+ shapeFill,
34
+ valueRampColor,
35
+ } from '../palettes/color-utils';
23
36
  import { resolveColor } from '../colors';
24
37
  import { resolveTagColor } from '../utils/tag-groups';
25
38
  import type { TagGroup } from '../utils/tag-groups';
@@ -32,6 +45,11 @@ import {
32
45
  import type { ParsedBoxesAndLines, BLNode } from './types';
33
46
  import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
34
47
  import { ScaleContext } from '../utils/scaling';
48
+ import {
49
+ CHAR_WIDTH_RATIO,
50
+ measureText,
51
+ truncateText,
52
+ } from '../utils/text-measure';
35
53
 
36
54
  // ── Constants (aligned with infra pattern) ─────────────────
37
55
  const DIAGRAM_PADDING = 20;
@@ -49,7 +67,6 @@ const ARROWHEAD_H = 4;
49
67
  const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
50
68
  const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px font (matches infra META_LINE_HEIGHT)
51
69
  const MAX_DESC_LINES = 6;
52
- const CHAR_WIDTH_RATIO = 0.6;
53
70
  const NODE_TEXT_PADDING = 12;
54
71
  const GROUP_RX = 8;
55
72
  const GROUP_LABEL_FONT_SIZE = 14;
@@ -127,16 +144,14 @@ function fitLabelToHeader(
127
144
  fontSize >= MIN_NODE_FONT_SIZE;
128
145
  fontSize--
129
146
  ) {
130
- const charWidth = fontSize * CHAR_WIDTH_RATIO;
131
- const maxChars = Math.floor(maxTextWidth / charWidth);
132
- if (maxChars < 2) continue;
147
+ if (maxTextWidth < measureText('MM', fontSize)) continue;
133
148
 
134
- // Wrap words into lines
149
+ // Wrap words into lines (greedy, by measured pixel width)
135
150
  const lines: string[] = [];
136
151
  let current = '';
137
152
  for (const word of words) {
138
153
  const test = current ? `${current} ${word}` : word;
139
- if (test.length <= maxChars) {
154
+ if (measureText(test, fontSize) <= maxTextWidth) {
140
155
  current = test;
141
156
  } else {
142
157
  if (current) lines.push(current);
@@ -145,15 +160,18 @@ function fitLabelToHeader(
145
160
  }
146
161
  if (current) lines.push(current);
147
162
 
163
+ const fits = (l: string): boolean =>
164
+ measureText(l, fontSize) <= maxTextWidth;
165
+
148
166
  // All lines fit at this font? Done.
149
- if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
167
+ if (lines.length <= maxLines && lines.every(fits)) {
150
168
  return { lines, fontSize };
151
169
  }
152
170
 
153
171
  // Lines fit in count but some are too wide? Truncate those lines.
154
172
  if (lines.length <= maxLines) {
155
173
  const result = lines.map((l) =>
156
- l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
174
+ fits(l) ? l : truncateText(l, fontSize, maxTextWidth)
157
175
  );
158
176
  return { lines: result, fontSize };
159
177
  }
@@ -161,25 +179,21 @@ function fitLabelToHeader(
161
179
  // Too many lines — take first maxLines, truncate last + any oversized
162
180
  const result = lines
163
181
  .slice(0, maxLines)
164
- .map((l) =>
165
- l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
166
- );
182
+ .map((l) => (fits(l) ? l : truncateText(l, fontSize, maxTextWidth)));
167
183
  // In-bounds: result has exactly maxLines entries (from .slice(0, maxLines)).
168
184
  const last = result[maxLines - 1]!;
169
185
  if (!last.endsWith('\u2026')) {
170
- result[maxLines - 1] =
171
- last.length >= maxChars
172
- ? last.slice(0, maxChars - 1) + '\u2026'
173
- : last + '\u2026';
186
+ result[maxLines - 1] = truncateText(
187
+ last + '\u2026',
188
+ fontSize,
189
+ maxTextWidth
190
+ );
174
191
  }
175
192
  return { lines: result, fontSize };
176
193
  }
177
194
 
178
195
  // Fallback at min font
179
- const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
180
- const maxChars = Math.floor(maxTextWidth / charWidth);
181
- const truncated =
182
- label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
196
+ const truncated = truncateText(label, MIN_NODE_FONT_SIZE, maxTextWidth);
183
197
  return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
184
198
  }
185
199
 
@@ -194,20 +208,40 @@ function nodeColors(
194
208
  value: {
195
209
  active: boolean;
196
210
  hue: string;
211
+ /** Two explicit endpoint colours (`box-metric Risk green red`). When set, the
212
+ * value's position on the ramp is carried by HUE, so the box follows the
213
+ * STANDARD box convention (solid colour outline + 25% faded fill) rather than
214
+ * the map's saturated choropleth fill. A single-colour ramp encodes value by
215
+ * saturation/lightness and keeps the choropleth fill (no hue to spare). */
216
+ twoColor: boolean;
197
217
  fillForValue: (v: number) => string;
198
218
  },
199
219
  solid?: boolean
200
220
  ): { fill: string; stroke: string; text: string } {
201
221
  // Untagged-neutral fill, reused by the value path for no-value boxes.
202
222
  const neutralFill = mix(palette.bg, palette.text, isDark ? 90 : 95);
203
- // Value dimension active: choropleth tint by the node's value, neutral when a
204
- // box has no value (mirror map: `value !== undefined ? fillForValue : neutral`).
205
223
  if (value.active) {
206
- const fill =
207
- node.value !== undefined ? value.fillForValue(node.value) : neutralFill;
208
- // Stroke = the ramp hue (NOT a tag color — there may be none); a present
209
- // stroke is required for the app's --bl-node-stroke hover-dim to work.
210
- const stroke = value.hue;
224
+ if (node.value === undefined) {
225
+ // No-value box: neutral fill, ramp-hue stroke (present so the app's
226
+ // --bl-node-stroke hover-dim still works).
227
+ const text = contrastText(
228
+ neutralFill,
229
+ palette.textOnFillLight,
230
+ palette.textOnFillDark
231
+ );
232
+ return { fill: neutralFill, stroke: value.hue, text };
233
+ }
234
+ // Value box: render the ramp colour like any tagged box — a 25% faded
235
+ // (muted) fill + a solid colour outline; `solid-fill` opts into the full
236
+ // fill. The outline differs by ramp kind: a two-colour ramp carries value by
237
+ // HUE, so each box's outline is its own ramp colour (red→green); a
238
+ // single-colour ramp has one hue, so the outline is the constant ramp hue and
239
+ // value reads from the muted fill depth.
240
+ const rampColor = value.fillForValue(node.value);
241
+ const fill = shapeFill(palette, rampColor, isDark, {
242
+ ...(solid !== undefined && { solid }),
243
+ });
244
+ const stroke = value.twoColor ? rampColor : value.hue;
211
245
  const text = contrastText(
212
246
  fill,
213
247
  palette.textOnFillLight,
@@ -306,6 +340,43 @@ function ensureArrowMarkers(
306
340
  }
307
341
  }
308
342
 
343
+ // ── Edge label placement ───────────────────────────────────
344
+
345
+ /** Point at the half-way arc length along an edge polyline — the geometric
346
+ * centre of the connector, so a label sits in the gap BETWEEN the two nodes
347
+ * (ELK's own label anchor drifts toward the target and ends up clipped under
348
+ * it). Falls back gracefully for degenerate point lists. */
349
+ function edgePolylineMidpoint(
350
+ points: ReadonlyArray<{ readonly x: number; readonly y: number }>
351
+ ): { x: number; y: number } {
352
+ if (points.length === 0) return { x: 0, y: 0 };
353
+ if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y };
354
+ let total = 0;
355
+ const segLen: number[] = [];
356
+ for (let k = 1; k < points.length; k++) {
357
+ const len = Math.hypot(
358
+ points[k]!.x - points[k - 1]!.x,
359
+ points[k]!.y - points[k - 1]!.y
360
+ );
361
+ segLen.push(len);
362
+ total += len;
363
+ }
364
+ let half = total / 2;
365
+ for (let k = 1; k < points.length; k++) {
366
+ const len = segLen[k - 1]!;
367
+ if (half <= len || k === points.length - 1) {
368
+ const t = len === 0 ? 0 : Math.min(1, half / len);
369
+ return {
370
+ x: points[k - 1]!.x + (points[k]!.x - points[k - 1]!.x) * t,
371
+ y: points[k - 1]!.y + (points[k]!.y - points[k - 1]!.y) * t,
372
+ };
373
+ }
374
+ half -= len;
375
+ }
376
+ const last = points[points.length - 1]!;
377
+ return { x: last.x, y: last.y };
378
+ }
379
+
309
380
  // ── Edge label overlap resolution ──────────────────────────
310
381
 
311
382
  function resolveEdgeLabelOverlaps(
@@ -407,19 +478,32 @@ export function renderBoxesAndLines(
407
478
  .filter((n) => n.value !== undefined)
408
479
  .map((n) => n.value!);
409
480
  const hasRamp = nodeValues.length > 0;
410
- const allNonNegative = hasRamp && nodeValues.every((v) => v >= 0);
411
- const rampMin = allNonNegative ? 0 : Math.min(...nodeValues);
481
+ // Anchor the low end at the lowest value (not 0) to maximise within-diagram
482
+ // dynamic range; mirrors the map's region-metric ramp. Equal-value data
483
+ // (rampMin === rampMax) falls back to t = 1 in fillForValue below.
484
+ const rampMin = hasRamp ? Math.min(...nodeValues) : 0;
412
485
  const rampMax = Math.max(...nodeValues);
413
486
  // Default hue = palette.primary (NOT red like the map — boxes have no water to
414
487
  // stand out against, and red reads as alarm on a neutral metric). A trailing
415
488
  // color on `box-metric` overrides.
416
489
  const rampHue =
417
490
  resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
491
+ // Explicit LOW endpoint (`box-metric Risk green red`); absent ⇒ single-colour
492
+ // (neutral low). Only recognized names peel, so resolveColor always succeeds.
493
+ const rampLow = parsed.boxMetricLowColor
494
+ ? (resolveColor(parsed.boxMetricLowColor, palette) ?? undefined)
495
+ : undefined;
418
496
  // Lift the ramp anchor off the near-black surface on dark themes so the
419
497
  // lowest values read as a clear muted tint rather than sinking to the surface.
420
498
  const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
499
+ const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
421
500
  const fillForValue = (v: number): string => {
422
501
  const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
502
+ // Two-colour ramp: shared low→high interpolation (direct or via midpoint).
503
+ if (rampLow !== undefined)
504
+ return valueRampColor(rampLow, rampHue, t, { isDark });
505
+ // Single/zero-colour: byte-identical to pre-change (same numeric pct, no
506
+ // float round-trip).
423
507
  const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
424
508
  return mix(rampHue, rampBase, pct);
425
509
  };
@@ -464,8 +548,8 @@ export function renderBoxesAndLines(
464
548
  gradient: {
465
549
  min: rampMin,
466
550
  max: rampMax,
467
- hue: rampHue,
468
- base: rampBase,
551
+ low: rampLow ?? rampLowFloor,
552
+ high: rampHue,
469
553
  },
470
554
  }
471
555
  : null;
@@ -829,22 +913,19 @@ export function renderBoxesAndLines(
829
913
  path.attr('marker-start', `url(#${revId})`);
830
914
  }
831
915
 
832
- // Edge label — for parallel edges, place relative to each line:
833
- // negative offset (top line) → label above, zero on line, positive → below
834
- if (le.label && le.labelX != null && le.labelY != null) {
835
- const lw = le.label.length * sEdgeLabelFontSize * CHAR_WIDTH_RATIO;
916
+ // Edge label — centred on the connector's polyline midpoint (the gap
917
+ // between the two nodes), NOT ELK's target-biased anchor. For parallel
918
+ // edges, nudge above/below so each line's label clears the line.
919
+ if (le.label && le.points.length > 0) {
920
+ const lw = measureText(le.label, sEdgeLabelFontSize);
836
921
  const labelH = sEdgeLabelFontSize + 6;
837
- let ly: number;
922
+ const mid = edgePolylineMidpoint(le.points);
923
+ let ly = mid.y;
838
924
  if (le.parallelCount > 1 && le.yOffset !== 0) {
839
- // Position label on the line at midpoint, shifted above/below based on offset sign
840
- const lineY = le.labelY + 10 + le.yOffset; // +10 to undo the -10 in layout
841
- const labelShift = le.yOffset < 0 ? -labelH : labelH;
842
- ly = lineY + labelShift * 0.5;
843
- } else {
844
- ly = le.labelY + le.yOffset;
925
+ ly += (le.yOffset < 0 ? -labelH : labelH) * 0.5;
845
926
  }
846
927
  labelPositions.push({
847
- x: le.labelX,
928
+ x: mid.x,
848
929
  y: ly,
849
930
  width: lw + 8,
850
931
  height: labelH,
@@ -903,15 +984,24 @@ export function renderBoxesAndLines(
903
984
  if (isHidden) continue;
904
985
  }
905
986
 
987
+ const solid = parsed.options['solid-fill'] === 'on';
906
988
  const colors = nodeColors(
907
989
  node,
908
990
  parsed.tagGroups,
909
991
  activeGroup,
910
992
  palette,
911
993
  isDark,
912
- { active: activeIsValue, hue: rampHue, fillForValue },
913
- parsed.options['solid-fill'] === 'on'
994
+ {
995
+ active: activeIsValue,
996
+ hue: rampHue,
997
+ twoColor: rampLow !== undefined,
998
+ fillForValue,
999
+ },
1000
+ solid
914
1001
  );
1002
+ // Divider matches the org-card convention: the box stroke normally, but the
1003
+ // contrast text colour in solid mode (where stroke == fill and would vanish).
1004
+ const dividerStroke = solid ? colors.text : colors.stroke;
915
1005
 
916
1006
  const nodeG = diagramG
917
1007
  .append('g')
@@ -980,13 +1070,14 @@ export function renderBoxesAndLines(
980
1070
  .attr('text-anchor', 'middle')
981
1071
  .attr('dominant-baseline', 'central')
982
1072
  .attr('font-size', fitted.fontSize)
983
- .attr('font-weight', '600')
1073
+ .attr('font-weight', 'bold')
984
1074
  .attr('fill', colors.text)
985
1075
  // In-bounds by loop guard.
986
1076
  .text(labelLines[li]!);
987
1077
  }
988
1078
 
989
- // Separator line (full width, matches infra style)
1079
+ // Single divider under the title (org-card convention) everything else
1080
+ // renders below it as one body section (no second divider / footer band).
990
1081
  const sepY = -ln.height / 2 + headerH;
991
1082
  nodeG
992
1083
  .append('line')
@@ -994,12 +1085,15 @@ export function renderBoxesAndLines(
994
1085
  .attr('y1', sepY)
995
1086
  .attr('x2', ln.width / 2)
996
1087
  .attr('y2', sepY)
997
- .attr('stroke', colors.stroke)
1088
+ .attr('stroke', dividerStroke)
998
1089
  .attr('stroke-opacity', 0.3)
999
1090
  .attr('stroke-width', 1);
1000
1091
 
1001
1092
  const descStartY = sepY + 4 + sDescFontSize;
1002
1093
  const maxTextWidth = ln.width - NODE_TEXT_PADDING * 2;
1094
+ // Char budget for the shared (char-based) bullet-aware wrapper. Derived
1095
+ // from the shared average glyph ratio so it stays in step with the
1096
+ // pixel measurer used everywhere else here.
1003
1097
  const charsPerLine = Math.floor(
1004
1098
  maxTextWidth / (sDescFontSize * CHAR_WIDTH_RATIO)
1005
1099
  );
@@ -1046,6 +1140,16 @@ export function renderBoxesAndLines(
1046
1140
  const BULLET_GLYPH_X = -ln.width / 2 + 6;
1047
1141
  const BULLET_BODY_X = BULLET_GLYPH_X + 10;
1048
1142
 
1143
+ // Description must stay legible on ANY fill. On the default light/tinted
1144
+ // fills keep the subtle muted grey; on a dark/saturated fill (e.g.
1145
+ // solid-fill) the fixed grey sinks in — switch to a muted tint of the
1146
+ // box's contrast-correct text colour so it reads while staying
1147
+ // subordinate to the title.
1148
+ const descColor =
1149
+ relativeLuminance(colors.fill) > 0.5
1150
+ ? palette.textMuted
1151
+ : mix(colors.text, colors.fill, 75);
1152
+
1049
1153
  for (let li = 0; li < visibleLines.length; li++) {
1050
1154
  // In-bounds by loop guard.
1051
1155
  const line = visibleLines[li]!;
@@ -1053,8 +1157,8 @@ export function renderBoxesAndLines(
1053
1157
  // Truncate last line if there are more lines beyond the cap
1054
1158
  if (truncated && li === visibleLines.length - 1) {
1055
1159
  lineText =
1056
- lineText.length >= charsPerLine
1057
- ? lineText.slice(0, charsPerLine - 1) + '\u2026'
1160
+ measureText(lineText, sDescFontSize) >= maxTextWidth
1161
+ ? truncateText(lineText, sDescFontSize, maxTextWidth)
1058
1162
  : lineText + '\u2026';
1059
1163
  }
1060
1164
  const y = descStartY + li * descLineH;
@@ -1066,7 +1170,7 @@ export function renderBoxesAndLines(
1066
1170
  .attr('text-anchor', 'start')
1067
1171
  .attr('dominant-baseline', 'central')
1068
1172
  .attr('font-size', sDescFontSize)
1069
- .attr('fill', palette.textMuted)
1173
+ .attr('fill', descColor)
1070
1174
  .text('\u2022');
1071
1175
  }
1072
1176
  const isBullet =
@@ -1078,7 +1182,7 @@ export function renderBoxesAndLines(
1078
1182
  .attr('text-anchor', isBullet ? 'start' : 'middle')
1079
1183
  .attr('dominant-baseline', 'central')
1080
1184
  .attr('font-size', DESC_FONT_SIZE)
1081
- .attr('fill', palette.textMuted);
1185
+ .attr('fill', descColor);
1082
1186
  renderInlineText(textEl, lineText, palette, sDescFontSize);
1083
1187
  }
1084
1188
 
@@ -1089,6 +1193,25 @@ export function renderBoxesAndLines(
1089
1193
  fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
1090
1194
  nodeG.append('title').text(tooltipText);
1091
1195
  }
1196
+
1197
+ // Value sits in the SAME body section, directly after the description \u2014
1198
+ // no second divider / footer band (org-card: title, one line, body).
1199
+ if (parsed.showValues && node.value !== undefined) {
1200
+ const valueLabel = parsed.boxMetric
1201
+ ? `${parsed.boxMetric}: ${node.value}`
1202
+ : String(node.value);
1203
+ nodeG
1204
+ .append('text')
1205
+ .attr('class', 'bl-node-value')
1206
+ .attr('x', 0)
1207
+ .attr('y', descStartY + visibleLines.length * descLineH)
1208
+ .attr('text-anchor', 'middle')
1209
+ .attr('dominant-baseline', 'central')
1210
+ .attr('font-size', VALUE_FONT_SIZE)
1211
+ .attr('font-weight', '600')
1212
+ .attr('fill', colors.text)
1213
+ .text(valueLabel);
1214
+ }
1092
1215
  } else if (parsed.showValues && node.value !== undefined) {
1093
1216
  // Plain node with show-values: label header + thin divider + a
1094
1217
  // "Metric: value" line below (org/infra card style), instead of a
@@ -1116,20 +1239,20 @@ export function renderBoxesAndLines(
1116
1239
  .attr('text-anchor', 'middle')
1117
1240
  .attr('dominant-baseline', 'central')
1118
1241
  .attr('font-size', fitted.fontSize)
1119
- .attr('font-weight', '600')
1242
+ .attr('font-weight', 'bold')
1120
1243
  .attr('fill', colors.text)
1121
1244
  // In-bounds by loop guard.
1122
1245
  .text(fitted.lines[li]!);
1123
1246
  }
1124
- // Thin divider under the title a tint of the box's own stroke colour
1125
- // (matches org / infra card separators), not a neutral text line.
1247
+ // Single divider under the title (org-card convention; solid-aware so it
1248
+ // stays visible when stroke == fill).
1126
1249
  nodeG
1127
1250
  .append('line')
1128
1251
  .attr('x1', -ln.width / 2)
1129
1252
  .attr('y1', sepY)
1130
1253
  .attr('x2', ln.width / 2)
1131
1254
  .attr('y2', sepY)
1132
- .attr('stroke', colors.stroke)
1255
+ .attr('stroke', dividerStroke)
1133
1256
  .attr('stroke-opacity', 0.3)
1134
1257
  .attr('stroke-width', 1);
1135
1258
  // "Metric: value" centered in the space below the divider.
@@ -1167,46 +1290,50 @@ export function renderBoxesAndLines(
1167
1290
  }
1168
1291
  }
1169
1292
 
1170
- // ── show-values on a DESCRIBED node ── the body is already full, so the
1171
- // value rides in a top-right corner badge (plain nodes are handled in the
1172
- // header/divider branch above; a described node with descriptions hidden
1173
- // also falls through to that plain branch).
1174
- if (
1175
- parsed.showValues &&
1176
- node.value !== undefined &&
1177
- desc &&
1178
- desc.length > 0 &&
1179
- !hideDescriptions
1180
- ) {
1181
- const valueText = String(node.value);
1182
- const padX = 6;
1183
- const padY = 5;
1184
- const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
1185
- const bh = VALUE_FONT_SIZE + 4;
1186
- // Clamp to the left padding so a long value on a narrow node never
1187
- // slides past the box edge / over the label (R2-6 / AC23).
1188
- const bx = Math.max(-ln.width / 2 + 4, ln.width / 2 - bw - 4);
1189
- const by = -ln.height / 2 + 4;
1190
- nodeG
1191
- .append('rect')
1192
- .attr('x', bx)
1193
- .attr('y', by)
1194
- .attr('width', bw)
1195
- .attr('height', bh)
1196
- .attr('rx', 3)
1197
- .attr('fill', palette.bg)
1198
- .attr('opacity', 0.85);
1199
- nodeG
1200
- .append('text')
1201
- .attr('class', 'bl-node-value')
1202
- .attr('x', bx + bw - padX)
1203
- .attr('y', by + padY)
1204
- .attr('text-anchor', 'end')
1205
- .attr('dominant-baseline', 'central')
1206
- .attr('font-size', VALUE_FONT_SIZE)
1207
- .attr('font-weight', '600')
1208
- .attr('fill', palette.textMuted)
1209
- .text(valueText);
1293
+ // ── Note (floated beside the box, or a collapsed corner badge) ──
1294
+ // The box keeps its layout position; the note floats in adjacent space.
1295
+ // Coords are node-center-local (the node `<g>` is at the box center).
1296
+ if (ln.note) {
1297
+ if (ln.note.collapsed) {
1298
+ renderNoteBadge(
1299
+ nodeG,
1300
+ {
1301
+ x: ln.width / 2 - NOTE_BADGE_RADIUS - 3,
1302
+ y: -ln.height / 2 + NOTE_BADGE_RADIUS + 3,
1303
+ },
1304
+ palette,
1305
+ {
1306
+ isDark,
1307
+ ...(ln.note.color && { color: ln.note.color }),
1308
+ lineNumber: ln.note.lineNumber,
1309
+ endLineNumber: ln.note.endLineNumber,
1310
+ }
1311
+ );
1312
+ } else {
1313
+ const [cx1, cy1, cx2, cy2] = noteConnectorPoints(
1314
+ { width: ln.width, height: ln.height },
1315
+ ln.note
1316
+ );
1317
+ renderNoteConnector(nodeG, cx1, cy1, cx2, cy2, palette);
1318
+ renderNoteBox(
1319
+ nodeG,
1320
+ {
1321
+ x: ln.note.x,
1322
+ y: ln.note.y,
1323
+ width: ln.note.width,
1324
+ height: ln.note.height,
1325
+ },
1326
+ ln.note.lines,
1327
+ palette,
1328
+ {
1329
+ isDark,
1330
+ ...(ln.note.color && { color: ln.note.color }),
1331
+ lineNumber: ln.note.lineNumber,
1332
+ endLineNumber: ln.note.endLineNumber,
1333
+ interactive: true,
1334
+ }
1335
+ );
1336
+ }
1210
1337
  }
1211
1338
  }
1212
1339
 
@@ -1,5 +1,6 @@
1
1
  import type { TagGroup } from '../utils/tag-groups';
2
2
  import type { DgmoError } from '../diagnostics';
3
+ import type { DiagramNote } from '../utils/notes';
3
4
 
4
5
  export interface BLNode {
5
6
  readonly label: string;
@@ -37,12 +38,18 @@ export interface ParsedBoxesAndLines {
37
38
  readonly groups: readonly BLGroup[];
38
39
  readonly tagGroups: readonly TagGroup[];
39
40
  readonly options: Readonly<Record<string, string>>;
41
+ /** Generic node notes (`note <Box> …`); resolved in layout. */
42
+ readonly notes?: readonly DiagramNote[];
40
43
  readonly initialHiddenTagValues: ReadonlyMap<string, ReadonlySet<string>>;
41
44
  readonly direction: 'LR' | 'TB';
42
- /** `box-metric <label> [color]` — names the value-ramp dimension and
43
- * optionally sets its hue. Mirror of map's `region-metric`. */
45
+ /** `box-metric <label> [low] [high]` — names the value-ramp dimension and
46
+ * optionally sets its endpoint colours. One color = high hue over a neutral
47
+ * low; two = explicit `low high`. Mirror of map's `region-metric`. */
44
48
  readonly boxMetric?: string;
49
+ /** Recognized color NAME for the ramp HIGH endpoint. */
45
50
  readonly boxMetricColor?: string;
51
+ /** Recognized color NAME for the ramp LOW endpoint (two-colour form). */
52
+ readonly boxMetricLowColor?: string;
46
53
  /** `show-values` — print each box's numeric value as text (opt-in). */
47
54
  readonly showValues?: boolean;
48
55
  readonly diagnostics: readonly DgmoError[];