@diagrammo/dgmo 0.22.0 → 0.23.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 (43) hide show
  1. package/dist/advanced.cjs +238 -48
  2. package/dist/advanced.d.cts +17 -0
  3. package/dist/advanced.d.ts +17 -0
  4. package/dist/advanced.js +238 -48
  5. package/dist/auto.cjs +236 -42
  6. package/dist/auto.js +115 -115
  7. package/dist/auto.mjs +236 -42
  8. package/dist/cli.cjs +153 -153
  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 +232 -41
  14. package/dist/index.js +232 -41
  15. package/dist/internal.cjs +238 -48
  16. package/dist/internal.d.cts +17 -0
  17. package/dist/internal.d.ts +17 -0
  18. package/dist/internal.js +238 -48
  19. package/dist/map-data/PROVENANCE.json +1 -1
  20. package/dist/map-data/gazetteer.json +1 -1
  21. package/dist/map-data/mountain-ranges.json +1 -1
  22. package/dist/map-data/water-bodies.json +1 -1
  23. package/dist/map-data/world-coarse.json +1 -1
  24. package/dist/map-data/world-detail.json +1 -1
  25. package/docs/language-reference.md +35 -0
  26. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  27. package/package.json +1 -1
  28. package/src/boxes-and-lines/parser.ts +39 -0
  29. package/src/boxes-and-lines/renderer.ts +171 -13
  30. package/src/boxes-and-lines/types.ts +9 -0
  31. package/src/completion.ts +4 -5
  32. package/src/d3.ts +12 -4
  33. package/src/editor/keywords.ts +3 -0
  34. package/src/map/data/PROVENANCE.json +1 -1
  35. package/src/map/data/README.md +6 -0
  36. package/src/map/data/gazetteer.json +1 -1
  37. package/src/map/data/mountain-ranges.json +1 -1
  38. package/src/map/data/water-bodies.json +1 -1
  39. package/src/map/data/world-coarse.json +1 -1
  40. package/src/map/data/world-detail.json +1 -1
  41. package/src/map/layout.ts +111 -18
  42. package/src/map/renderer.ts +95 -4
  43. package/src/utils/reserved-key-registry.ts +5 -3
@@ -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
@@ -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.23.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';
@@ -50,6 +52,9 @@ const NODE_TEXT_PADDING = 12;
50
52
  const GROUP_RX = 8;
51
53
  const GROUP_LABEL_FONT_SIZE = 14;
52
54
  const GROUP_LABEL_ZONE = 32;
55
+ // % tint floor so the ramp minimum still reads as "low, present" (mirror map).
56
+ const RAMP_FLOOR = 15;
57
+ const VALUE_FONT_SIZE = 11;
53
58
 
54
59
  type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
55
60
  type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
