@diagrammo/dgmo 0.22.0 → 0.24.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 (51) hide show
  1. package/dist/advanced.cjs +372 -103
  2. package/dist/advanced.d.cts +52 -19
  3. package/dist/advanced.d.ts +52 -19
  4. package/dist/advanced.js +372 -103
  5. package/dist/auto.cjs +370 -97
  6. package/dist/auto.js +117 -117
  7. package/dist/auto.mjs +370 -97
  8. package/dist/cli.cjs +151 -151
  9. package/dist/editor.cjs +3 -0
  10. package/dist/editor.js +3 -0
  11. package/dist/highlight.cjs +3 -0
  12. package/dist/highlight.js +3 -0
  13. package/dist/index.cjs +498 -96
  14. package/dist/index.d.cts +37 -1
  15. package/dist/index.d.ts +37 -1
  16. package/dist/index.js +496 -96
  17. package/dist/internal.cjs +372 -103
  18. package/dist/internal.d.cts +52 -19
  19. package/dist/internal.d.ts +52 -19
  20. package/dist/internal.js +372 -103
  21. package/dist/map-data/PROVENANCE.json +1 -1
  22. package/dist/map-data/gazetteer.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -1
  24. package/dist/map-data/water-bodies.json +1 -1
  25. package/dist/map-data/world-coarse.json +1 -1
  26. package/dist/map-data/world-detail.json +1 -1
  27. package/docs/language-reference.md +38 -2
  28. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  29. package/package.json +1 -1
  30. package/src/boxes-and-lines/parser.ts +39 -0
  31. package/src/boxes-and-lines/renderer.ts +219 -14
  32. package/src/boxes-and-lines/types.ts +9 -0
  33. package/src/completion.ts +4 -5
  34. package/src/d3.ts +26 -6
  35. package/src/editor/keywords.ts +3 -0
  36. package/src/index.ts +8 -0
  37. package/src/map/data/PROVENANCE.json +1 -1
  38. package/src/map/data/README.md +6 -0
  39. package/src/map/data/gazetteer.json +1 -1
  40. package/src/map/data/mountain-ranges.json +1 -1
  41. package/src/map/data/water-bodies.json +1 -1
  42. package/src/map/data/world-coarse.json +1 -1
  43. package/src/map/data/world-detail.json +1 -1
  44. package/src/map/dimensions.ts +21 -5
  45. package/src/map/layout.ts +167 -63
  46. package/src/map/legend-band.ts +99 -0
  47. package/src/map/renderer.ts +105 -32
  48. package/src/map/resolver.ts +43 -1
  49. package/src/map/types.ts +20 -0
  50. package/src/utils/reserved-key-registry.ts +5 -3
  51. package/src/utils/svg-embed.ts +193 -0
