@diagrammo/dgmo 0.21.0 → 0.22.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 (76) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2521 -623
  3. package/dist/advanced.d.cts +917 -534
  4. package/dist/advanced.d.ts +917 -534
  5. package/dist/advanced.js +2516 -623
  6. package/dist/auto.cjs +2333 -608
  7. package/dist/auto.js +119 -119
  8. package/dist/auto.mjs +2335 -609
  9. package/dist/cli.cjs +168 -168
  10. package/dist/editor.cjs +13 -15
  11. package/dist/editor.js +13 -15
  12. package/dist/highlight.cjs +15 -12
  13. package/dist/highlight.js +15 -12
  14. package/dist/index.cjs +2317 -595
  15. package/dist/index.d.cts +4 -1
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +2319 -596
  18. package/dist/internal.cjs +2521 -623
  19. package/dist/internal.d.cts +917 -534
  20. package/dist/internal.d.ts +917 -534
  21. package/dist/internal.js +2516 -623
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -0
  24. package/dist/map-data/water-bodies.json +1 -0
  25. package/docs/language-reference.md +44 -31
  26. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  27. package/gallery/fixtures/map-categorical.dgmo +0 -1
  28. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  29. package/gallery/fixtures/map-coastline.dgmo +7 -0
  30. package/gallery/fixtures/map-colorize.dgmo +11 -0
  31. package/gallery/fixtures/map-direct-color.dgmo +9 -0
  32. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  33. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  34. package/gallery/fixtures/map-route.dgmo +0 -1
  35. package/package.json +1 -1
  36. package/src/advanced.ts +26 -1
  37. package/src/boxes-and-lines/renderer.ts +39 -12
  38. package/src/cli.ts +1 -1
  39. package/src/completion.ts +32 -24
  40. package/src/cycle/renderer.ts +14 -1
  41. package/src/d3.ts +23 -11
  42. package/src/editor/highlight-api.ts +4 -0
  43. package/src/editor/keywords.ts +13 -15
  44. package/src/infra/renderer.ts +35 -7
  45. package/src/map/colorize.ts +54 -0
  46. package/src/map/context-labels.ts +429 -0
  47. package/src/map/data/PROVENANCE.json +1 -1
  48. package/src/map/data/mountain-ranges.json +1 -0
  49. package/src/map/data/types.ts +34 -0
  50. package/src/map/data/water-bodies.json +1 -0
  51. package/src/map/dimensions.ts +117 -0
  52. package/src/map/geo-query.ts +295 -0
  53. package/src/map/geo.ts +305 -2
  54. package/src/map/invert.ts +111 -0
  55. package/src/map/layout.ts +1504 -335
  56. package/src/map/load-data.ts +16 -2
  57. package/src/map/parser.ts +57 -111
  58. package/src/map/renderer.ts +556 -13
  59. package/src/map/resolved-types.ts +24 -2
  60. package/src/map/resolver.ts +237 -67
  61. package/src/map/types.ts +39 -23
  62. package/src/mindmap/renderer.ts +10 -1
  63. package/src/palettes/atlas.ts +77 -0
  64. package/src/palettes/blueprint.ts +73 -0
  65. package/src/palettes/color-utils.ts +58 -1
  66. package/src/palettes/index.ts +12 -3
  67. package/src/palettes/slate.ts +73 -0
  68. package/src/palettes/tidewater.ts +73 -0
  69. package/src/render.ts +8 -1
  70. package/src/tech-radar/renderer.ts +3 -0
  71. package/src/tech-radar/types.ts +3 -0
  72. package/src/utils/d3-types.ts +5 -0
  73. package/src/utils/legend-layout.ts +21 -4
  74. package/src/utils/legend-types.ts +7 -0
  75. package/src/utils/reserved-key-registry.ts +3 -0
  76. package/src/palettes/bold.ts +0 -67
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: {
@@ -508,19 +511,12 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
508
511
  ],