@@ -184,8 +189,30 @@ function nodeColors(
184
189
  activeGroupName: string | null,
185
190
  palette: PaletteColors,
186
191
  isDark: boolean,
192
+ value: {
193
+ active: boolean;
194
+ hue: string;
195
+ fillForValue: (v: number) => string;
196
+ },
187
197
  solid?: boolean
188
198
  ): { fill: string; stroke: string; text: string } {
199
+ // Untagged-neutral fill, reused by the value path for no-value boxes.
200
+ const neutralFill = mix(palette.bg, palette.text, isDark ? 90 : 95);
201
+ // Value dimension active: choropleth tint by the node's value, neutral when a
202
+ // box has no value (mirror map: `value !== undefined ? fillForValue : neutral`).
203
+ if (value.active) {
204
+ const fill =
205
+ node.value !== undefined ? value.fillForValue(node.value) : neutralFill;
206
+ // Stroke = the ramp hue (NOT a tag color — there may be none); a present
207
+ // stroke is required for the app's --bl-node-stroke hover-dim to work.
208
+ const stroke = value.hue;
209
+ const text = contrastText(
210
+ fill,
211
+ palette.textOnFillLight,
212
+ palette.textOnFillDark
213
+ );
214
+ return { fill, stroke, text };
215
+ }
189
216
  const tagColor = resolveTagColor(
190
217
  node.metadata,
191
218
  [...tagGroups],
@@ -368,6 +395,83 @@ export function renderBoxesAndLines(
368
395
  const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
369
396
  const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
370
397
  const sTitleY = sctx.structural(TITLE_Y);
398
+
399
+ // ── Value ramp + active-dimension resolution (mirror of map's value model) ──
400
+ // The ramp is computed in the renderer (architectural divergence from the
401
+ // map, which precomputes in layout) — node sizes are value-independent, and
402
+ // this file already owns all colouring + the legend build. Hoisted ONCE
403
+ // before the node loop so `fillForValue` is not recomputed per node.
404
+ const nodeValues = parsed.nodes
405
+ .filter((n) => n.value !== undefined)
406
+ .map((n) => n.value!);
407
+ const hasRamp = nodeValues.length > 0;
408
+ const allNonNegative = hasRamp && nodeValues.every((v) => v >= 0);
409
+ const rampMin = allNonNegative ? 0 : Math.min(...nodeValues);
410
+ const rampMax = Math.max(...nodeValues);
411
+ // Default hue = palette.primary (NOT red like the map — boxes have no water to
412
+ // stand out against, and red reads as alarm on a neutral metric). A trailing
413
+ // color on `box-metric` overrides.
414
+ const rampHue =
415
+ resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
416
+ // Lift the ramp anchor off the near-black surface on dark themes so the
417
+ // lowest values read as a clear muted tint rather than sinking to the surface.
418
+ const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
419
+ const fillForValue = (v: number): string => {
420
+ const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
421
+ const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
422
+ return mix(rampHue, rampBase, pct);
423
+ };
424
+ const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || 'Value' : null;
425
+
426
+ // Local active-dimension resolver — mirror map's inline matchColorGroup /
427
+ // activeIsScore. Do NOT extend the shared resolveActiveTagGroup (it has a
428
+ // fixed 3-arg signature consumed by 7 chart types). On a name collision
429
+ // between a tag group and the metric label, the tag group wins (AC9).
430
+ const matchColorGroup = (v: string): string | null => {
431
+ const lv = v.trim().toLowerCase();
432
+ if (lv === 'none') return null;
433
+ const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
434
+ if (tg) return tg.name;
435
+ if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
436
+ return v; // unknown name passes through → renders neutral
437
+ };
438
+ const override = activeTagGroup; // string | null | undefined
439
+ let activeGroup: string | null;
440
+ if (override !== undefined) {
441
+ activeGroup = override === null ? null : matchColorGroup(override);
442
+ } else if (parsed.options['active-tag'] !== undefined) {
443
+ activeGroup = matchColorGroup(parsed.options['active-tag']);
444
+ } else {
445
+ // Default-active dimension: value ramp when any box has a value, else the
446
+ // first declared tag group, else none.
447
+ activeGroup =
448
+ VALUE_NAME ??
449
+ (parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null);
450
+ }
451
+ const activeIsValue = VALUE_NAME !== null && activeGroup === VALUE_NAME;
452
+
453
+ // Synthetic legend group for the value ramp (empty entries + gradient),
454
+ // prepended to the tag groups handed to renderLegendD3 — exactly like the
455
+ // map's VALUE_NAME group. The shared legend infra renders the gradient capsule
456
+ // ONLY when it is the active group (legendState.activeGroup === its name).
457
+ const valueGroup: LegendGroupData | null =
458
+ VALUE_NAME !== null
459
+ ? {
460
+ name: VALUE_NAME,
461
+ entries: [],
462
+ gradient: {
463
+ min: rampMin,
464
+ max: rampMax,
465
+ hue: rampHue,
466
+ base: rampBase,
467
+ },
468
+ }
469
+ : null;
470
+ const legendGroups: readonly LegendGroupData[] = [
471
+ ...(valueGroup ? [valueGroup] : []),
472
+ ...parsed.tagGroups,
473
+ ];
474
+
371
475
  // Reserve legend height only when a legend will actually render. App-hosted
372
476
  // controls move the Descriptions toggle to the app overlay, so a
373
477
  // descriptions-only chart (no tag groups) reserves nothing.
@@ -375,13 +479,13 @@ export function renderBoxesAndLines(
375
479
  (n) => n.description && n.description.length > 0
376
480
  );
377
481
  const willRenderLegend =
378
- parsed.tagGroups.length > 0 ||
482
+ legendGroups.length > 0 ||
379
483
  (reserveHasDescriptions && controlsHost !== 'app');
380
484
  const sLegendHeight = willRenderLegend
381
485
  ? sctx.structural(
382
486
  getMaxLegendReservedHeight(
383
487
  {
384
- groups: parsed.tagGroups,
488
+ groups: legendGroups,
385
489
  position: { placement: 'top-center', titleRelation: 'below-title' },
386
490
  mode: exportMode ? 'export' : 'preview',
387
491
  },
@@ -390,12 +494,6 @@ export function renderBoxesAndLines(
390
494
  )
391
495
  : 0;
392
496
 
393
- const activeGroup = resolveActiveTagGroup(
394
- parsed.tagGroups,
395
- parsed.options['active-tag'],
396
- activeTagGroup
397
- );
398
-
399
497
  // Build hidden set
400
498
  const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
401
499
 
@@ -414,7 +512,7 @@ export function renderBoxesAndLines(
414
512
  (n) => n.description && n.description.length > 0
415
513
  );
416
514
  const needsLegend =
417
- parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
515
+ legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
418
516
  const legendH = needsLegend ? sLegendHeight + 8 : 0;
419
517
 
420
518
  const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
@@ -809,6 +907,7 @@ export function renderBoxesAndLines(
809
907
  activeGroup,
810
908
  palette,
811
909
  isDark,
910
+ { active: activeIsValue, hue: rampHue, fillForValue },
812
911
  parsed.options['solid-fill'] === 'on'
813
912
  );
814
913
 
@@ -826,6 +925,12 @@ export function renderBoxesAndLines(
826
925
  nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
827
926
  }
828
927
 
928
+ // Numeric value drives the gradient scrub; guard on !== undefined so a
929
+ // legitimate `value: 0` still emits data-value="0" (0 is falsy).
930
+ if (node.value !== undefined) {
931
+ nodeG.attr('data-value', node.value);
932
+ }
933
+
829
934
  if (onClickItem) {
830
935
  nodeG.on('click', (event: Event) => {
831
936
  // Don't intercept clicks on links in description text
@@ -1004,6 +1109,58 @@ export function renderBoxesAndLines(
1004
1109
  .text(fitted.lines[li]!);
1005
1110
  }
1006
1111
  }
1112
+
1113
+ // ── show-values: print the numeric value as text (opt-in) ──
1114
+ // Independent of the active dimension (a user may want the numbers printed
1115
+ // while a tag group tints). Plain nodes: centered below the label. Described
1116
+ // nodes: a top-right corner badge so it never overflows the full body (R2-6).
1117
+ if (parsed.showValues && node.value !== undefined) {
1118
+ const valueText = String(node.value);
1119
+ const descShown = !!(desc && desc.length > 0 && !hideDescriptions);
1120
+ if (descShown) {
1121
+ // Corner badge — pill behind the number so it reads over the header.
1122
+ const padX = 6;
1123
+ const padY = 5;
1124
+ const bw = valueText.length * VALUE_FONT_SIZE * CHAR_WIDTH_RATIO + 8;
1125
+ const bh = VALUE_FONT_SIZE + 4;
1126
+ const bx = ln.width / 2 - bw - 4;
1127
+ const by = -ln.height / 2 + 4;
1128
+ nodeG
1129
+ .append('rect')
1130
+ .attr('x', bx)
1131
+ .attr('y', by)
1132
+ .attr('width', bw)
1133
+ .attr('height', bh)
1134
+ .attr('rx', 3)
1135
+ .attr('fill', palette.bg)
1136
+ .attr('opacity', 0.85);
1137
+ nodeG
1138
+ .append('text')
1139
+ .attr('class', 'bl-node-value')
1140
+ .attr('x', bx + bw - padX)
1141
+ .attr('y', by + padY)
1142
+ .attr('text-anchor', 'end')
1143
+ .attr('dominant-baseline', 'central')
1144
+ .attr('font-size', VALUE_FONT_SIZE)
1145
+ .attr('font-weight', '600')
1146
+ .attr('fill', palette.textMuted)
1147
+ .text(valueText);
1148
+ } else {
1149
+ // Plain node: value centered just above the bottom edge.
1150
+ nodeG
1151
+ .append('text')
1152
+ .attr('class', 'bl-node-value')
1153
+ .attr('x', 0)
1154
+ .attr('y', ln.height / 2 - VALUE_FONT_SIZE)
1155
+ .attr('text-anchor', 'middle')
1156
+ .attr('dominant-baseline', 'central')
1157
+ .attr('font-size', VALUE_FONT_SIZE)
1158
+ .attr('font-weight', '600')
1159
+ .attr('fill', colors.text)
1160
+ .attr('opacity', 0.8)
1161
+ .text(valueText);
1162
+ }
1163
+ }
1007
1164
  }
1008
1165
 
1009
1166
  // ── Render legend ──────────────────────────────────────
@@ -1011,9 +1168,10 @@ export function renderBoxesAndLines(
1011
1168
  (n) => n.description && n.description.length > 0
1012
1169
  );
1013
1170
  // App-hosted: the Descriptions control moves to the app overlay, so a
1014
- // descriptions-only legend (no tag groups) has nothing left to render.
1171
+ // descriptions-only legend (no tag groups) has nothing left to render. The
1172
+ // value ramp (a synthetic group in legendGroups) also forces a legend.
1015
1173
  const hasLegend =
1016
- parsed.tagGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
1174
+ legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
1017
1175
 
1018
1176
  if (hasLegend) {
1019
1177
  // Build controls group for description toggle. App-hosted controls own the
@@ -1035,7 +1193,7 @@ export function renderBoxesAndLines(
1035
1193
  }
1036
1194
 
1037
1195
  const legendConfig: LegendConfig = {
1038
- groups: parsed.tagGroups,
1196
+ groups: legendGroups,
1039
1197
  position: { placement: 'top-center', titleRelation: 'below-title' },
1040
1198
  mode: exportMode ? 'export' : 'preview',
1041
1199
  // 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
@@ -4301,8 +4301,16 @@ function renderTimelineHorizontalTimeSort(
4301
4301
  ? -(topScaleH + markerReserve + ERA_ROW_H / 2)
4302
4302
  : 0;
4303
4303
  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);
4304
+ const availInnerHeight = height - margin.top - margin.bottom;
4305
+ const rowH = Math.min(ctx.structural(28), availInnerHeight / sorted.length);
4306
+ // Each event needs only `rowH` of vertical space. When the container is
4307
+ // taller than the rows require (rowH hits its 28px cap), draw the era
4308
+ // bands and time axis to the content height instead of the full container
4309
+ // so the axis sits just below the last event rather than leaving a large
4310
+ // vertical gap. The SVG itself shrinks to match (top-aligned via
4311
+ // preserveAspectRatio) so callers don't reserve dead space below the chart.
4312
+ const innerHeight = rowH * sorted.length;
4313
+ const usedHeight = margin.top + innerHeight + margin.bottom;
4306
4314
 
4307
4315
  const xScale = d3Scale
4308
4316
  .scaleLinear()
@@ -4313,8 +4321,8 @@ function renderTimelineHorizontalTimeSort(
4313
4321
  .select(container)
4314
4322
  .append('svg')
4315
4323
  .attr('width', width)
4316
- .attr('height', height)
4317
- .attr('viewBox', `0 0 ${width} ${height}`)
4324
+ .attr('height', usedHeight)
4325
+ .attr('viewBox', `0 0 ${width} ${usedHeight}`)
4318
4326
  .attr('preserveAspectRatio', 'xMidYMin meet')
4319
4327
  .style('background', bgColor);
4320
4328
 
@@ -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
@@ -1 +1 @@
1
- {"assets":{"gazetteer.json":{"bytes":130706,"gzBytes":56251,"sha256":"8cefc36db73c9337429e66c94339a385656de3ec22a67487b0a146aa37dd632a"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":30081,"gzBytes":8971,"sha256":"b0eb87e6b3c514b882245299fbe698b3caaa3f40494e3d220f973caf6a12a16c"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"world-coarse.json":{"bytes":55426,"gzBytes":18390,"sha256":"9ed5a15d775c0a303003dcf096a209dd433383ea52e18014885d482c38bc01a4"},"world-detail.json":{"bytes":163555,"gzBytes":46762,"sha256":"e99dddc3fccd96465ad4e5cd7bcca839095672e9c595d78276d510c44db84fd7"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2118,"mountainRanges":114,"usStates":56},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_regions_polys.geojson":{"bytes":4305815,"sha256":"33396643d8f0eed408e80bf5ca43dcdb7993f790fdc4eadae311660f8b91fe76"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5546862,"sha256":"e898c1399b05bd521540c5fa56ac3db44b4b2029d432b5f40a1e45f66d5a0929"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-05-25 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"mountainRanges":{"license":"public domain (Natural Earth)","url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_regions_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
1
+ {"assets":{"gazetteer.json":{"bytes":130767,"gzBytes":56261,"sha256":"5ad56e5ba0b3a4f9a6dc8bd3bf8b0fda0e7b86cbe4d85d231114f5dd967d65f7"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":90845,"gzBytes":26493,"sha256":"a698b3f296e61712fb39b3d8d42ec7c4699f8aadecb549367feb7d09f7785580"},"na-lakes.json":{"bytes":39387,"gzBytes":11281,"sha256":"2a41c04969209380d544a09efe354277e12d704458af95955201eb4f698d16c6"},"na-land.json":{"bytes":114082,"gzBytes":32375,"sha256":"7b94c9bb4e809c22813da5ae939e1ff6a781fd77a04d9c1585a9a82d2a195388"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"water-bodies.json":{"bytes":4854,"gzBytes":2123,"sha256":"6d1a407a376c63518329c52189e2887053c4b61062af0597e060050ae8469635"},"world-coarse.json":{"bytes":55436,"gzBytes":18397,"sha256":"5cb42e3c8975dde56504ca5c68ece0a1e71d0929680b5fc8cdab758c8666dbf8"},"world-detail.json":{"bytes":163562,"gzBytes":46767,"sha256":"39f1736eaabe9e21190972be3157822be22ee84fdc41751237f2b516f09a7586"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2119,"mountainRanges":205,"usStates":56,"waterBodies":113},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json":{"bytes":6648697,"sha256":"93c8fdf0e591e113f449d0d466e15c7a9841b9b6571c7afe41f95ba51b322452"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson":{"bytes":13287234,"sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson":{"bytes":5583870,"sha256":"b7b26e50ea917d3696aec87f932def2bf5f890f5770e441d59c162c6f4c92a77"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson":{"bytes":534055,"sha256":"b9c3f7f557d0ff5217906adc82b66ecdac14aa7438df7e518cf6675d037bceb8"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson":{"bytes":1163418,"sha256":"6fe58083e0cc5c7fad9e396970e28a8580bbd8770cfa4d1d7b5a34423e912f97"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5549002,"sha256":"d20e28b2f610da34c21fd82ff6a8e4d24ebe67eba2dccf65bd2c4332ff0f380a"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-06-02 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"marineCoarse":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson","version":"natural-earth 110m (nvkelso vector snapshot)"},"marineDetail":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"mountainRanges":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"naLakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json","version":"natural-earth 10m (martynafford snapshot)"},"naLand":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
@@ -10,6 +10,10 @@ hand-edit — regenerate from source.
10
10
  - `us-states.json` — US states + DC + territories (TopoJSON), keyed by ISO 3166-2.
11
11
  - `lakes.json` — major lakes (Natural Earth 110m, TopoJSON), drawn as water over land.
12
12
  - `rivers.json` — major river centerlines (Natural Earth 110m, TopoJSON), drawn as thin water lines.
13
+ - `na-land.json` — NA-clipped 10m country land (TopoJSON, ISO-keyed): crisp neighbour context under the albers-usa US view.
14
+ - `na-lakes.json` — NA-clipped 10m major lakes (TopoJSON): the lakes counterpart to `na-land.json` for the US view.
15
+ - `mountain-ranges.json` — notable mountain ranges (Natural Earth 50m geography regions, FEATURECLA "Range/mtn", TopoJSON), drawn as a subtle gradient relief cue when the `relief` directive is on. Optional; single tier (no elevation).
16
+ - `water-bodies.json` — water-body orientation labels (`{ entries: [lat, lon, name, tier, kind] }`) from Natural Earth 110m+50m geography marine polys (oceans/seas/gulfs/bays/straits/channels/sounds; rivers + reefs excluded). Anchors are mapshaper inner points; `tier` is the NE scalerank. Drawn only when the `context-labels` directive is on. Optional.
13
17
  - `gazetteer.json` — `{ cities, byName, alt }` city index (see `types.ts`).
14
18
  `byName`/`alt` reference `cities` by array index (normalized).
15
19
  - `PROVENANCE.json` — source versions + per-asset sha256/sizes + GeoNames date range.
@@ -18,6 +22,8 @@ hand-edit — regenerate from source.
18
22
  ## Sources & attribution
19
23
  - **Country boundaries:** Natural Earth via `world-atlas@2.0.2` (public domain).
20
24
  - **US states:** US Census via `us-atlas@3.0.1` (public domain).
25
+ - **Mountain ranges:** Natural Earth 50m `geography_regions_polys` via `nvkelso/natural-earth-vector` (public domain).
26
+ - **Water bodies:** Natural Earth 110m+50m `geography_marine_polys` via `nvkelso/natural-earth-vector` (public domain). One editorial override applied (`Gulf of Mexico` → `Gulf of America`).
21
27
  - **Cities:** Data © **GeoNames**, licensed under **CC BY 4.0**
22
28
  (https://creativecommons.org/licenses/by/4.0/) — https://www.geonames.org/.
23
29