@diagrammo/dgmo 0.20.3 → 0.21.1

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 (48) hide show
  1. package/dist/advanced.cjs +867 -286
  2. package/dist/advanced.js +866 -286
  3. package/dist/auto.cjs +635 -284
  4. package/dist/auto.js +113 -113
  5. package/dist/auto.mjs +635 -284
  6. package/dist/cli.cjs +156 -156
  7. package/dist/editor.cjs +6 -2
  8. package/dist/editor.js +6 -2
  9. package/dist/highlight.cjs +6 -2
  10. package/dist/highlight.js +6 -2
  11. package/dist/index.cjs +628 -281
  12. package/dist/index.js +628 -281
  13. package/dist/internal.cjs +867 -286
  14. package/dist/internal.js +866 -286
  15. package/dist/map-data/PROVENANCE.json +1 -1
  16. package/dist/map-data/mountain-ranges.json +1 -0
  17. package/docs/language-reference.md +27 -25
  18. package/gallery/fixtures/map-choropleth.dgmo +7 -7
  19. package/gallery/fixtures/map-direct-color.dgmo +10 -0
  20. package/gallery/fixtures/map-pois.dgmo +4 -4
  21. package/gallery/fixtures/map-region-scope.dgmo +8 -8
  22. package/gallery/fixtures/map-route.dgmo +5 -6
  23. package/package.json +1 -1
  24. package/src/advanced.ts +14 -0
  25. package/src/completion.ts +10 -4
  26. package/src/d3.ts +15 -9
  27. package/src/editor/keywords.ts +6 -2
  28. package/src/map/data/PROVENANCE.json +1 -1
  29. package/src/map/data/mountain-ranges.json +1 -0
  30. package/src/map/geo-query.ts +277 -0
  31. package/src/map/geo.ts +258 -1
  32. package/src/map/invert.ts +111 -0
  33. package/src/map/layout.ts +333 -139
  34. package/src/map/load-data.ts +7 -1
  35. package/src/map/parser.ts +142 -33
  36. package/src/map/renderer.ts +57 -6
  37. package/src/map/resolved-types.ts +21 -2
  38. package/src/map/resolver.ts +219 -53
  39. package/src/map/types.ts +57 -14
  40. package/src/utils/reserved-key-registry.ts +7 -7
  41. package/dist/advanced.d.cts +0 -5290
  42. package/dist/advanced.d.ts +0 -5290
  43. package/dist/auto.d.cts +0 -39
  44. package/dist/auto.d.ts +0 -39
  45. package/dist/index.d.cts +0 -336
  46. package/dist/index.d.ts +0 -336
  47. package/dist/internal.d.cts +0 -5290
  48. package/dist/internal.d.ts +0 -5290
@@ -14,10 +14,17 @@ import type {
14
14
  ResolvedPoi,
15
15
  ResolvedEdge,
16
16
  ResolvedRoute,
17
+ ResolvedRouteLeg,
17
18
  ProjectionFamily,
18
19
  GeoExtent,
19
20
  } from './resolved-types';
20
- import { featureIndex, featureBbox, unionExtent, fold } from './geo';
21
+ import {
22
+ featureIndex,
23
+ featureBbox,
24
+ featureBboxPrimary,
25
+ unionExtent,
26
+ fold,
27
+ } from './geo';
21
28
 
