@diagrammo/dgmo 0.21.1 → 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 (87) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2230 -503
  3. package/dist/advanced.d.cts +5731 -0
  4. package/dist/advanced.d.ts +5731 -0
  5. package/dist/advanced.js +2226 -503
  6. package/dist/auto.cjs +2272 -479
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +124 -124
  10. package/dist/auto.mjs +2274 -480
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +16 -16
  13. package/dist/editor.js +16 -16
  14. package/dist/highlight.cjs +18 -13
  15. package/dist/highlight.js +18 -13
  16. package/dist/index.cjs +2253 -465
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2255 -466
  20. package/dist/internal.cjs +2230 -503
  21. package/dist/internal.d.cts +5731 -0
  22. package/dist/internal.d.ts +5731 -0
  23. package/dist/internal.js +2226 -503
  24. package/dist/map-data/PROVENANCE.json +1 -1
  25. package/dist/map-data/gazetteer.json +1 -1
  26. package/dist/map-data/mountain-ranges.json +1 -1
  27. package/dist/map-data/water-bodies.json +1 -0
  28. package/dist/map-data/world-coarse.json +1 -1
  29. package/dist/map-data/world-detail.json +1 -1
  30. package/docs/language-reference.md +55 -9
  31. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  32. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  33. package/gallery/fixtures/map-categorical.dgmo +0 -1
  34. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  35. package/gallery/fixtures/map-coastline.dgmo +7 -0
  36. package/gallery/fixtures/map-colorize.dgmo +11 -0
  37. package/gallery/fixtures/map-direct-color.dgmo +0 -1
  38. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  39. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  40. package/gallery/fixtures/map-route.dgmo +0 -1
  41. package/package.json +1 -1
  42. package/src/advanced.ts +12 -1
  43. package/src/boxes-and-lines/parser.ts +39 -0
  44. package/src/boxes-and-lines/renderer.ts +205 -20
  45. package/src/boxes-and-lines/types.ts +9 -0
  46. package/src/cli.ts +1 -1
  47. package/src/completion.ts +36 -30
  48. package/src/cycle/renderer.ts +14 -1
  49. package/src/d3.ts +20 -6
  50. package/src/editor/highlight-api.ts +4 -0
  51. package/src/editor/keywords.ts +16 -16
  52. package/src/infra/renderer.ts +35 -7
  53. package/src/map/colorize.ts +54 -0
  54. package/src/map/context-labels.ts +429 -0
  55. package/src/map/data/PROVENANCE.json +1 -1
  56. package/src/map/data/README.md +6 -0
  57. package/src/map/data/gazetteer.json +1 -1
  58. package/src/map/data/mountain-ranges.json +1 -1
  59. package/src/map/data/types.ts +34 -0
  60. package/src/map/data/water-bodies.json +1 -0
  61. package/src/map/data/world-coarse.json +1 -1
  62. package/src/map/data/world-detail.json +1 -1
  63. package/src/map/dimensions.ts +117 -0
  64. package/src/map/geo-query.ts +21 -3
  65. package/src/map/geo.ts +47 -1
  66. package/src/map/layout.ts +1408 -266
  67. package/src/map/load-data.ts +10 -2
  68. package/src/map/parser.ts +42 -116
  69. package/src/map/renderer.ts +604 -14
  70. package/src/map/resolved-types.ts +16 -2
  71. package/src/map/resolver.ts +208 -59
  72. package/src/map/types.ts +30 -32
  73. package/src/mindmap/renderer.ts +10 -1
  74. package/src/palettes/atlas.ts +77 -0
  75. package/src/palettes/blueprint.ts +73 -0
  76. package/src/palettes/color-utils.ts +58 -1
  77. package/src/palettes/index.ts +12 -3
  78. package/src/palettes/slate.ts +73 -0
  79. package/src/palettes/tidewater.ts +73 -0
  80. package/src/render.ts +8 -1
  81. package/src/tech-radar/renderer.ts +3 -0
  82. package/src/tech-radar/types.ts +3 -0
  83. package/src/utils/d3-types.ts +5 -0
  84. package/src/utils/legend-layout.ts +21 -4
  85. package/src/utils/legend-types.ts +7 -0
  86. package/src/utils/reserved-key-registry.ts +8 -3
  87. package/src/palettes/bold.ts +0 -67