509
512
  [
510
513
  '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.
514
+ // Geographic map directives (§24B.2/.7). Cosmetics are ON by default — the
515
+ // only switches are bare `no-*` opt-outs, surfaced proactively so a
516
+ // zero-config map still hints at what can be turned off. `poi`/`route` are
517
+ // content keywords, not directives; metadata keys (value/label/style) live
518
+ // in the reserved-key registry.
514
519
  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
520
  'region-metric': { description: 'Label for the region value ramp' },
525
521
  'poi-metric': {
526
522
  description: 'Label for the POI value (marker size) channel',
@@ -528,20 +524,32 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
528
524
  'flow-metric': {
529
525
  description: 'Label for the edge/leg value (thickness) channel',
530
526
  },
531
- scale: { description: 'Override value ramp anchors: scale <min> <max>' },
532
- 'region-labels': {
533
- description: 'Subdivision name labels',
534
- values: ['full', 'abbrev', 'off'],
527
+ locale: {
528
+ description:
529
+ 'Default country/state for bare place names, e.g. locale US-GA',
535
530
  },
536
- 'poi-labels': {
537
- description: 'POI labels/values',
538
- values: ['off', 'auto', 'all'],
531
+ 'active-tag': {
532
+ description: 'Which tag group leads when several are present',
539
533
  },
540
- 'default-country': { description: 'ISO scope for bare city resolution' },
541
- 'default-state': { description: 'ISO subdivision scope' },
534
+ caption: { description: 'Caption line (data-source attribution)' },
542
535
  'no-legend': { description: 'Suppress the legend' },
543
- subtitle: { description: 'Subtitle line' },
544
- caption: { description: 'Caption line' },
536
+ 'no-coastline': {
537
+ description: 'Turn off coastal water-lines (on by default)',
538
+ },
539
+ 'no-relief': {
540
+ description: 'Turn off mountain-range relief shading (on by default)',
541
+ },
542
+ 'no-context-labels': {
543
+ description: 'Turn off orientation labels for water + nearby countries',
544
+ },
545
+ 'no-region-labels': {
546
+ description: 'Turn off subdivision name labels (on by default)',
547
+ },
548
+ 'no-poi-labels': { description: 'Turn off POI labels (on by default)' },
549
+ 'no-colorize': {
550
+ description:
551
+ 'Force plain green-land reference dress (regions are auto-coloured by default)',
552
+ },
545
553
  }),
546
554
  ],
547
555
  ]);
@@ -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
@@ -7739,6 +7739,10 @@ export async function renderForExport(
7739
7739
  c4Container?: string;
7740
7740
  tagGroup?: string;
7741
7741
  exportMode?: boolean;
7742
+ // Browser callers (the app / Obsidian) bundle the map JSON and inject it
7743
+ // here — the Node fs `loadMapData()` seam can't run in a browser. CLI/SSR
7744
+ // omit this and fall back to the fs loader.
7745
+ mapData?: import('./map/resolved-types').MapData;
7742
7746
  }