22
29
  /** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
23
30
  * retry in pass B with inferred scope" — distinct from `miss` (errored, drop) so
@@ -29,7 +36,13 @@ type LookupResult =
29
36
 
30
37
  // Projection / tier thresholds (degrees of span) — tunable (R10).
31
38
  const WORLD_SPAN = 90;
32
- const MERCATOR_MAX_SPAN = 25;
39
+ // Mercator is used for everything sub-world (tight clusters AND single-continent
40
+ // regional views — a mid-latitude continent reads with its familiar conventional
41
+ // shape, where equirectangular squashes it). Two guards push back to
42
+ // equirectangular: a world/multi-continent `span` (> WORLD_SPAN), or a frame that
43
+ // reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's sec(φ) area
44
+ // blow-up turns gross. Europe (≈71°N) and East Asia stay comfortably on Mercator.
45
+ const MERCATOR_MAX_LAT = 80;
33
46
  const PAD_FRACTION = 0.05;
34
47
  // Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
35
48
  // Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
@@ -64,6 +77,75 @@ const REGION_ALIASES: Readonly<Record<string, string>> = {
64
77
  'czech republic': 'czechia',
65
78
  };
66
79
 
80
+ // US state (+ DC) postal abbreviations. A bare trailing two-letter scope token
81
+ // that is one of these resolves to the US STATE `US-XX` (not the colliding
82
+ // ISO 3166-1 country code — e.g. `CA` = California, not Canada) and signals US
83
+ // scope (§24B.8, 2026-06-01). Non-state two-letter tokens (`CR`, `FR`) stay
84
+ // country codes. Trade-off: a foreign country whose code collides with a state
85
+ // (DE/IN/AR/CO/LA/PA/…) can't be picked by bare code — use the city name alone
86
+ // (most-populous) or coordinates.
87
+ const US_STATE_POSTAL: ReadonlySet<string> = new Set([
88
+ 'AL',
89
+ 'AK',
90
+ 'AZ',
91
+ 'AR',
92
+ 'CA',
93
+ 'CO',
94
+ 'CT',
95
+ 'DE',
96
+ 'FL',
97
+ 'GA',
98
+ 'HI',
99
+ 'ID',
100
+ 'IL',
101
+ 'IN',
102
+ 'IA',
103
+ 'KS',
104
+ 'KY',
105
+ 'LA',
106
+ 'ME',
107
+ 'MD',
108
+ 'MA',
109
+ 'MI',
110
+ 'MN',
111
+ 'MS',
112
+ 'MO',
113
+ 'MT',
114
+ 'NE',
115
+ 'NV',
116
+ 'NH',
117
+ 'NJ',
118
+ 'NM',
119
+ 'NY',
120
+ 'NC',
121
+ 'ND',
122
+ 'OH',
123
+ 'OK',
124
+ 'OR',
125
+ 'PA',
126
+ 'RI',
127
+ 'SC',
128
+ 'SD',
129
+ 'TN',
130
+ 'TX',
131
+ 'UT',
132
+ 'VT',
133
+ 'VA',
134
+ 'WA',
135
+ 'WV',
136
+ 'WI',
137
+ 'WY',
138
+ 'DC',
139
+ ]);
140
+
141
+ /** A bare two-letter scope token that names a US state → its `US-XX` 3166-2 id
142
+ * (`OR` → `US-OR`), else null. Case-insensitive. */
143
+ function usStateFromBareScope(scope: string | undefined): string | null {
144
+ if (!scope) return null;
145
+ const up = scope.toUpperCase();
146
+ return US_STATE_POSTAL.has(up) ? `US-${up}` : null;
147
+ }
148
+
67
149
  /** Rough US bounding box (incl. AK across the dateline, HI, PR) for classifying
68
150
  * bare coordinate POIs as US-or-not when deciding `albers-usa` (#13). */
69
151
  function looksUS(lat: number, lon: number): boolean {
@@ -128,10 +210,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
128
210
  return usStateIndex.has(f) && !countryIndex.has(f);
129
211
  }) ||
130
212
  parsed.regions.some(
131
- (r) => r.scope === 'US' || r.scope?.startsWith('US-')
213
+ (r) =>
214
+ r.scope === 'US' ||
215
+ r.scope?.startsWith('US-') ||
216
+ usStateFromBareScope(r.scope) !== null
132
217
  ) ||