@@ -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],
@@ -324,6 +351,9 @@ interface BLRenderOptions {
324
351
  onToggleDescriptions?: (active: boolean) => void;
325
352
  onToggleControlsExpand?: () => void;
326
353
  exportMode?: boolean;
354
+ /** When 'app', the description toggle is hosted by the app overlay strip
355
+ * (inline gear suppressed, controls row + anchor reserved). */
356
+ controlsHost?: 'app' | 'inline';
327
357
  }
328
358
 
329
359
  export function renderBoxesAndLines(
@@ -344,6 +374,7 @@ export function renderBoxesAndLines(
344
374
  onToggleDescriptions,
345
375
  onToggleControlsExpand,
346
376
  exportMode = false,
377
+ controlsHost,
347
378
  } = options ?? {};
348
379
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
349
380
 
@@ -364,22 +395,104 @@ export function renderBoxesAndLines(
364
395
  const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
365
396
  const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
366
397
  const sTitleY = sctx.structural(TITLE_Y);
367
- const sLegendHeight = sctx.structural(
368
- getMaxLegendReservedHeight(
369
- {
370
- groups: parsed.tagGroups,
371
- position: { placement: 'top-center', titleRelation: 'below-title' },
372
- mode: exportMode ? 'export' : 'preview',
373
- },
374
- width
375
- )
376
- );
377
398
 
378
- const activeGroup = resolveActiveTagGroup(
379
- parsed.tagGroups,
380
- parsed.options['active-tag'],
381
- activeTagGroup
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
+
475
+ // Reserve legend height only when a legend will actually render. App-hosted
476
+ // controls move the Descriptions toggle to the app overlay, so a
477
+ // descriptions-only chart (no tag groups) reserves nothing.
478
+ const reserveHasDescriptions = parsed.nodes.some(
479
+ (n) => n.description && n.description.length > 0
382
480
  );
481
+ const willRenderLegend =
482
+ legendGroups.length > 0 ||
483
+ (reserveHasDescriptions && controlsHost !== 'app');
484
+ const sLegendHeight = willRenderLegend
485
+ ? sctx.structural(
486
+ getMaxLegendReservedHeight(
487
+ {
488
+ groups: legendGroups,
489
+ position: { placement: 'top-center', titleRelation: 'below-title' },
490
+ mode: exportMode ? 'export' : 'preview',
491
+ },
492
+ width
493
+ )
494
+ )
495
+ : 0;
383
496
 
384
497
  // Build hidden set
385
498
  const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
@@ -399,7 +512,7 @@ export function renderBoxesAndLines(
399
512
  (n) => n.description && n.description.length > 0
400
513
  );
401
514
  const needsLegend =
402
- parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
515
+ legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
403
516
  const legendH = needsLegend ? sLegendHeight + 8 : 0;
404
517
 
405
518
  const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
@@ -794,6 +907,7 @@ export function renderBoxesAndLines(
794
907
  activeGroup,
795
908
  palette,
796
909
  isDark,
910
+ { active: activeIsValue, hue: rampHue, fillForValue },
797
911
  parsed.options['solid-fill'] === 'on'
798
912
  );
799
913
 
@@ -811,6 +925,12 @@ export function renderBoxesAndLines(
811
925
  nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
812
926
  }
813
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
+
814
934
  if (onClickItem) {
815
935
  nodeG.on('click', (event: Event) => {
816
936
  // Don't intercept clicks on links in description text
@@ -989,18 +1109,76 @@ export function renderBoxesAndLines(
989
1109
  .text(fitted.lines[li]!);
990
1110
  }
991
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
+ }
992
1164
  }
993
1165
 
994
1166
  // ── Render legend ──────────────────────────────────────
995
1167
  const hasDescriptions = parsed.nodes.some(
996
1168
  (n) => n.description && n.description.length > 0
997
1169
  );
998
- const hasLegend = parsed.tagGroups.length > 0 || hasDescriptions;
1170
+ // App-hosted: the Descriptions control moves to the app overlay, so a
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.
1173
+ const hasLegend =
1174
+ legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
999
1175
 
1000
1176
  if (hasLegend) {
1001
- // Build controls group for description toggle
1177
+ // Build controls group for description toggle. App-hosted controls own the
1178
+ // toggling, so the group is built (to gate + size the row) even without the
1179
+ // inline-gear callback.
1002
1180
  let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
1003
- if (hasDescriptions && onToggleDescriptions) {
1181
+ if (hasDescriptions && (onToggleDescriptions || controlsHost === 'app')) {
1004
1182
  controlsGroup = {
1005
1183
  toggles: [
1006
1184
  {
@@ -1015,10 +1193,17 @@ export function renderBoxesAndLines(
1015
1193
  }
1016
1194
 
1017
1195
  const legendConfig: LegendConfig = {
1018
- groups: parsed.tagGroups,
1196
+ groups: legendGroups,
1019
1197
  position: { placement: 'top-center', titleRelation: 'below-title' },
1020
1198
  mode: exportMode ? 'export' : 'preview',
1199
+ // Keep inactive sibling tag groups visible as collapsed pills so the user
1200
+ // can click one to flip the active colouring dimension (preview only —
1201
+ // export shows just the active group). Without this, declaring a second
1202
+ // tag group (e.g. Team) leaves it invisible whenever another group is
1203
+ // active. The app's BoxesAndLinesPreview already wires pill clicks.
1204
+ showInactivePills: true,
1021
1205
  ...(controlsGroup !== undefined && { controlsGroup }),
1206
+ ...(controlsHost !== undefined && { controlsHost }),
1022
1207
  };
1023
1208
  const legendState: LegendState = {
1024
1209
  activeGroup,
@@ -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/cli.ts CHANGED
@@ -136,7 +136,7 @@ Key options:
136
136
  - \`-o <file>\` — output file; format inferred from extension (\`.svg\` → SVG, else PNG)
137
137
  - \`-o url\` — output a shareable diagrammo.app URL
138
138
  - \`--theme <theme>\` — \`light\` (default), \`dark\`, \`transparent\`
139
- - \`--palette <name>\` — \`nord\` (default), \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`bold\`
139
+ - \`--palette <name>\` — \`nord\` (default), \`atlas\`, \`blueprint\`, \`slate\`, \`tidewater\`, \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`dracula\`, \`monokai\`
140
140
  - \`--copy\` — copy the URL to clipboard (use with \`-o url\`)
141
141
  - \`--chart-types\` — list all supported chart types
142
142
 
package/src/completion.ts CHANGED
@@ -98,9 +98,12 @@ const GLOBAL_DIRECTIVES: Record<string, DirectiveValueSpec> = {
98
98
  'gruvbox',
99
99
  'tokyo-night',
100
100
  'one-dark',
101
- 'bold',
102
101
  'dracula',
103
102
  'monokai',
103
+ 'atlas',
104
+ 'blueprint',
105
+ 'slate',
106
+ 'tidewater',
104
107
  ],
105
108
  },
106
109
  theme: {
@@ -444,6 +447,8 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
444
447
  direction: { description: 'Layout direction', values: ['LR', 'TB'] },
445
448
  'active-tag': { description: 'Active tag group name' },
446
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' },
447
452
  }),
448
453
  ],
449
454
  [
@@ -508,19 +513,12 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
508
513
  ],
509
514
  [
510
515
  'map',
511
- // Geographic map directives (§24B.2/.7). `poi`/`route` are content
512
- // keywords, not directives; metadata keys (value/label/style) live in the
513
- // reserved-key registry.
516
+ // Geographic map directives (§24B.2/.7). Cosmetics are ON by default — the
517
+ // only switches are bare `no-*` opt-outs, surfaced proactively so a
518
+ // zero-config map still hints at what can be turned off. `poi`/`route` are
519
+ // content keywords, not directives; metadata keys (value/label/style) live
520
+ // in the reserved-key registry.
514
521
  withGlobals({
515
- region: {
516
- description:
517
- 'Basemap: us-states (force US state mesh + scoping) | world (inert — already the default)',
518
- values: ['us-states', 'world'],
519
- },
520
- projection: {
521
- description: 'Override the auto projection',
522
- values: ['equirectangular', 'natural-earth', 'albers-usa', 'mercator'],
523
- },
524
522
  'region-metric': { description: 'Label for the region value ramp' },
525
523
  'poi-metric': {
526
524
  description: 'Label for the POI value (marker size) channel',
@@ -528,21 +526,32 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
528
526
  'flow-metric': {
529
527
  description: 'Label for the edge/leg value (thickness) channel',
530
528
  },
531
- scale: { description: 'Override value ramp anchors: scale <min> <max>' },
532
- 'region-labels': {
533
- description: 'Subdivision name labels',
534
- values: ['full', 'abbrev', 'off'],
529
+ locale: {
530
+ description:
531
+ 'Default country/state for bare place names, e.g. locale US-GA',
535
532
  },
536
- 'poi-labels': {
537
- description: 'POI labels/values',
538
- values: ['off', 'auto', 'all'],
533
+ 'active-tag': {
534
+ description: 'Which tag group leads when several are present',
539
535
  },
540
- 'default-country': { description: 'ISO scope for bare city resolution' },
541
- 'default-state': { description: 'ISO subdivision scope' },
536
+ caption: { description: 'Caption line (data-source attribution)' },
542
537
  'no-legend': { description: 'Suppress the legend' },
543
- relief: { description: 'Subtle mountain-range relief shading' },
544
- subtitle: { description: 'Subtitle line' },
545
- caption: { description: 'Caption line' },
538
+ 'no-coastline': {
539
+ description: 'Turn off coastal water-lines (on by default)',
540
+ },
541
+ 'no-relief': {
542
+ description: 'Turn off mountain-range relief shading (on by default)',
543
+ },
544
+ 'no-context-labels': {
545
+ description: 'Turn off orientation labels for water + nearby countries',
546
+ },
547
+ 'no-region-labels': {
548
+ description: 'Turn off subdivision name labels (on by default)',
549
+ },
550
+ 'no-poi-labels': { description: 'Turn off POI labels (on by default)' },
551
+ 'no-colorize': {
552
+ description:
553
+ 'Force plain green-land reference dress (regions are auto-coloured by default)',
554
+ },
546
555
  }),
547
556
  ],
548
557
  ]);
@@ -733,12 +742,9 @@ export const PIPE_METADATA = new Map<string, PipeContextMap>([
733
742
  {
734
743
  node: {
735
744
  description: { description: 'Node description text' },
745
+ value: { description: 'Numeric value for the metric ramp' },
736
746
  },
737
- edge: {
738
- width: { description: 'Edge stroke width in pixels' },
739
- split: { description: 'Traffic split percentage' },
740
- fanout: { description: 'Fanout multiplier (integer >= 1)' },
741
- },
747
+ edge: {},
742
748
  },
743
749
  ],
744
750
  [
@@ -48,6 +48,10 @@ export interface CycleRenderOptions {
48
48
  onToggleDescriptions?: (active: boolean) => void;
49
49
  onToggleControlsExpand?: () => void;
50
50
  exportMode?: boolean;
51
+ /** When 'app', the description toggle is hosted by the app overlay strip:
52
+ * the inline gear is suppressed and a controls row + anchor are reserved.
53
+ * Default (inline) renders the gear as before. */
54
+ controlsHost?: 'app' | 'inline';
51
55
  }
52
56
 
53
57
  /**
@@ -92,7 +96,13 @@ export function renderCycle(
92
96
  const hasDescriptions =
93
97
  parsed.nodes.some((n) => n.description.length > 0) ||
94
98
  parsed.edges.some((e) => e.description.length > 0);
95
- const hasLegend = hasDescriptions && !!renderOptions?.onToggleDescriptions;
99
+ // App-hosted: controls live in the app overlay strip. Cycle has no tag groups,
100
+ // so there's no in-SVG legend left to render — don't reserve a legend band.
101
+ const appHostedControls = renderOptions?.controlsHost === 'app';
102
+ const hasLegend =
103
+ !appHostedControls &&
104
+ hasDescriptions &&
105
+ !!renderOptions?.onToggleDescriptions;
96
106
 
97
107
  const showTitle = !!parsed.title && parsed.options['no-title'] !== 'on';
98
108
  const legendOffset = hasLegend ? sLegendHeight : 0;
@@ -160,6 +170,9 @@ export function renderCycle(
160
170
  position: { placement: 'top-center', titleRelation: 'below-title' },
161
171
  mode: renderOptions?.exportMode ? 'export' : 'preview',
162
172
  controlsGroup,
173
+ ...(renderOptions?.controlsHost !== undefined && {
174
+ controlsHost: renderOptions.controlsHost,
175
+ }),
163
176
  };
164
177
  const legendState: LegendState = {
165
178
  activeGroup: null,
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
 
@@ -8459,6 +8467,7 @@ export async function renderForExport(
8459
8467
  const { parseMap } = await import('./map/parser');
8460
8468
  const { resolveMap } = await import('./map/resolver');
8461
8469
  const { renderMapForExport } = await import('./map/renderer');
8470
+ const { mapExportDimensions } = await import('./map/dimensions');
8462
8471
 
8463
8472
  const effectivePalette = await resolveExportPalette(theme, palette);
8464
8473
  const mapParsed = parseMap(content);
@@ -8478,14 +8487,19 @@ export async function renderForExport(
8478
8487
  }
8479
8488
  const mapResolved = resolveMap(mapParsed, mapData);
8480
8489
 
8481
- const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
8490
+ // Content-aware canvas: derive the height from the map's intrinsic projected
8491
+ // aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
8492
+ // export matches the content's natural shape — no vertical stretch, no
8493
+ // letterbox bands. `preferContain` rides along to the renderer.
8494
+ const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
8495
+ const container = createExportContainer(dims.width, dims.height);
8482
8496
  renderMapForExport(
8483
8497
  container,
8484
8498
  mapResolved,
8485
8499
  mapData,
8486
8500
  effectivePalette,
8487
8501
  theme === 'dark',
8488
- { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }
8502
+ dims
8489
8503
  );
8490
8504
  return finalizeSvgExport(container, theme, effectivePalette);
8491
8505
  }
@@ -200,6 +200,10 @@ const ATTRIBUTE_KEYS = new Set([
200
200
  'tech',
201
201
  'span',
202
202
  'split',
203
+ // Map (§24B) reserved keys
204
+ 'value',
205
+ 'label',
206
+ 'style',
203
207
  ]);
204
208
 
205
209
  /**
@@ -80,11 +80,10 @@ export const METADATA_KEYS = new Set([
80
80
  'quadrant',
81
81
  'ring',
82
82
  'trend',
83
- // Map (§24B) metadata keys
84
- 'score',
83
+ // Map (§24B) reserved metadata keys
84
+ 'value',
85
85
  'label',
86
- 'description',
87
- 'weight',
86
+ 'style',
88
87
  ]);
89
88
 
90
89
  /** Tag declaration keyword. */
@@ -111,6 +110,9 @@ export const DIRECTIVE_KEYWORDS = new Set([
111
110
  'hide',
112
111
  'mode',
113
112
  'direction',
113
+ // Boxes-and-lines
114
+ 'box-metric',
115
+ 'show-values',
114
116
  // ER
115
117
  'notation',
116
118
  // Class
@@ -150,22 +152,20 @@ export const DIRECTIVE_KEYWORDS = new Set([
150
152
  // Sequence
151
153
  'activations',
152
154
  'no-activations',
153
- // Map (§24B) directives
154
- 'region',
155
- 'projection',
155
+ // Map (§24B) directives — cosmetics on by default, bare `no-*` opt-outs
156
156
  'region-metric',
157
157
  'poi-metric',
158
158
  'flow-metric',
159
- 'region-labels',
160
- 'poi-labels',
161
- 'default-country',
162
- 'default-state',
163
- 'no-legend',
164
- 'no-insets',
165
- 'muted',
166
- 'natural',
167
- 'subtitle',
159
+ 'locale',
160
+ 'active-tag',
168
161
  'caption',
162
+ 'no-legend',
163
+ 'no-coastline',
164
+ 'no-relief',
165
+ 'no-context-labels',
166
+ 'no-region-labels',
167
+ 'no-poi-labels',
168
+ 'no-colorize',
169
169
  'poi',
170
170
  'route',
171
171
  // Data charts