@@ -1469,12 +1469,47 @@ Indented shorthand also supports groups (place arrow directly after group header
1469
1469
  ### 13.6 Directives
1470
1470
 
1471
1471
  - `direction TB` — top-to-bottom layout (default: `LR`)
1472
+ - `box-metric <Label> [color]` — name a numeric value dimension (see §13.8); optional trailing color sets the ramp hue
1473
+ - `show-values` — print each box's numeric value as text (off by default)
1472
1474
 
1473
1475
  ### 13.7 Options
1474
1476
 
1475
1477
  - `active-tag GroupName` — set active tag group for coloring
1478
+ - `active-tag none` — suppress tag coloring
1479
+ - `active-tag <metric>` — make the value ramp the active dimension (see §13.8)
1476
1480
  - `hide team:Backend, team:Frontend` — hide nodes with matching tag values (colon syntax for tag:value)
1477
1481
 
1482
+ ### 13.8 Value metric (numeric ramp)
1483
+
1484
+ Boxes can carry a numeric measure that drives a continuous color ramp — a
1485
+ choropleth-style "value dimension" alongside the categorical tag groups.
1486
+
1487
+ ```
1488
+ boxes-and-lines Fleet Crews
1489
+ box-metric Crew blue
1490
+ show-values
1491
+
1492
+ Flagship value: 120
1493
+ Frigate value: 40
1494
+ Sloop value: 12
1495
+ Flagship -> Frigate
1496
+ Flagship -> Sloop
1497
+ ```
1498
+
1499
+ - `value: <number>` on any box records its measure (a reserved metadata key —
1500
+ lifted out, never rendered as a tag). Non-numeric values are an error.
1501
+ - `box-metric <Label> [color]` names the dimension and optionally sets the ramp
1502
+ hue (default: the palette's primary color).
1503
+ - The ramp anchors at `0` for all-non-negative data, else at the data minimum.
1504
+ - The value ramp is the resting-active dimension whenever any box has a
1505
+ `value:` (so value shading works in static export with no interaction).
1506
+ `active-tag <tag-group>` switches to a tag group; `active-tag none` suppresses
1507
+ tinting; `active-tag <metric>` forces the value ramp. On a name collision
1508
+ between a tag group and the metric label, the tag group wins.
1509
+ - When the value ramp is active, every box tints along the min→max ramp and the
1510
+ legend shows a gradient capsule; boxes without a `value:` get a neutral fill.
1511
+ - `show-values` additionally prints each box's number as text.
1512
+
1478
1513
  ---
1479
1514
 
1480
1515
  ## 15. Timeline Diagrams
@@ -2751,7 +2786,8 @@ route Miami style: arc
2751
2786
  - Title is the declaration line; `caption` (data-source attribution, travels with the exported PNG) is the only chrome directive. There is no `subtitle`.
2752
2787
  - Legend auto-composes below the title: the value ramp + `region-metric` and each tag group are **selectable colouring groups** (collapse/activate to flip the fill); POI size (`poi-metric`) and edge thickness (`flow-metric`) are self-evident from scale and carry no legend key in v1. `no-legend` suppresses all of it.
2753
2788
  - **Region and POI labels are on by default.** Region labels auto-fit **full → abbrev → hide** (a US-state 2-letter abbreviation is tried when the full name doesn't fit; other regions degrade full → hide); POI labels are collision-managed. Labels render **on the map** (export-safe), escalating inline → leader line → numbered pin in dense clusters; markers never move. A wide map in a narrow column (< ~480px) prefers abbreviations and drops reference relief, as if zoomed out.
2754
- - **Cosmetic features are on by default**; the only switches are bare `no-*` opt-outs (no positive opt-in flag): `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-legend`. A plain look = the four basemap flags together.
2789
+ - **Cosmetic features are on by default**; the only switches are bare `no-*` opt-outs (no positive opt-in flag): `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-legend`, `no-colorize`. A plain look = the four basemap flags together (`no-colorize` is **not** one of the four — it toggles region *fill style*, not a basemap backdrop layer).
2790
+ - **Colorize (distinct political fills) is the default for any map without region data.** Unless a region carries data (a `value:` or a tag), every region drawn at the resolved extent is filled a **distinct light pastel** such that no two bordering regions share a hue — the conventional "colour the countries/states so neighbours separate" look, with zero config. It applies to named-region maps, POI/route-only maps, and even a bare `map` (the whole world colours as the backdrop). The fills are **non-semantic** (no legend entry) and **extent-independent** (a region's colour is the same at any width and in an inset). A direct trailing colour (`Texas red`) paints on top as a highlight and does not suppress colorize; adding any `value:`/tag flips the map to the data dress (colorize auto-suppressed, no error). `no-colorize` forces the plain green-land + blue-water dress — useful when many POIs/routes should pop against a calm map.
2755
2791
 
2756
2792
  ### Name resolution
2757
2793
 
@@ -2767,7 +2803,7 @@ route Miami style: arc
2767
2803
 
2768
2804
  ### Directives & reserved keys
2769
2805
 
2770
- The directive set is **12, all colon-free**: six naming intent the renderer can't infer — `region-metric`, `poi-metric`, `flow-metric`, `locale`, `active-tag`, `caption` — and six `no-*` cosmetic opt-outs — `no-legend`, `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`. There is **no** `projection`, `scale`, `subtitle`, `surface`, `region`, or label-enum directive, and cosmetics have no positive opt-in form. Reserved metadata keys (need colons): `value`, `label`, `style` (`value` = the one numeric channel: region shade / POI size / edge thickness); `surface:` is no longer recognized. A bare US state postal code resolves to that state (`poi Portland OR` → Oregon; `CA` = California). Coordinates are positional (no `at:` key). Projection is inferred from extent + whether the map carries data (US → albers-usa; world data → Equal Earth; world reference → natural-earth; regional → mercator) and cannot be overridden.
2806
+ The directive set is **13, all colon-free**: six naming intent the renderer can't infer — `region-metric`, `poi-metric`, `flow-metric`, `locale`, `active-tag`, `caption` — and seven `no-*` cosmetic opt-outs — `no-legend`, `no-coastline`, `no-relief`, `no-context-labels`, `no-region-labels`, `no-poi-labels`, `no-colorize`. There is **no** `projection`, `scale`, `subtitle`, `surface`, `region`, or label-enum directive, and cosmetics have no positive opt-in form. Reserved metadata keys (need colons): `value`, `label`, `style` (`value` = the one numeric channel: region shade / POI size / edge thickness); `surface:` is no longer recognized. A bare US state postal code resolves to that state (`poi Portland OR` → Oregon; `CA` = California). Coordinates are positional (no `at:` key). Projection is inferred from extent + whether the map carries data (US → albers-usa; world data → Equal Earth; world reference → natural-earth; regional → mercator) and cannot be overridden.
2771
2807
 
2772
2808
  ---
2773
2809
 
@@ -6,28 +6,30 @@ tag Priority p High red, Medium orange, Low gray
6
6
  active-tag Team
7
7
  hide priority:Low
8
8
 
9
+ box-metric Load orange
10
+
9
11
  direction LR
10
12
 
11
13
  // --- Services ---
12
- API Gateway t: Backend
14
+ API Gateway t: Backend, value: 850
13
15
  Main entry point for all requests
14
16
  Routes to **backend services**
15
17
  -routes-> UserService
16
18
  -routes-> ProductService
17
19
  -routes-> OrderService
18
20
 
19
- UserService t: Backend
21
+ UserService t: Backend, value: 430
20
22
  Handles auth and profiles
21
23
  Uses `JWT` tokens for sessions
22
24
  -reads-> UserDB
23
25
  -checks-> SessionCache
24
26
 
25
- ProductService t: Frontend, description: Product catalog and search
27
+ ProductService t: Frontend, value: 620, description: Product catalog and search
26
28
  Supports *full-text* search
27
29
  -queries-> ProductDB
28
30
  -invalidates-> ProductCache
29
31
 
30
- OrderService t: Backend
32
+ OrderService t: Backend, value: 290
31
33
  Order processing pipeline
32
34
  Validates inventory before commit
33
35
  -writes-> OrderDB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@ import {
26
26
  extractColor,
27
27
  parseFirstLine,
28
28
  OPTION_NOCOLON_RE,
29
+ peelTrailingColorName,
29
30
  splitNameAndMeta,
30
31
  tryParseSharedOption,
31
32
  warnUnknownMetaKeys,
@@ -297,6 +298,26 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
297
298
  continue;
298
299
  }
299
300
 
301
+ // box-metric / show-values directives — pre-content only (like
302
+ // active-tag). Explicit regex branches: a bare flag and a
303
+ // `key value` form won't both match the active-tag OPTION codepath.
304
+ if (!contentStarted) {
305
+ const metricMatch = trimmed.match(/^box-metric\s+(.+)$/i);
306
+ if (metricMatch) {
307
+ // Regex capture group present after successful match.
308
+ const { label, colorName } = peelTrailingColorName(
309
+ metricMatch[1]!.trim()
310
+ );
311
+ result.boxMetric = label;
312
+ if (colorName !== undefined) result.boxMetricColor = colorName;
313
+ continue;
314
+ }
315
+ if (/^show-values$/i.test(trimmed)) {
316
+ result.showValues = true;
317
+ continue;
318
+ }
319
+ }
320
+
300
321
  // active-tag directive
301
322
  if (!contentStarted) {
302
323
  const optMatch = trimmed.match(OPTION_NOCOLON_RE);
@@ -784,6 +805,23 @@ function parseNodeLine(
784
805
  delete metadata['description'];
785
806
  }
786
807
 
808
+ // Lift `value: X` out of metadata into a typed numeric field (mirror of the
809
+ // map parser). Validate finite-numeric; delete from metadata so it never
810
+ // becomes a `data-tag-value` attribute.
811
+ let value: number | undefined;
812
+ if (metadata['value'] !== undefined) {
813
+ const raw = metadata['value'];
814
+ const num = Number(raw);
815
+ if (Number.isFinite(num)) {
816
+ value = num;
817
+ } else {
818
+ diagnostics.push(
819
+ makeDgmoError(lineNum, `value must be a number (got "${raw}")`, 'error')
820
+ );
821
+ }
822
+ delete metadata['value'];
823
+ }
824
+
787
825
  // TD-18 alias is now peeled by splitNameAndMeta — re-register if set.
788
826
  if (split.alias) {
789
827
  nameAliasMap?.set(normalizeName(split.alias), label);
@@ -796,6 +834,7 @@ function parseNodeLine(
796
834
  lineNumber: lineNum,
797
835
  metadata,
798
836
  ...(description !== undefined && { description }),
837
+ ...(value !== undefined && { value }),
799
838
  };
800
839
  }
801
840
 
@@ -11,6 +11,7 @@ import type {
11
11
  LegendConfig,
12
12
  LegendState,
13
13
  LegendCallbacks,
14
+ LegendGroupData,
14
15
  ControlsGroupToggle,
15
16
  } from '../utils/legend-types';
16
17
  import {
@@ -19,7 +20,8 @@ import {
19
20
  TITLE_Y,
20
21
  } from '../utils/title-constants';
21
22
  import { contrastText, mix, shapeFill } from '../palettes/color-utils';
22
- import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
23
+ import { resolveColor } from '../colors';
24
+ import { resolveTagColor } from '../utils/tag-groups';
23
25
  import type { TagGroup } from '../utils/tag-groups';
24
26
  import type { PaletteColors } from '../palettes';
25
27
  import { renderInlineText } from '../utils/inline-markdown';
@@ -33,7 +35,9 @@ import { ScaleContext } from '../utils/scaling';
33
35
 
34
36
  // ── Constants (aligned with infra pattern) ─────────────────
35
37
  const DIAGRAM_PADDING = 20;
36
- const NODE_FONT_SIZE = 13;
38
+ // Box labels run smaller than the 13px org/infra use — boxes-and-lines nodes are
39
+ // narrower (~97px), so a smaller label fits more text per line before wrapping.
40
+ const NODE_FONT_SIZE = 11;
37
41
  const MIN_NODE_FONT_SIZE = 9;
38
42
  const EDGE_LABEL_FONT_SIZE = 11;
39
43
  const EDGE_STROKE_WIDTH = 1.5;
@@ -50,6 +54,9 @@ const NODE_TEXT_PADDING = 12;
50
54
  const GROUP_RX = 8;
51
55
  const GROUP_LABEL_FONT_SIZE = 14;
52
56
  const GROUP_LABEL_ZONE = 32;
57
+ // % tint floor so the ramp minimum still reads as "low, present" (mirror map).
58
+ const RAMP_FLOOR = 15;
59
+ const VALUE_FONT_SIZE = 11;
53
60
 
54
61
  type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
55
62
  type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
@@ -184,8 +191,30 @@ function nodeColors(
184
191
  activeGroupName: string | null,
185
192
  palette: PaletteColors,
186
193
  isDark: boolean,
194
+ value: {
195
+ active: boolean;
196
+ hue: string;
197
+ fillForValue: (v: number) => string;
198
+ },
187
199
  solid?: boolean
188
200
  ): { fill: string; stroke: string; text: string } {
201
+ // Untagged-neutral fill, reused by the value path for no-value boxes.
202
+ 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
+ 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;
211
+ const text = contrastText(
212
+ fill,
213
+ palette.textOnFillLight,
214
+ palette.textOnFillDark
215
+ );
216
+ return { fill, stroke, text };
217
+ }
189
218
  const tagColor = resolveTagColor(
190
219
  node.metadata,
191
220
  [...tagGroups],
@@ -368,6 +397,83 @@ export function renderBoxesAndLines(
368
397
  const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
369
398
  const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
370
399
  const sTitleY = sctx.structural(TITLE_Y);
400
+
401
+ // ── Value ramp + active-dimension resolution (mirror of map's value model) ──
402
+ // The ramp is computed in the renderer (architectural divergence from the
403
+ // map, which precomputes in layout) — node sizes are value-independent, and
404
+ // this file already owns all colouring + the legend build. Hoisted ONCE
405
+ // before the node loop so `fillForValue` is not recomputed per node.
406
+ const nodeValues = parsed.nodes
407
+ .filter((n) => n.value !== undefined)
408
+ .map((n) => n.value!);
409
+ const hasRamp = nodeValues.length > 0;
410
+ const allNonNegative = hasRamp && nodeValues.every((v) => v >= 0);
411
+ const rampMin = allNonNegative ? 0 : Math.min(...nodeValues);
412
+ const rampMax = Math.max(...nodeValues);
413
+ // Default hue = palette.primary (NOT red like the map — boxes have no water to
414
+ // stand out against, and red reads as alarm on a neutral metric). A trailing
415
+ // color on `box-metric` overrides.
416
+ const rampHue =
417
+ resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
418
+ // Lift the ramp anchor off the near-black surface on dark themes so the
419
+ // lowest values read as a clear muted tint rather than sinking to the surface.
420
+ const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
421
+ const fillForValue = (v: number): string => {
422
+ const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
423
+ const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
424
+ return mix(rampHue, rampBase, pct);
425
+ };
426
+ const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || 'Value' : null;
427
+
428
+ // Local active-dimension resolver — mirror map's inline matchColorGroup /
429
+ // activeIsScore. Do NOT extend the shared resolveActiveTagGroup (it has a
430
+ // fixed 3-arg signature consumed by 7 chart types). On a name collision
431
+ // between a tag group and the metric label, the tag group wins (AC9).
432
+ const matchColorGroup = (v: string): string | null => {
433
+ const lv = v.trim().toLowerCase();
434
+ if (lv === '' || lv === 'none') return null;
435
+ const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
436
+ if (tg) return tg.name;
437
+ if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
438
+ return v; // unknown name passes through → renders neutral
439
+ };
440
+ const override = activeTagGroup; // string | null | undefined
441
+ let activeGroup: string | null;
442
+ if (override !== undefined) {
443
+ activeGroup = override === null ? null : matchColorGroup(override);
444
+ } else if (parsed.options['active-tag'] !== undefined) {
445
+ activeGroup = matchColorGroup(parsed.options['active-tag']);
446
+ } else {
447
+ // Default-active dimension: value ramp when any box has a value, else the
448
+ // first declared tag group, else none.
449
+ activeGroup =
450
+ VALUE_NAME ??
451
+ (parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null);
452
+ }
453
+ const activeIsValue = VALUE_NAME !== null && activeGroup === VALUE_NAME;
454
+
455
+ // Synthetic legend group for the value ramp (empty entries + gradient),
456
+ // prepended to the tag groups handed to renderLegendD3 — exactly like the
457
+ // map's VALUE_NAME group. The shared legend infra renders the gradient capsule
458
+ // ONLY when it is the active group (legendState.activeGroup === its name).
459
+ const valueGroup: LegendGroupData | null =
460
+ VALUE_NAME !== null
461
+ ? {
462
+ name: VALUE_NAME,
463
+ entries: [],
464
+ gradient: {
465
+ min: rampMin,
466
+ max: rampMax,
467
+ hue: rampHue,
468
+ base: rampBase,
469
+ },
470
+ }
471
+ : null;
472
+ const legendGroups: readonly LegendGroupData[] = [
473
+ ...(valueGroup ? [valueGroup] : []),
474
+ ...parsed.tagGroups,
475
+ ];
476
+
371
477
  // Reserve legend height only when a legend will actually render. App-hosted
372
478
  // controls move the Descriptions toggle to the app overlay, so a
373
479
  // descriptions-only chart (no tag groups) reserves nothing.
@@ -375,13 +481,13 @@ export function renderBoxesAndLines(
375
481
  (n) => n.description && n.description.length > 0
376
482
  );
377
483
  const willRenderLegend =
378
- parsed.tagGroups.length > 0 ||
484
+ legendGroups.length > 0 ||
379
485
  (reserveHasDescriptions && controlsHost !== 'app');
380
486
  const sLegendHeight = willRenderLegend
381
487
  ? sctx.structural(
382
488
  getMaxLegendReservedHeight(
383
489
  {
384
- groups: parsed.tagGroups,
490
+ groups: legendGroups,
385
491
  position: { placement: 'top-center', titleRelation: 'below-title' },
386
492
  mode: exportMode ? 'export' : 'preview',
387
493
  },
@@ -390,12 +496,6 @@ export function renderBoxesAndLines(
390
496
  )
391
497
  : 0;
392
498
 
393
- const activeGroup = resolveActiveTagGroup(
394
- parsed.tagGroups,
395
- parsed.options['active-tag'],
396
- activeTagGroup
397
- );
398
-
399
499
  // Build hidden set
400
500
  const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
401
501
 
@@ -414,7 +514,7 @@ export function renderBoxesAndLines(
414
514
  (n) => n.description && n.description.length > 0
415
515
  );
416
516
  const needsLegend =
417
- parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
517
+ legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
418
518
  const legendH = needsLegend ? sLegendHeight + 8 : 0;
419
519
 
420
520
  const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
@@ -809,6 +909,7 @@ export function renderBoxesAndLines(
809
909
  activeGroup,
810
910
  palette,
811
911
  isDark,
912
+ { active: activeIsValue, hue: rampHue, fillForValue },
812
913
  parsed.options['solid-fill'] === 'on'
813
914
  );
814
915
 
@@ -826,6 +927,12 @@ export function renderBoxesAndLines(
826
927
  nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
827
928
  }
828
929
 
930
+ // Numeric value drives the gradient scrub; guard on !== undefined so a
931
+ // legitimate `value: 0` still emits data-value="0" (0 is falsy).
932
+ if (node.value !== undefined) {
933
+ nodeG.attr('data-value', node.value);
934
+ }
935
+
829
936
  if (onClickItem) {
830
937
  nodeG.on('click', (event: Event) => {
831
938
  // Don't intercept clicks on links in description text
@@ -982,6 +1089,61 @@ export function renderBoxesAndLines(
982
1089
  fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
983
1090
  nodeG.append('title').text(tooltipText);
984
1091
  }
1092
+ } else if (parsed.showValues && node.value !== undefined) {
1093
+ // Plain node with show-values: label header + thin divider + a
1094
+ // "Metric: value" line below (org/infra card style), instead of a
1095
+ // vertically-centered label with a floating number.
1096
+ const valueLabel = parsed.boxMetric
1097
+ ? `${parsed.boxMetric}: ${node.value}`
1098
+ : String(node.value);
1099
+ // Fixed header zone (not label-height-driven) so the divider sits at a
1100
+ // UNIFORM Y across every box, regardless of label line count (infra/org
1101
+ // both anchor the separator to a constant header height).
1102
+ const headerH = ln.height / 2;
1103
+ const sepY = -ln.height / 2 + headerH;
1104
+ const fitted = fitLabelToHeader(node.label, ln.width, 2);
1105
+ const labelLineH = fitted.fontSize * 1.3;
1106
+ const labelTotalH = fitted.lines.length * labelLineH;
1107
+ const headerCenterY = -ln.height / 2 + headerH / 2;
1108
+ for (let li = 0; li < fitted.lines.length; li++) {
1109
+ nodeG
1110
+ .append('text')
1111
+ .attr('x', 0)
1112
+ .attr(
1113
+ 'y',
1114
+ headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
1115
+ )
1116
+ .attr('text-anchor', 'middle')
1117
+ .attr('dominant-baseline', 'central')
1118
+ .attr('font-size', fitted.fontSize)
1119
+ .attr('font-weight', '600')
1120
+ .attr('fill', colors.text)
1121
+ // In-bounds by loop guard.
1122
+ .text(fitted.lines[li]!);
1123
+ }
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.
1126
+ nodeG
1127
+ .append('line')
1128
+ .attr('x1', -ln.width / 2)
1129
+ .attr('y1', sepY)
1130
+ .attr('x2', ln.width / 2)
1131
+ .attr('y2', sepY)
1132
+ .attr('stroke', colors.stroke)
1133
+ .attr('stroke-opacity', 0.3)
1134
+ .attr('stroke-width', 1);
1135
+ // "Metric: value" centered in the space below the divider.
1136
+ nodeG
1137
+ .append('text')
1138
+ .attr('class', 'bl-node-value')
1139
+ .attr('x', 0)
1140
+ .attr('y', (sepY + ln.height / 2) / 2)
1141
+ .attr('text-anchor', 'middle')
1142
+ .attr('dominant-baseline', 'central')
1143
+ .attr('font-size', VALUE_FONT_SIZE)
1144
+ .attr('fill', colors.text)
1145
+ .attr('opacity', 0.85)
1146
+ .text(valueLabel);
985
1147
  } else {
986
1148
  const maxLabelLines = Math.max(
987
1149
  2,
@@ -1004,6 +1166,48 @@ export function renderBoxesAndLines(
1004
1166
  .text(fitted.lines[li]!);
1005
1167
  }
1006
1168
  }
1169
+
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);
1210
+ }
1007
1211
  }
1008
1212
 
1009
1213
  // ── Render legend ──────────────────────────────────────
@@ -1011,9 +1215,10 @@ export function renderBoxesAndLines(
1011
1215
  (n) => n.description && n.description.length > 0
1012
1216
  );
1013
1217
  // App-hosted: the Descriptions control moves to the app overlay, so a
1014
- // descriptions-only legend (no tag groups) has nothing left to render.
1218
+ // descriptions-only legend (no tag groups) has nothing left to render. The
1219
+ // value ramp (a synthetic group in legendGroups) also forces a legend.
1015
1220
  const hasLegend =
1016
- parsed.tagGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
1221
+ legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
1017
1222
 
1018
1223
  if (hasLegend) {
1019
1224
  // Build controls group for description toggle. App-hosted controls own the
@@ -1035,7 +1240,7 @@ export function renderBoxesAndLines(
1035
1240
  }
1036
1241
 
1037
1242
  const legendConfig: LegendConfig = {
1038
- groups: parsed.tagGroups,
1243
+ groups: legendGroups,
1039
1244
  position: { placement: 'top-center', titleRelation: 'below-title' },
1040
1245
  mode: exportMode ? 'export' : 'preview',
1041
1246
  // Keep inactive sibling tag groups visible as collapsed pills so the user
@@ -6,6 +6,9 @@ export interface BLNode {
6
6
  readonly lineNumber: number;
7
7
  readonly metadata: Readonly<Record<string, string>>;
8
8
  readonly description?: readonly string[];
9
+ /** Numeric measure lifted from `value: X` metadata (mirror of map's
10
+ * `region.value`). Drives the value ramp / choropleth tinting. */
11
+ readonly value?: number;
9
12
  }
10
13
 
11
14
  export interface BLEdge {
@@ -36,6 +39,12 @@ export interface ParsedBoxesAndLines {
36
39
  readonly options: Readonly<Record<string, string>>;
37
40
  readonly initialHiddenTagValues: ReadonlyMap<string, ReadonlySet<string>>;
38
41
  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`. */
44
+ readonly boxMetric?: string;
45
+ readonly boxMetricColor?: string;
46
+ /** `show-values` — print each box's numeric value as text (opt-in). */
47
+ readonly showValues?: boolean;
39
48
  readonly diagnostics: readonly DgmoError[];
40
49
  readonly error: string | null;
41
50
  }
package/src/completion.ts CHANGED
@@ -447,6 +447,8 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
447
447
  direction: { description: 'Layout direction', values: ['LR', 'TB'] },
448
448
  'active-tag': { description: 'Active tag group name' },
449
449
  hide: { description: 'Hide tag:value pairs' },
450
+ 'box-metric': { description: 'Metric label for the value ramp' },
451
+ 'show-values': { description: 'Print box values as text' },
450
452
  }),
451
453
  ],
452
454
  [
@@ -740,12 +742,9 @@ export const PIPE_METADATA = new Map<string, PipeContextMap>([
740
742
  {
741
743
  node: {
742
744
  description: { description: 'Node description text' },
745
+ value: { description: 'Numeric value for the metric ramp' },
743
746
  },
744
- edge: {
745
- width: { description: 'Edge stroke width in pixels' },
746
- split: { description: 'Traffic split percentage' },
747
- fanout: { description: 'Fanout multiplier (integer >= 1)' },
748
- },
747
+ edge: {},
749
748
  },
750
749
  ],
751
750
  [
package/src/d3.ts CHANGED
@@ -4238,7 +4238,6 @@ function renderTimelineHorizontalTimeSort(
4238
4238
  ): void {
4239
4239
  const {
4240
4240
  width,
4241
- height,
4242
4241
  tooltip,
4243
4242
  solid,
4244
4243
  textColor,
@@ -4301,8 +4300,20 @@ function renderTimelineHorizontalTimeSort(
4301
4300
  ? -(topScaleH + markerReserve + ERA_ROW_H / 2)
4302
4301
  : 0;
4303
4302
  const innerWidth = width - margin.left - margin.right;
4304
- const innerHeight = height - margin.top - margin.bottom;
4305
- const rowH = Math.min(ctx.structural(28), innerHeight / sorted.length);
4303
+ // Each event gets a fixed comfortable row. The old behaviour compressed rowH
4304
+ // to fit the container height (`min(28, avail / n)`), but that only ever
4305
+ // shrank rows BELOW the 22px bar height — cramming events into overlap when
4306
+ // the host surface was shorter than the content required (e.g. the app's
4307
+ // fixed-height embedded-diagram surface). A constant rowH never overlaps:
4308
+ // when the container is taller than needed the SVG shrinks to the content
4309
+ // (top-aligned via preserveAspectRatio); when shorter, the SVG grows past it
4310
+ // and the host collapses/expands to the rendered height. This also makes the
4311
+ // interactive preview match the exported image, which already used rowH=28.
4312
+ const rowH = ctx.structural(28);
4313
+ // Draw the era bands and time axis to the content height (not the full
4314
+ // container) so the axis sits just below the last event.
4315
+ const innerHeight = rowH * sorted.length;
4316
+ const usedHeight = margin.top + innerHeight + margin.bottom;
4306
4317
 
4307
4318
  const xScale = d3Scale
4308
4319
  .scaleLinear()
@@ -4313,8 +4324,8 @@ function renderTimelineHorizontalTimeSort(
4313
4324
  .select(container)
4314
4325
  .append('svg')
4315
4326
  .attr('width', width)
4316
- .attr('height', height)
4317
- .attr('viewBox', `0 0 ${width} ${height}`)
4327
+ .attr('height', usedHeight)
4328
+ .attr('viewBox', `0 0 ${width} ${usedHeight}`)
4318
4329
  .attr('preserveAspectRatio', 'xMidYMin meet')
4319
4330
  .style('background', bgColor);
4320
4331
 
@@ -7743,6 +7754,10 @@ export async function renderForExport(
7743
7754
  // here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
7744
7755
  // omit this and fall back to the fs loader.
7745
7756
  mapData?: import('./map/resolved-types').MapData;
7757
+ // WYSIWYG map export: the live preview pane's displayed aspect (w/h). When
7758
+ // set, the map canvas adopts it + stretch-fills so the PNG matches the
7759
+ // on-screen map. The app passes this; headless consumers omit it.
7760
+ mapAspect?: number;
7746
7761
  }
7747
7762
  ): Promise<string> {
7748
7763
  const exportMode = options?.exportMode ?? false;
@@ -8483,7 +8498,12 @@ export async function renderForExport(
8483
8498
  // aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
8484
8499
  // export matches the content's natural shape — no vertical stretch, no
8485
8500
  // letterbox bands. `preferContain` rides along to the renderer.
8486
- const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
8501
+ const dims = mapExportDimensions(
8502
+ mapResolved,
8503
+ mapData,
8504
+ EXPORT_WIDTH,
8505
+ options?.mapAspect
8506
+ );
8487
8507
  const container = createExportContainer(dims.width, dims.height);
8488
8508
  renderMapForExport(
8489
8509
  container,
@@ -110,6 +110,9 @@ export const DIRECTIVE_KEYWORDS = new Set([
110
110
  'hide',
111
111
  'mode',
112
112
  'direction',
113
+ // Boxes-and-lines
114
+ 'box-metric',
115
+ 'show-values',
113
116
  // ER
114
117
  'notation',
115
118
  // Class
package/src/index.ts CHANGED
@@ -213,6 +213,14 @@ export { themes, type Theme } from './themes';
213
213
 
214
214
  export { getMinDimensions } from './dimensions';
215
215
 
216
+ // ============================================================
217
+ // SVG embed normalization (responsive inline embedding)
218
+ // ============================================================
219
+ // Tightens a static render() SVG's viewBox to its content + strips fixed
220
+ // width/height so hosts (Obsidian, remark/markdown, web) can size it to its
221
+ // natural aspect ratio with no dead space. Pure string transform.
222
+ export { normalizeSvgForEmbed, getEmbedSvgViewBox } from './utils/svg-embed';
223
+
216
224
  // ============================================================
217
225
  // Map chart-type completion (gazetteer-fed; §24B.5/.8)
218
226
  // ============================================================