133
218
  parsed.pois.some(
134
- (p) => p.pos.kind === 'name' && p.pos.scope?.startsWith('US-')
219
+ (p) =>
220
+ p.pos.kind === 'name' &&
221
+ (p.pos.scope?.startsWith('US-') ||
222
+ usStateFromBareScope(p.pos.scope) !== null)
135
223
  );
136
224
 
137
225
  // ── Regions (R2/R12) ──
@@ -179,17 +267,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
179
267
  }
180
268
  } else if (inCountry && inState) {
181
269
  if (usScoped) {
270
+ // A US scope (e.g. `region us-states`) makes the state the unambiguous
271
+ // intent — resolve silently, no disambiguation warning needed.
182
272
  chosen = { ...inState, layer: 'us-state' };
183
273
  } else {
184
274
  chosen = { ...inCountry, layer: 'country' };
275
+ // Teach the disambiguation syntax so the author can pin it explicitly.
276
+ // Suggest the non-redundant forms: a bare ISO code, or name + scope.
277
+ warn(
278
+ r.lineNumber,
279
+ `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
280
+ 'W_MAP_REGION_AMBIGUOUS'
281
+ );
185
282
  }
186
- // Teach the disambiguation syntax so the author can pin it explicitly.
187
- // Suggest the non-redundant forms: a bare ISO code, or name + scope.
188
- warn(
189
- r.lineNumber,
190
- `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
191
- 'W_MAP_REGION_AMBIGUOUS'
192
- );
193
283
  } else if (inState) {
194
284
  chosen = { ...inState, layer: 'us-state' };
195
285
  } else if (inCountry) {
@@ -212,7 +302,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
212
302
  iso: chosen.id,
213
303
  name: chosen.name,
214
304
  layer: chosen.layer,
215
- ...(r.score !== undefined && { score: r.score }),
305
+ ...(r.value !== undefined && { value: r.value }),
306
+ ...(r.color !== undefined && { color: r.color }),
216
307
  tags: r.tags,
217
308
  meta: r.meta,
218
309
  lineNumber: r.lineNumber,
@@ -289,11 +380,14 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
289
380
  let cands = idxs.map((i) => data.gazetteer.cities[i]!);
290
381
  const scopeUse = scope ?? scopeHint;
291
382
  if (scopeUse) {
292
- // ISO 3166-2 subdivision scope is `XX-…` (two letters + dash); a bare
293
- // 2-letter token is a country code (#9 regex, not a brittle dash test).
294
- const isSub = /^[A-Za-z]{2}-/.test(scopeUse);
383
+ // ISO 3166-2 subdivision scope is `XX-…` (two letters + dash). A bare
384
+ // two-letter token that names a US state resolves as the `US-XX`
385
+ // subdivision (`CA` = California, §24B.8) — NOT the colliding country
386
+ // code; any other bare two-letter token is a 3166-1 country code.
387
+ const bareState = usStateFromBareScope(scopeUse);
388
+ const subScope = /^[A-Za-z]{2}-/.test(scopeUse) ? scopeUse : bareState;
295
389
  const filtered = cands.filter((c) =>
296
- isSub ? c[5] === scopeUse : c[2] === scopeUse
390
+ subScope ? c[5] === subScope : c[2] === scopeUse
297
391
  );
298
392
  if (filtered.length) cands = filtered;
299
393
  else if (scope) {
@@ -397,6 +491,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
397
491
  lat,
398
492
  lon,
399
493
  ...(p.label !== undefined && { label: p.label }),
494
+ ...(p.color !== undefined && { color: p.color }),
400
495
  tags: p.tags,
401
496
  meta: p.meta,
402
497
  lineNumber: p.lineNumber,
@@ -455,36 +550,102 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
455
550
  });
456
551
  }
457
552
 
458
- const routes: ResolvedRoute[] = [];
459
- for (const rt of parsed.routes) {
460
- const stopIds: string[] = [];
461
- for (const stop of rt.stops) {
462
- let id: string | null;
463
- if (stop.ref.kind === 'coords') {
464
- id = stop.alias ? fold(stop.alias) : `@${stop.ref.lat},${stop.ref.lon}`;
465
- if (!looksUS(stop.ref.lat, stop.ref.lon)) anyNonUsPoi = true;
466
- if (!registry.has(id)) {
467
- const poi: Writable<ResolvedPoi> = {
553
+ // Resolve a route stop to a POI id, registering it (with its inline tags/label/
554
+ // size value) or binding to an already-declared POI. Returns null if a named
555
+ // stop can't be geocoded. Unlike the old code, stop metadata is NO LONGER
556
+ // dropped — it rides onto the created POI (fixes the named-stop meta bug).
557
+ const resolveStop = (
558
+ pos: PoiPos,
559
+ alias: string | undefined,
560
+ label: string | undefined,
561
+ tags: Readonly<Record<string, string>>,
562
+ sizeValue: string | undefined,
563
+ line: number
564
+ ): string | null => {
565
+ const meta: Record<string, string> =
566
+ sizeValue !== undefined ? { value: sizeValue } : {};
567
+ if (pos.kind === 'coords') {
568
+ const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
569
+ if (!looksUS(pos.lat, pos.lon)) anyNonUsPoi = true;
570
+ if (!registry.has(id)) {
571
+ registerPoi(
572
+ id,
573
+ {
468
574
  id,
469
- ...(stop.alias !== undefined && { name: stop.alias }),
470
- lat: stop.ref.lat,
471
- lon: stop.ref.lon,
472
- tags: {},
473
- meta: stop.meta,
474
- lineNumber: stop.lineNumber,
475
- implicit: true,
476
- };
477
- registerPoi(id, poi, stop.lineNumber);
478
- }
479
- } else {
480
- id =
481
- stop.alias && registry.has(fold(stop.alias))
482
- ? fold(stop.alias)
483
- : resolveEndpoint(stop.ref.name, stop.lineNumber);
575
+ ...(alias !== undefined && { name: alias }),
576
+ lat: pos.lat,
577
+ lon: pos.lon,
578
+ ...(label !== undefined && { label }),
579
+ tags,
580
+ meta,
581
+ lineNumber: line,
582
+ },
583
+ line
584
+ );
484
585
  }
485
- if (id) stopIds.push(id);
586
+ return id;
587
+ }
588
+ // Named stop: bind to an existing declared POI if present, else geocode + create.
589
+ const f = fold(pos.name);
590
+ if (registry.has(f)) return f;
591
+ const aliased = declaredByName.get(f);
592
+ if (aliased) return aliased;
593
+ const got = lookupName(pos.name, pos.scope, line, inferredCountry, true);
594
+ if (got.kind !== 'ok') return null;
595
+ noteCountry(got.iso);
596
+ registerPoi(
597
+ f,
598
+ {
599
+ id: f,
600
+ name: pos.name,
601
+ lat: got.lat,
602
+ lon: got.lon,
603
+ ...(label !== undefined && { label }),
604
+ tags,
605
+ meta,
606
+ lineNumber: line,
607
+ },
608
+ line
609
+ );
610
+ return f;
611
+ };
612
+
613
+ const routes: ResolvedRoute[] = [];
614
+ for (const rt of parsed.routes) {
615
+ const originId = resolveStop(
616
+ rt.origin,
617
+ rt.originAlias,
618
+ rt.originLabel,
619
+ rt.originTags,
620
+ rt.originValue,
621
+ rt.lineNumber
622
+ );
623
+ if (!originId) continue; // can't anchor the route → drop (error already pushed)
624
+ const stopIds: string[] = [originId];
625
+ const legs: ResolvedRouteLeg[] = [];
626
+ let prevId = originId;
627
+ for (const leg of rt.legs) {
628
+ const destId = resolveStop(
629
+ leg.dest,
630
+ leg.destAlias,
631
+ leg.destLabel,
632
+ leg.destTags,
633
+ undefined, // a leg's `value:` is leg thickness, not the dest's size
634
+ leg.lineNumber
635
+ );
636
+ if (!destId) continue; // ungeocodable destination → skip this leg
637
+ legs.push({
638
+ fromId: prevId,
639
+ toId: destId,
640
+ ...(leg.label !== undefined && { label: leg.label }),
641
+ style: leg.style,
642
+ ...(leg.value !== undefined && { value: leg.value }),
643
+ lineNumber: leg.lineNumber,
644
+ });
645
+ if (!stopIds.includes(destId)) stopIds.push(destId); // unique markers (loop-close dedupe)
646
+ prevId = destId;
486
647
  }
487
- routes.push({ stopIds, meta: rt.meta, lineNumber: rt.lineNumber });
648
+ routes.push({ stopIds, legs, lineNumber: rt.lineNumber });
488
649
  }
489
650
 
490
651
  // ── Basemaps + scope ──
@@ -498,10 +659,12 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
498
659
  const bb = featureBbox(data.usStates, ref.id);
499
660
  if (bb) regionBoxes.push(bb);
500
661
  }
501
- // country regions contribute their country bbox
662
+ // country regions contribute their country bbox — but framed on the dominant
663
+ // landmass, ignoring far-detached minor territories (e.g. French Guiana) so a
664
+ // Europe map naming France doesn't auto-fit across the Atlantic (R5).
502
665
  for (const r of regions) {
503
666
  if (r.layer === 'country') {
504
- const bb = featureBbox(data.worldCoarse, r.iso);
667
+ const bb = featureBboxPrimary(data.worldCoarse, r.iso);
505
668
  if (bb) regionBoxes.push(bb);
506
669
  }
507
670
  }
@@ -516,6 +679,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
516
679
  const lonSpan = extent[1][0] - extent[0][0];
517
680
  const latSpan = extent[1][1] - extent[0][1];
518
681
  const span = Math.max(lonSpan, latSpan);
682
+ const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
519
683
  // albers-usa only covers US territory: choose it only when the map is truly
520
684
  // US-only — no non-US country region AND no POI outside the US (#13). Without
521
685
  // the POI guard a `default-country US` + Tokyo map projected to garbage.
@@ -542,16 +706,18 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
542
706
  projection = override;
543
707
  } else if (usDominant) {
544
708
  projection = 'albers-usa';
545
- } else if (span > WORLD_SPAN) {
546
- // World/continental scale: equirectangular fills the frame edge-to-edge and
547
- // never clips the continents at the boundary (naturalEarth's curved sides
548
- // overrun a corner-based fit). `projection natural-earth` opts back into the
549
- // curved look explicitly.
709
+ } else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
710
+ // World/multi-continent scale (or a polar-reaching frame): equirectangular
711
+ // fills the frame edge-to-edge, never clips the continents at the boundary
712
+ // (naturalEarth's curved sides overrun a corner-based fit), and avoids
713
+ // Mercator's gross sec(φ) area blow-up near the poles. `projection
714
+ // natural-earth` opts back into the curved look explicitly.
550
715
  projection = 'equirectangular';
551
- } else if (span < MERCATOR_MAX_SPAN) {
552
- projection = 'mercator';
553
716
  } else {
554
- projection = 'equirectangular';
717
+ // Tight clusters AND single-continent regional views: Mercator gives every
718
+ // mid-latitude landmass its familiar conventional shape (equirectangular
719
+ // squashes a continent like Europe horizontally).
720
+ projection = 'mercator';
555
721
  }
556
722
 
557
723
  // World-scale framing (R10): a multi-continent spread frames most cleanly as
package/src/map/types.ts CHANGED
@@ -21,8 +21,15 @@ export interface MapScale {
21
21
  export interface MapDirectives {
22
22
  region?: string;
23
23
  projection?: string;
24
- metric?: string;
25
- sizeMetric?: string;
24
+ /** Legend label for the region value ramp (`region-metric <label>`). */
25
+ regionMetric?: string;
26
+ /** Recognized color NAME for the choropleth ramp hue, peeled off the
27
+ * `region-metric` trailing token (§24B.3). Defaults to red when absent. */
28
+ regionMetricColor?: string;
29
+ /** Legend label for the POI value (marker size) channel (`poi-metric`). */
30
+ poiMetric?: string;
31
+ /** Legend label for the edge/leg value (thickness) channel (`flow-metric`). */
32
+ flowMetric?: string;
26
33
  scale?: MapScale;
27
34
  regionLabels?: string; // full | abbrev | off
28
35
  poiLabels?: string; // off | auto | all
@@ -30,8 +37,23 @@ export interface MapDirectives {
30
37
  defaultState?: string;
31
38
  activeTag?: string;
32
39
  noLegend?: boolean;
40
+ /** Suppress the Alaska & Hawaii inset boxes drawn under the `albers-usa`
41
+ * projection (bare flag `no-insets`). Only meaningful for the US states
42
+ * basemap; silently ignored under any other projection. */
43
+ noInsets?: boolean;
33
44
  subtitle?: string;
34
45
  caption?: string;
46
+ /** Basemap dress override (bare flags `muted` / `natural`). Forces the
47
+ * land/water styling regardless of whether a colouring dimension is active —
48
+ * `muted` recedes to neutral grays, `natural` keeps the green/blue reference
49
+ * dress. Absent → auto (muted iff a score/tag dimension is active). Lets two
50
+ * maps in one deck share a look. */
51
+ basemapStyle?: 'muted' | 'natural';
52
+ /** Opt-in subtle mountain-range relief shading (bare flag `relief`, §24B.2).
53
+ * Draws a shared directional gradient ("degenerate hillshade") clipped to
54
+ * each notable mountain-range polygon, over base land and under data fills.
55
+ * Off by default; needs the optional `mountain-ranges.json` asset. */
56
+ relief?: boolean;
35
57
  }
36
58
 
37
59
  /** A region-fill: a subdivision name with an optional score and/or tag values
@@ -42,37 +64,58 @@ export interface MapRegion {
42
64
  * (`Georgia US` → US context) or 3166-2 subdivision (`Georgia US-GA`).
43
65
  * Forces the country-vs-state interpretation and silences the ambiguity warning. */
44
66
  readonly scope?: string;
45
- readonly score?: number;
67
+ /** Numeric value → choropleth shade (§24B.3). Lifted out of `meta`. */
68
+ readonly value?: number;
69
+ /** §1.5 trailing-token color NAME → flat categorical override fill (§24B.4);
70
+ * painted regardless of the active colouring dimension, no legend entry. */
71
+ readonly color?: string;
46
72
  /** Tag values keyed by lowercased tag GROUP name (alias is resolved away). */
47
73
  readonly tags: Readonly<Record<string, string>>;
48
- /** Other reserved-but-inert keys (description/date/…) captured verbatim. */
74
+ /** Any remaining reserved keys captured verbatim (`label`/`style`/…). */
49
75
  readonly meta: Readonly<Record<string, string>>;
50
76
  readonly lineNumber: number;
51
77
  }
52
78
 
53
- /** A point of interest (§24B.5). `meta` holds reserved-but-inert keys
54
- * (size/score/description/weight/date) verbatim; `label` is lifted out. */
79
+ /** A point of interest (§24B.5). `meta` holds the numeric `value` (→ marker
80
+ * size) and `style` verbatim; `label` is lifted out. */
55
81
  export interface MapPoi {
56
82
  readonly pos: PoiPos;
57
83
  readonly alias?: string;
58
84
  readonly label?: string;
85
+ /** §1.5 trailing-token color NAME → flat marker fill (§24B.5); wins over a
86
+ * tag color and the default orange. */
87
+ readonly color?: string;
59
88
  readonly tags: Readonly<Record<string, string>>;
60
89
  readonly meta: Readonly<Record<string, string>>;
61
90
  readonly lineNumber: number;
62
91
  }
63
92
 
64
- /** A route stop (§24B.6); raw order preserved incl. a repeated first==last (loop). */
65
- export interface MapRouteStop {
66
- readonly ref: PoiPos;
67
- readonly alias?: string;
68
- readonly meta: Readonly<Record<string, string>>;
93
+ /** One leg of a route (§24B.6): an edge from the previous stop to `dest`. Reuses
94
+ * the edge arrow idiom — in-arrow text = leg label, `value:` = leg thickness,
95
+ * `->`/`~>` (or the header `style: arc`) = shape. Stop-targeted keys on the leg
96
+ * line (`tag`, `label:`) decorate the DESTINATION point. */
97
+ export interface MapRouteLeg {
98
+ readonly label?: string; // in-arrow leg label
99
+ readonly style: 'straight' | 'arc';
100
+ readonly value?: string; // leg thickness (numeric string, like an edge)
101
+ readonly dest: PoiPos;
102
+ readonly destAlias?: string;
103
+ readonly destLabel?: string;
104
+ readonly destTags: Readonly<Record<string, string>>;
69
105
  readonly lineNumber: number;
70
106
  }
71
107
 
72
- /** An ordered, auto-numbered route (§24B.6). `meta.style==='arc'` curves legs. */
108
+ /** An ordered, auto-numbered route (§24B.6): `route <origin> [style: arc]` + a
109
+ * sequence of indented arrow legs, each continuing from the previous stop.
110
+ * Repeat the origin as a leg's destination to close a loop. */
73
111
  export interface MapRoute {
74
- readonly stops: readonly MapRouteStop[];
75
- readonly meta: Readonly<Record<string, string>>;
112
+ readonly origin: PoiPos;
113
+ readonly originAlias?: string;
114
+ readonly originLabel?: string;
115
+ readonly originValue?: string; // header value → origin marker size
116
+ readonly originTags: Readonly<Record<string, string>>;
117
+ readonly style: 'straight' | 'arc'; // header default leg shape
118
+ readonly legs: readonly MapRouteLeg[];
76
119
  readonly lineNumber: number;
77
120
  }
78
121
 
@@ -75,16 +75,16 @@ export const INFRA_REGISTRY: ReservedKeyRegistry = staticRegistry([
75
75
  // NOTE: `color` is deliberately OMITTED (unlike sibling registries) — per
76
76
  // §24B.9 the map chart type carries color as a trailing token (peeled by
77
77
  // `peelTrailingColorName`) / via the tag system, not a `color:` metadata key.
78
- // `date`/`weight`/`style` are reserved seams (§24B.12): included so they require
79
- // colons and don't bleed into a name region, but inert in v1.
78
+ // `value` is the single numeric data channel (§24B): it renders as region shade,
79
+ // POI marker size, or edge/leg thickness depending on the element. `style` is the
80
+ // route/edge shape key. `description`/`date`/`score`/`size`/`weight` were removed
81
+ // in the 2026-06-01 syntax review — `value` collapses the old three numeric keys,
82
+ // and description/date had no v1 surface (they now raise an unknown-key error
83
+ // rather than silently no-op).
80
84
  export const MAP_REGISTRY: ReservedKeyRegistry = staticRegistry([
81
- 'score',
85
+ 'value',
82
86
  'label',
83
- 'size',
84
- 'description',
85
- 'weight',
86
87
  'style',
87
- 'date',
88
88
  ]);
89
89
 
90
90
  export const ORG_REGISTRY: ReservedKeyRegistry = staticRegistry([