7743
7747
  ): Promise<string> {
7744
7748
  const exportMode = options?.exportMode ?? false;
@@ -8454,32 +8458,40 @@ export async function renderForExport(
8454
8458
  if (detectedType === 'map') {
8455
8459
  const { parseMap } = await import('./map/parser');
8456
8460
  const { resolveMap } = await import('./map/resolver');
8457
- const { loadMapData } = await import('./map/load-data');
8458
8461
  const { renderMapForExport } = await import('./map/renderer');
8462
+ const { mapExportDimensions } = await import('./map/dimensions');
8459
8463
 
8460
8464
  const effectivePalette = await resolveExportPalette(theme, palette);
8461
8465
  const mapParsed = parseMap(content);
8462
8466
  // Always render — an empty or partially-resolved map still draws the
8463
8467
  // inferred base map (§24B.10 / layout AC23); diagnostics surface separately.
8464
- // Degrade like every other branch (return '') if the assets can't load,
8465
- // rather than throwing out of render() e.g. a deployment without
8466
- // dist/map-data or the not-yet-supported browser fs path.
8467
- let mapData;
8468
- try {
8469
- mapData = await loadMapData();
8470
- } catch {
8471
- return '';
8468
+ // Prefer injected `mapData` (browser bundles it; the fs loader can't run
8469
+ // there); fall back to the Node fs loader for CLI/SSR. Degrade like every
8470
+ // other branch (return '') if neither yields data.
8471
+ let mapData = options?.mapData;
8472
+ if (!mapData) {
8473
+ const { loadMapData } = await import('./map/load-data');
8474
+ try {
8475
+ mapData = await loadMapData();
8476
+ } catch {
8477
+ return '';
8478
+ }
8472
8479
  }
8473
8480
  const mapResolved = resolveMap(mapParsed, mapData);
8474
8481
 
8475
- const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
8482
+ // Content-aware canvas: derive the height from the map's intrinsic projected
8483
+ // aspect (world ~2.3:1, a region taller, etc.) instead of the fixed 800, so the
8484
+ // export matches the content's natural shape — no vertical stretch, no
8485
+ // letterbox bands. `preferContain` rides along to the renderer.
8486
+ const dims = mapExportDimensions(mapResolved, mapData, EXPORT_WIDTH);
8487
+ const container = createExportContainer(dims.width, dims.height);
8476
8488
  renderMapForExport(
8477
8489
  container,
8478
8490
  mapResolved,
8479
8491
  mapData,
8480
8492
  effectivePalette,
8481
8493
  theme === 'dark',
8482
- { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }
8494
+ dims
8483
8495
  );
8484
8496
  return finalizeSvgExport(container, theme, effectivePalette);
8485
8497
  }
@@ -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. */
@@ -150,21 +149,20 @@ export const DIRECTIVE_KEYWORDS = new Set([
150
149
  // Sequence
151
150
  'activations',
152
151
  'no-activations',
153
- // Map (§24B) directives
154
- 'region',
155
- 'projection',
152
+ // Map (§24B) directives — cosmetics on by default, bare `no-*` opt-outs
156
153
  'region-metric',
157
154
  'poi-metric',
158
155
  'flow-metric',
159
- 'region-labels',
160
- 'poi-labels',
161
- 'default-country',
162
- 'default-state',
163
- 'no-legend',
164
- 'muted',
165
- 'natural',
166
- 'subtitle',
156
+ 'locale',
157
+ 'active-tag',
167
158
  'caption',
159
+ 'no-legend',
160
+ 'no-coastline',
161
+ 'no-relief',
162
+ 'no-context-labels',
163
+ 'no-region-labels',
164
+ 'no-poi-labels',
165
+ 'no-colorize',
168
166
  'poi',
169
167
  'route',
170
168
  // Data charts
@@ -2080,9 +2080,13 @@ function renderLegend(
2080
2080
  isDark: boolean,
2081
2081
  activeGroup: string | null,
2082
2082
  playback?: InfraPlaybackState,
2083
- exportMode = false
2083
+ exportMode = false,
2084
+ controlsHost?: 'app' | 'inline'
2084
2085
  ) {
2085
2086
  if (legendGroups.length === 0 && !playback) return;
2087
+ // App-hosted playback: the play/pause + speed UI lives in the app overlay
2088
+ // strip, so suppress the in-SVG Playback pill and emit the controls anchor.
2089
+ const appHostedPlayback = controlsHost === 'app' && !!playback;
2086
2090
 
2087
2091
  const legendG = rootSvg
2088
2092
  .append('g')
@@ -2097,8 +2101,9 @@ function renderLegend(
2097
2101
  name: g.name,
2098
2102
  entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2099
2103
  }));
2100
- // Add Playback as a group with empty entries (collapsed pill) or dummy entries (expanded)
2101
- if (playback) {
2104
+ // Add Playback as a group with empty entries (collapsed pill) or dummy entries
2105
+ // (expanded) — unless the app hosts it, in which case it's suppressed.
2106
+ if (playback && !appHostedPlayback) {
2102
2107
  allGroups.push({ name: 'Playback', entries: [] });
2103
2108
  }
2104
2109
 
@@ -2107,6 +2112,20 @@ function renderLegend(
2107
2112
  position: { placement: 'top-center', titleRelation: 'below-title' },
2108
2113
  mode: exportMode ? 'export' : 'preview',
2109
2114
  showEmptyGroups: true,
2115
+ ...(appHostedPlayback && {
2116
+ controlsHost: 'app' as const,
2117
+ controlsGroup: {
2118
+ toggles: [
2119
+ {
2120
+ id: 'playback',
2121
+ type: 'toggle' as const,
2122
+ label: 'Playback',
2123
+ active: true,
2124
+ onToggle: () => {},
2125
+ },
2126
+ ],
2127
+ },
2128
+ }),
2110
2129
  };
2111
2130
  const legendState: LegendState = { activeGroup };
2112
2131
  renderLegendD3(
@@ -2233,9 +2252,13 @@ export function renderInfra(
2233
2252
  playback?: InfraPlaybackState | null,
2234
2253
  expandedNodeIds?: Set<string> | null,
2235
2254
  exportMode?: boolean,
2236
- collapsedNodes?: Set<string> | null
2255
+ collapsedNodes?: Set<string> | null,
2256
+ /** When 'app', the playback pill is suppressed and a controls row + anchor are
2257
+ * reserved for the app overlay strip (play/pause + speed live there). */
2258
+ controlsHost?: 'app' | 'inline'
2237
2259
  ) {
2238
2260
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2261
+ const appHostedPlayback = controlsHost === 'app' && !!playback;
2239
2262
 
2240
2263
  const ctx = ScaleContext.identity();
2241
2264
  const sc = buildScaledConstants(ctx);
@@ -2246,7 +2269,10 @@ export function renderInfra(
2246
2269
  palette,
2247
2270
  layout.edges
2248
2271
  );
2249
- const hasLegend = legendGroups.length > 0 || !!playback;
2272
+ // App-hosted: the playback pill moves to the app overlay, so a playback-only
2273
+ // legend (no tag groups) has nothing left to render.
2274
+ const hasLegend =
2275
+ legendGroups.length > 0 || (!!playback && !appHostedPlayback);
2250
2276
  const fixedLegend = !exportMode && hasLegend;
2251
2277
  const legendDynamicH = hasLegend
2252
2278
  ? getMaxLegendReservedHeight(
@@ -2461,7 +2487,8 @@ export function renderInfra(
2461
2487
  isDark,
2462
2488
  activeGroup ?? null,
2463
2489
  playback ?? undefined,
2464
- exportMode
2490
+ exportMode,
2491
+ controlsHost
2465
2492
  );
2466
2493
  // Re-enable pointer events on interactive legend elements
2467
2494
  legendSvg
@@ -2478,7 +2505,8 @@ export function renderInfra(
2478
2505
  isDark,
2479
2506
  activeGroup ?? null,
2480
2507
  playback ?? undefined,
2481
- exportMode
2508
+ exportMode,
2509
+ controlsHost
2482
2510
  );
2483
2511
  }
2484
2512
  }
@@ -0,0 +1,54 @@
1
+ // Political fill-assignment pass (§24B colorize). PURE + DETERMINISTIC — no
2
+ // projection, no palette, no DOM. Given the per-topology arc-adjacency graph
3
+ // (from geo.ts buildAdjacency), assign every drawn region a colour INDEX such
4
+ // that no two arc-neighbours share one. The only job of a political fill is
5
+ // boundary disambiguation; "no two neighbours share a hue" is the minimal
6
+ // property — and a planar political map famously needs only a handful of colours.
7
+ //
8
+ // FIRST-FIT greedy: each region takes the LOWEST index not used by an
9
+ // already-coloured neighbour. This is collision-free by construction (a node with
10
+ // k coloured neighbours has at most k forbidden indices, so index ≤ k is always
11
+ // free) AND clusters regions into the FEWEST colours — on the shipped graphs the
12
+ // max index used is 5 (world) / 4 (us-states), i.e. 5–6 colours total. The caller
13
+ // generates exactly that many palette tints, so the fills stay on-palette (no
14
+ // need for the old Δ+1 ≈ 17 wheel hues).
15
+
16
+ /** Result of {@link assignColors}: a colour INDEX per ISO + the number of
17
+ * distinct colours actually used (`= maxIndex + 1`). The index is a stable
18
+ * function of (ISO, global arc-adjacency) — extent-independent (AC10). */
19
+ export interface ColorAssignment {
20
+ readonly byIso: Map<string, number>;
21
+ readonly huesNeeded: number;
22
+ }
23
+
24
+ /** First-fit greedy graph-coloring over `isos` using `adjacency` (ISO → neighbour
25
+ * ISOs). Visits ISOs in stable ascending order; each takes the lowest index not
26
+ * taken by an already-coloured neighbour — collision-free, and minimises the
27
+ * total colour count.
28
+ *
29
+ * EVERY iso in `isos` is assigned an index — including zero-degree nodes
30
+ * (islands, DC, territories with no neighbour entry) — so the caller never needs
31
+ * a fallback fill (F14). */
32
+ export function assignColors(
33
+ isos: readonly string[],
34
+ adjacency: ReadonlyMap<string, readonly string[]>
35
+ ): ColorAssignment {
36
+ const sorted = [...isos].sort();
37
+ const byIso = new Map<string, number>();
38
+ let maxIndex = -1;
39
+
40
+ for (const iso of sorted) {
41
+ const taken = new Set<number>();
42
+ for (const n of adjacency.get(iso) ?? []) {
43
+ const c = byIso.get(n);
44
+ if (c !== undefined) taken.add(c);
45
+ }
46
+ // Lowest index not taken by a neighbour (always exists: ≤ neighbour count).
47
+ let h = 0;
48
+ while (taken.has(h)) h++;
49
+ byIso.set(iso, h);
50
+ if (h > maxIndex) maxIndex = h;
51
+ }
52
+
53
+ return { byIso, huesNeeded: maxIndex + 1 };
54
+ }