@diagrammo/dgmo 0.20.3 → 0.21.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.
package/src/map/parser.ts CHANGED
@@ -28,7 +28,7 @@ import type {
28
28
  MapRegion,
29
29
  MapPoi,
30
30
  MapRoute,
31
- MapRouteStop,
31
+ MapRouteLeg,
32
32
  MapEdge,
33
33
  PoiPos,
34
34
  MapScale,
@@ -42,13 +42,16 @@ const SCOPE_RE = /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/;
42
42
  // whitespace, so hyphens inside names (`office-east`) and `foo-bar` are safe.
43
43
  const ARROW_SPLIT = /\s+(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+/;
44
44
  const HUB_RE = /^(->|~>)\s+(.+)$/;
45
+ // A route leg line: an optional leading arrow (with in-arrow label) + a destination.
46
+ const LEG_ARROW_RE = /^(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+(.+)$/;
45
47
  const AT_RE = /(^|[\s,])at\s*:/i; // the removed `at:` coord form (§24B.9)
46
48
 
47
49
  const DIRECTIVE_SET: ReadonlySet<string> = new Set([
48
50
  'region',
49
51
  'projection',
50
- 'metric',
51
- 'size-metric',
52
+ 'region-metric',
53
+ 'poi-metric',
54
+ 'flow-metric',
52
55
  'scale',
53
56
  'region-labels',
54
57
  'poi-labels',
@@ -177,9 +180,10 @@ export function parseMap(content: string): ParsedMap {
177
180
  addTagEntry(open.tag, trimmed, lineNumber);
178
181
  continue;
179
182
  }
180
- // (1a) Indented child of an open route → a stop.
183
+ // (1a) Indented child of an open route → a leg (an edge from the prev stop).
181
184
  if (open.route && indent > open.route.indent) {
182
- open.route.route.stops.push(parseStop(trimmed, lineNumber));
185
+ const leg = parseLeg(trimmed, lineNumber, open.route.route.style);
186
+ (open.route.route.legs as MapRouteLeg[]).push(leg);
183
187
  continue;
184
188
  }
185
189
  // (1b) Indented child of an open POI → hub edge or extra metadata.
@@ -217,6 +221,15 @@ export function parseMap(content: string): ParsedMap {
217
221
  handleTag(trimmed, lineNumber);
218
222
  continue;
219
223
  }
224
+ // Bare-flag directives (no value) — only when the line is exactly the flag,
225
+ // so a region named e.g. "Natural Bridge" still parses as a region.
226
+ if (
227
+ (firstWord === 'muted' || firstWord === 'natural') &&
228
+ trimmed === firstWord
229
+ ) {
230
+ handleDirective(firstWord, '', lineNumber);
231
+ continue;
232
+ }
220
233
  if (
221
234
  DIRECTIVE_SET.has(firstWord) &&
222
235
  !trimmed.slice(firstWord.length).trimStart().startsWith(':')
@@ -297,13 +310,17 @@ export function parseMap(content: string): ParsedMap {
297
310
  );
298
311
  d.projection = value;
299
312
  break;
300
- case 'metric':
301
- dup(d.metric);
302
- d.metric = value;
313
+ case 'region-metric':
314
+ dup(d.regionMetric);
315
+ d.regionMetric = value;
303
316
  break;
304
- case 'size-metric':
305
- dup(d.sizeMetric);
306
- d.sizeMetric = value;
317
+ case 'poi-metric':
318
+ dup(d.poiMetric);
319
+ d.poiMetric = value;
320
+ break;
321
+ case 'flow-metric':
322
+ dup(d.flowMetric);
323
+ d.flowMetric = value;
307
324
  break;
308
325
  case 'scale':
309
326
  dup(d.scale);
@@ -345,6 +362,15 @@ export function parseMap(content: string): ParsedMap {
345
362
  case 'no-legend':
346
363
  d.noLegend = true;
347
364
  break;
365
+ case 'muted':
366
+ case 'natural':
367
+ if (d.basemapStyle !== undefined && d.basemapStyle !== key)
368
+ pushWarning(
369
+ line,
370
+ `Conflicting basemap dress — "${d.basemapStyle}" then "${key}"; last wins.`
371
+ );
372
+ d.basemapStyle = key;
373
+ break;
348
374
  case 'subtitle':
349
375
  dup(d.subtitle);
350
376
  d.subtitle = value;
@@ -431,18 +457,18 @@ export function parseMap(content: string): ParsedMap {
431
457
  line
432
458
  );
433
459
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
434
- let scoreNum: number | undefined;
435
- const score = meta['score'];
436
- if (score !== undefined) {
437
- delete (meta as Record<string, string>)['score']; // lifted out of inert meta
438
- scoreNum = Number(score);
439
- if (!Number.isFinite(scoreNum)) {
440
- pushError(line, `score must be a number (got "${score}").`);
441
- scoreNum = undefined;
460
+ let valueNum: number | undefined;
461
+ const value = meta['value'];
462
+ if (value !== undefined) {
463
+ delete (meta as Record<string, string>)['value']; // lifted out of meta
464
+ valueNum = Number(value);
465
+ if (!Number.isFinite(valueNum)) {
466
+ pushError(line, `value must be a number (got "${value}").`);
467
+ valueNum = undefined;
442
468
  }
443
469
  }
444
- // A region may carry BOTH a `score:` and a tag value — they are two
445
- // selectable colouring dimensions (the legend flips between the score ramp
470
+ // A region may carry BOTH a `value:` and a tag value — they are two
471
+ // selectable colouring dimensions (the legend flips between the value ramp
446
472
  // and the tag group), so this is no longer warned (bivariate is handled).
447
473
  // Peel a trailing ISO scope token (§24B.8) — same qualifier POIs accept,
448
474
  // so `Georgia US-GA` / `Georgia US` can force the country-vs-state pick.
@@ -461,7 +487,7 @@ export function parseMap(content: string): ParsedMap {
461
487
  lineNumber: line,
462
488
  };
463
489
  if (regionScope !== undefined) region.scope = regionScope;
464
- if (scoreNum !== undefined) region.score = scoreNum;
490
+ if (valueNum !== undefined) region.value = valueNum;
465
491
  regions.push(region);
466
492
  }
467
493
 
@@ -482,7 +508,7 @@ export function parseMap(content: string): ParsedMap {
482
508
  const pos = parsePos(split.name, line);
483
509
  if (!pos) return; // error already pushed
484
510
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
485
- const label = meta['label']; // label lifted out of meta; `score`/`size`/`date` stay inert (#2)
511
+ const label = meta['label']; // label lifted out of meta; `value` (→ marker size) stays in meta
486
512
  if (label !== undefined) delete (meta as Record<string, string>)['label'];
487
513
  const poi: Writable<MapPoi> = { pos, tags, meta, lineNumber: line };
488
514
  if (split.alias) poi.alias = split.alias;
@@ -492,25 +518,88 @@ export function parseMap(content: string): ParsedMap {
492
518
  }
493
519
 
494
520
  function handleRoute(rest: string, line: number, indent: number): void {
495
- const meta = rest ? splitNameAndMeta(rest, registry(), aliasMap).meta : {};
496
- const route: Writable<MapRoute> = { stops: [], meta, lineNumber: line };
521
+ const split = rest
522
+ ? splitNameAndMeta(
523
+ rest,
524
+ registry(),
525
+ aliasMap,
526
+ undefined,
527
+ diagnostics,
528
+ line
529
+ )
530
+ : { name: '', meta: {} as Record<string, string>, alias: undefined };
531
+ const pos = parsePos(split.name, line);
532
+ if (!pos || (pos.kind === 'name' && !pos.name)) {
533
+ pushError(
534
+ line,
535
+ 'route requires an origin: `route <origin> [style: arc]`.'
536
+ );
537
+ return;
538
+ }
539
+ const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
540
+ const originLabel = meta['label'];
541
+ const originValue = meta['value'];
542
+ const style: 'straight' | 'arc' =
543
+ meta['style'] === 'arc' ? 'arc' : 'straight';
544
+ const route: Writable<MapRoute> = {
545
+ origin: pos,
546
+ ...(split.alias !== undefined && { originAlias: split.alias }),
547
+ ...(originLabel !== undefined && { originLabel }),
548
+ ...(originValue !== undefined && { originValue }),
549
+ originTags: tags,
550
+ style,
551
+ legs: [],
552
+ lineNumber: line,
553
+ };
497
554
  routes.push(route);
498
555
  open.route = { route, indent };
499
556
  }
500
557
 
501
- function parseStop(trimmed: string, line: number): MapRouteStop {
502
- const split = splitNameAndMeta(trimmed, registry(), aliasMap);
503
- const ref = parsePos(split.name, line) ?? {
558
+ /** Parse one route body line into a leg: `[-label->] <destination> [keys]`.
559
+ * The arrow (if any) gives the leg label + shape; `value:` is leg thickness;
560
+ * a tag / `label:` decorate the destination stop. Bare `<dest>` = plain leg. */
561
+ function parseLeg(
562
+ trimmed: string,
563
+ line: number,
564
+ headerStyle: 'straight' | 'arc'
565
+ ): MapRouteLeg {
566
+ let arrowStyle: 'straight' | 'arc' = 'straight';
567
+ let label: string | undefined;
568
+ let rest = trimmed;
569
+ const m = trimmed.match(LEG_ARROW_RE);
570
+ if (m) {
571
+ const arr = classifyArrow(m[1]!, line);
572
+ arrowStyle = arr.style;
573
+ label = arr.label;
574
+ rest = m[2]!;
575
+ }
576
+ const split = splitNameAndMeta(
577
+ rest,
578
+ registry(),
579
+ aliasMap,
580
+ undefined,
581
+ diagnostics,
582
+ line
583
+ );
584
+ const pos = parsePos(split.name, line) ?? {
504
585
  kind: 'name',
505
586
  name: split.name,
506
587
  };
507
- const stop: Writable<MapRouteStop> = {
508
- ref,
509
- meta: split.meta,
588
+ const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
589
+ const value = meta['value'];
590
+ const destLabel = meta['label'];
591
+ const style: 'straight' | 'arc' =
592
+ arrowStyle === 'arc' || headerStyle === 'arc' ? 'arc' : 'straight';
593
+ return {
594
+ ...(label !== undefined && { label }),
595
+ style,
596
+ ...(value !== undefined && { value }),
597
+ dest: pos,
598
+ ...(split.alias !== undefined && { destAlias: split.alias }),
599
+ ...(destLabel !== undefined && { destLabel }),
600
+ destTags: tags,
510
601
  lineNumber: line,
511
602
  };
512
- if (split.alias) stop.alias = split.alias;
513
- return stop;
514
603
  }
515
604
 
516
605
  function handleEdges(trimmed: string, line: number): void {
@@ -101,11 +101,11 @@ export function renderMap(
101
101
  .attr('stroke', r.stroke)
102
102
  .attr('stroke-width', strokeWidth);
103
103
  // Data layer? Tag it so the app can highlight on legend hover / gradient
104
- // scrub. `data-score` for ramp-proximity, `data-tag-<group>` per tag value
104
+ // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
105
105
  // (both lowercased to match the lowercased legend-entry attributes).
106
106
  if (r.layer !== 'base') {
107
107
  p.classed('dgmo-map-region', true).attr('data-region', r.id);
108
- if (r.score !== undefined) p.attr('data-score', r.score);
108
+ if (r.value !== undefined) p.attr('data-value', r.value);
109
109
  if (r.tags) {
110
110
  for (const [group, value] of Object.entries(r.tags)) {
111
111
  p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
@@ -230,6 +230,13 @@ export function renderMap(
230
230
  .attr('stroke-width', 1)
231
231
  .attr('data-line-number', poi.lineNumber)
232
232
  .attr('data-poi', poi.id);
233
+ // Tag the marker per tag value (lowercased, matching the lowercased
234
+ // legend-entry attributes) so the app can spotlight it on legend hover.
235
+ if (poi.tags) {
236
+ for (const [group, value] of Object.entries(poi.tags)) {
237
+ c.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
238
+ }
239
+ }
233
240
  if (onClickItem) {
234
241
  c.style('cursor', 'pointer').on('click', () =>
235
242
  onClickItem(poi.lineNumber)
@@ -296,14 +303,14 @@ export function renderMap(
296
303
  .append('g')
297
304
  .attr('class', 'dgmo-map-legend')
298
305
  .attr('transform', `translate(0, ${legendY})`);
299
- // The score ramp is a selectable colouring group alongside the tag groups
306
+ // The value ramp is a selectable colouring group alongside the tag groups
300
307
  // (the user flips between them); its capsule renders the gradient inline.
301
- // Reserved name "Score" when no metric label is set — must match SCORE_NAME
302
- // in layout.ts so the resolved activeGroup selects it.
308
+ // Reserved name "Value" when no region-metric label is set — must match
309
+ // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
303
310
  const ramp = layout.legend.ramp;
304
311
  const scoreGroup = ramp
305
312
  ? {
306
- name: ramp.metric?.trim() || 'Score',
313
+ name: ramp.metric?.trim() || 'Value',
307
314
  entries: [],
308
315
  gradient: {
309
316
  min: ramp.min,
@@ -46,7 +46,7 @@ export interface ResolvedRegion {
46
46
  readonly iso: string;
47
47
  readonly name: string; // display name
48
48
  readonly layer: 'country' | 'us-state';
49
- readonly score?: number;
49
+ readonly value?: number;
50
50
  readonly tags: Readonly<Record<string, string>>;
51
51
  readonly meta: Readonly<Record<string, string>>;
52
52
  readonly lineNumber: number;
@@ -79,9 +79,20 @@ export interface ResolvedEdge {
79
79
  readonly lineNumber: number;
80
80
  }
81
81
 
82
+ export interface ResolvedRouteLeg {
83
+ readonly fromId: string;
84
+ readonly toId: string;
85
+ readonly label?: string;
86
+ readonly style: 'straight' | 'arc';
87
+ readonly value?: string; // leg thickness
88
+ readonly lineNumber: number;
89
+ }
90
+
82
91
  export interface ResolvedRoute {
92
+ /** Ordered UNIQUE stop ids (for numbering + the origin marker). A loop-closing
93
+ * leg whose destination is an earlier stop adds a leg but no duplicate stop. */
83
94
  readonly stopIds: readonly string[];
84
- readonly meta: Readonly<Record<string, string>>;
95
+ readonly legs: readonly ResolvedRouteLeg[];
85
96
  readonly lineNumber: number;
86
97
  }
87
98
 
@@ -14,6 +14,7 @@ import type {
14
14
  ResolvedPoi,
15
15
  ResolvedEdge,
16
16
  ResolvedRoute,
17
+ ResolvedRouteLeg,
17
18
  ProjectionFamily,
18
19
  GeoExtent,
19
20
  } from './resolved-types';
@@ -64,6 +65,75 @@ const REGION_ALIASES: Readonly<Record<string, string>> = {
64
65
  'czech republic': 'czechia',
65
66
  };
66
67
 
68
+ // US state (+ DC) postal abbreviations. A bare trailing two-letter scope token
69
+ // that is one of these resolves to the US STATE `US-XX` (not the colliding
70
+ // ISO 3166-1 country code — e.g. `CA` = California, not Canada) and signals US
71
+ // scope (§24B.8, 2026-06-01). Non-state two-letter tokens (`CR`, `FR`) stay
72
+ // country codes. Trade-off: a foreign country whose code collides with a state
73
+ // (DE/IN/AR/CO/LA/PA/…) can't be picked by bare code — use the city name alone
74
+ // (most-populous) or coordinates.
75
+ const US_STATE_POSTAL: ReadonlySet<string> = new Set([
76
+ 'AL',
77
+ 'AK',
78
+ 'AZ',
79
+ 'AR',
80
+ 'CA',
81
+ 'CO',
82
+ 'CT',
83
+ 'DE',
84
+ 'FL',
85
+ 'GA',
86
+ 'HI',
87
+ 'ID',
88
+ 'IL',
89
+ 'IN',
90
+ 'IA',
91
+ 'KS',
92
+ 'KY',
93
+ 'LA',
94
+ 'ME',
95
+ 'MD',
96
+ 'MA',
97
+ 'MI',
98
+ 'MN',
99
+ 'MS',
100
+ 'MO',
101
+ 'MT',
102
+ 'NE',
103
+ 'NV',
104
+ 'NH',
105
+ 'NJ',
106
+ 'NM',
107
+ 'NY',
108
+ 'NC',
109
+ 'ND',
110
+ 'OH',
111
+ 'OK',
112
+ 'OR',
113
+ 'PA',
114
+ 'RI',
115
+ 'SC',
116
+ 'SD',
117
+ 'TN',
118
+ 'TX',
119
+ 'UT',
120
+ 'VT',
121
+ 'VA',
122
+ 'WA',
123
+ 'WV',
124
+ 'WI',
125
+ 'WY',
126
+ 'DC',
127
+ ]);
128
+
129
+ /** A bare two-letter scope token that names a US state → its `US-XX` 3166-2 id
130
+ * (`OR` → `US-OR`), else null. Case-insensitive. */
131
+ function usStateFromBareScope(scope: string | undefined): string | null {
132
+ if (!scope) return null;
133
+ const up = scope.toUpperCase();
134
+ return US_STATE_POSTAL.has(up) ? `US-${up}` : null;
135
+ }
136
+
67
137
  /** Rough US bounding box (incl. AK across the dateline, HI, PR) for classifying
68
138
  * bare coordinate POIs as US-or-not when deciding `albers-usa` (#13). */
69
139
  function looksUS(lat: number, lon: number): boolean {
@@ -128,10 +198,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
128
198
  return usStateIndex.has(f) && !countryIndex.has(f);
129
199
  }) ||
130
200
  parsed.regions.some(
131
- (r) => r.scope === 'US' || r.scope?.startsWith('US-')
201
+ (r) =>
202
+ r.scope === 'US' ||
203
+ r.scope?.startsWith('US-') ||
204
+ usStateFromBareScope(r.scope) !== null
132
205
  ) ||
133
206
  parsed.pois.some(
134
- (p) => p.pos.kind === 'name' && p.pos.scope?.startsWith('US-')
207
+ (p) =>
208
+ p.pos.kind === 'name' &&
209
+ (p.pos.scope?.startsWith('US-') ||
210
+ usStateFromBareScope(p.pos.scope) !== null)
135
211
  );
136
212
 
137
213
  // ── Regions (R2/R12) ──
@@ -212,7 +288,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
212
288
  iso: chosen.id,
213
289
  name: chosen.name,
214
290
  layer: chosen.layer,
215
- ...(r.score !== undefined && { score: r.score }),
291
+ ...(r.value !== undefined && { value: r.value }),
216
292
  tags: r.tags,
217
293
  meta: r.meta,
218
294
  lineNumber: r.lineNumber,
@@ -289,11 +365,14 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
289
365
  let cands = idxs.map((i) => data.gazetteer.cities[i]!);
290
366
  const scopeUse = scope ?? scopeHint;
291
367
  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);
368
+ // ISO 3166-2 subdivision scope is `XX-…` (two letters + dash). A bare
369
+ // two-letter token that names a US state resolves as the `US-XX`
370
+ // subdivision (`CA` = California, §24B.8) — NOT the colliding country
371
+ // code; any other bare two-letter token is a 3166-1 country code.
372
+ const bareState = usStateFromBareScope(scopeUse);
373
+ const subScope = /^[A-Za-z]{2}-/.test(scopeUse) ? scopeUse : bareState;
295
374
  const filtered = cands.filter((c) =>
296
- isSub ? c[5] === scopeUse : c[2] === scopeUse
375
+ subScope ? c[5] === subScope : c[2] === scopeUse
297
376
  );
298
377
  if (filtered.length) cands = filtered;
299
378
  else if (scope) {
@@ -455,36 +534,102 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
455
534
  });
456
535
  }
457
536
 
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> = {
537
+ // Resolve a route stop to a POI id, registering it (with its inline tags/label/
538
+ // size value) or binding to an already-declared POI. Returns null if a named
539
+ // stop can't be geocoded. Unlike the old code, stop metadata is NO LONGER
540
+ // dropped — it rides onto the created POI (fixes the named-stop meta bug).
541
+ const resolveStop = (
542
+ pos: PoiPos,
543
+ alias: string | undefined,
544
+ label: string | undefined,
545
+ tags: Readonly<Record<string, string>>,
546
+ sizeValue: string | undefined,
547
+ line: number
548
+ ): string | null => {
549
+ const meta: Record<string, string> =
550
+ sizeValue !== undefined ? { value: sizeValue } : {};
551
+ if (pos.kind === 'coords') {
552
+ const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
553
+ if (!looksUS(pos.lat, pos.lon)) anyNonUsPoi = true;
554
+ if (!registry.has(id)) {
555
+ registerPoi(
556
+ id,
557
+ {
468
558
  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);
559
+ ...(alias !== undefined && { name: alias }),
560
+ lat: pos.lat,
561
+ lon: pos.lon,
562
+ ...(label !== undefined && { label }),
563
+ tags,
564
+ meta,
565
+ lineNumber: line,
566
+ },
567
+ line
568
+ );
484
569
  }
485
- if (id) stopIds.push(id);
570
+ return id;
571
+ }
572
+ // Named stop: bind to an existing declared POI if present, else geocode + create.
573
+ const f = fold(pos.name);
574
+ if (registry.has(f)) return f;
575
+ const aliased = declaredByName.get(f);
576
+ if (aliased) return aliased;
577
+ const got = lookupName(pos.name, pos.scope, line, inferredCountry, true);
578
+ if (got.kind !== 'ok') return null;
579
+ noteCountry(got.iso);
580
+ registerPoi(
581
+ f,
582
+ {
583
+ id: f,
584
+ name: pos.name,
585
+ lat: got.lat,
586
+ lon: got.lon,
587
+ ...(label !== undefined && { label }),
588
+ tags,
589
+ meta,
590
+ lineNumber: line,
591
+ },
592
+ line
593
+ );
594
+ return f;
595
+ };
596
+
597
+ const routes: ResolvedRoute[] = [];
598
+ for (const rt of parsed.routes) {
599
+ const originId = resolveStop(
600
+ rt.origin,
601
+ rt.originAlias,
602
+ rt.originLabel,
603
+ rt.originTags,
604
+ rt.originValue,
605
+ rt.lineNumber
606
+ );
607
+ if (!originId) continue; // can't anchor the route → drop (error already pushed)
608
+ const stopIds: string[] = [originId];
609
+ const legs: ResolvedRouteLeg[] = [];
610
+ let prevId = originId;
611
+ for (const leg of rt.legs) {
612
+ const destId = resolveStop(
613
+ leg.dest,
614
+ leg.destAlias,
615
+ leg.destLabel,
616
+ leg.destTags,
617
+ undefined, // a leg's `value:` is leg thickness, not the dest's size
618
+ leg.lineNumber
619
+ );
620
+ if (!destId) continue; // ungeocodable destination → skip this leg
621
+ legs.push({
622
+ fromId: prevId,
623
+ toId: destId,
624
+ ...(leg.label !== undefined && { label: leg.label }),
625
+ style: leg.style,
626
+ ...(leg.value !== undefined && { value: leg.value }),
627
+ lineNumber: leg.lineNumber,
628
+ });
629
+ if (!stopIds.includes(destId)) stopIds.push(destId); // unique markers (loop-close dedupe)
630
+ prevId = destId;
486
631
  }
487
- routes.push({ stopIds, meta: rt.meta, lineNumber: rt.lineNumber });
632
+ routes.push({ stopIds, legs, lineNumber: rt.lineNumber });
488
633
  }
489
634
 
490
635
  // ── Basemaps + scope ──
package/src/map/types.ts CHANGED
@@ -21,8 +21,12 @@ 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
+ /** Legend label for the POI value (marker size) channel (`poi-metric`). */
27
+ poiMetric?: string;
28
+ /** Legend label for the edge/leg value (thickness) channel (`flow-metric`). */
29
+ flowMetric?: string;
26
30
  scale?: MapScale;
27
31
  regionLabels?: string; // full | abbrev | off
28
32
  poiLabels?: string; // off | auto | all
@@ -32,6 +36,12 @@ export interface MapDirectives {
32
36
  noLegend?: boolean;
33
37
  subtitle?: string;
34
38
  caption?: string;
39
+ /** Basemap dress override (bare flags `muted` / `natural`). Forces the
40
+ * land/water styling regardless of whether a colouring dimension is active —
41
+ * `muted` recedes to neutral grays, `natural` keeps the green/blue reference
42
+ * dress. Absent → auto (muted iff a score/tag dimension is active). Lets two
43
+ * maps in one deck share a look. */
44
+ basemapStyle?: 'muted' | 'natural';
35
45
  }
36
46
 
37
47
  /** A region-fill: a subdivision name with an optional score and/or tag values
@@ -42,16 +52,17 @@ export interface MapRegion {
42
52
  * (`Georgia US` → US context) or 3166-2 subdivision (`Georgia US-GA`).
43
53
  * Forces the country-vs-state interpretation and silences the ambiguity warning. */
44
54
  readonly scope?: string;
45
- readonly score?: number;
55
+ /** Numeric value → choropleth shade (§24B.3). Lifted out of `meta`. */
56
+ readonly value?: number;
46
57
  /** Tag values keyed by lowercased tag GROUP name (alias is resolved away). */
47
58
  readonly tags: Readonly<Record<string, string>>;
48
- /** Other reserved-but-inert keys (description/date/…) captured verbatim. */
59
+ /** Any remaining reserved keys captured verbatim (`label`/`style`/…). */
49
60
  readonly meta: Readonly<Record<string, string>>;
50
61
  readonly lineNumber: number;
51
62
  }
52
63
 
53
- /** A point of interest (§24B.5). `meta` holds reserved-but-inert keys
54
- * (size/score/description/weight/date) verbatim; `label` is lifted out. */
64
+ /** A point of interest (§24B.5). `meta` holds the numeric `value` (→ marker
65
+ * size) and `style` verbatim; `label` is lifted out. */
55
66
  export interface MapPoi {
56
67
  readonly pos: PoiPos;
57
68
  readonly alias?: string;
@@ -61,18 +72,32 @@ export interface MapPoi {
61
72
  readonly lineNumber: number;
62
73
  }
63
74
 
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>>;
75
+ /** One leg of a route (§24B.6): an edge from the previous stop to `dest`. Reuses
76
+ * the edge arrow idiom — in-arrow text = leg label, `value:` = leg thickness,
77
+ * `->`/`~>` (or the header `style: arc`) = shape. Stop-targeted keys on the leg
78
+ * line (`tag`, `label:`) decorate the DESTINATION point. */
79
+ export interface MapRouteLeg {
80
+ readonly label?: string; // in-arrow leg label
81
+ readonly style: 'straight' | 'arc';
82
+ readonly value?: string; // leg thickness (numeric string, like an edge)
83
+ readonly dest: PoiPos;
84
+ readonly destAlias?: string;
85
+ readonly destLabel?: string;
86
+ readonly destTags: Readonly<Record<string, string>>;
69
87
  readonly lineNumber: number;
70
88
  }
71
89
 
72
- /** An ordered, auto-numbered route (§24B.6). `meta.style==='arc'` curves legs. */
90
+ /** An ordered, auto-numbered route (§24B.6): `route <origin> [style: arc]` + a
91
+ * sequence of indented arrow legs, each continuing from the previous stop.
92
+ * Repeat the origin as a leg's destination to close a loop. */
73
93
  export interface MapRoute {
74
- readonly stops: readonly MapRouteStop[];
75
- readonly meta: Readonly<Record<string, string>>;
94
+ readonly origin: PoiPos;
95
+ readonly originAlias?: string;
96
+ readonly originLabel?: string;
97
+ readonly originValue?: string; // header value → origin marker size
98
+ readonly originTags: Readonly<Record<string, string>>;
99
+ readonly style: 'straight' | 'arc'; // header default leg shape
100
+ readonly legs: readonly MapRouteLeg[];
76
101
  readonly lineNumber: number;
77
102
  }
78
103