@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/dist/advanced.cjs +329 -109
- package/dist/advanced.d.cts +66 -25
- package/dist/advanced.d.ts +66 -25
- package/dist/advanced.js +329 -109
- package/dist/auto.cjs +332 -107
- package/dist/auto.js +109 -109
- package/dist/auto.mjs +332 -107
- package/dist/cli.cjs +151 -151
- package/dist/editor.cjs +5 -2
- package/dist/editor.js +5 -2
- package/dist/highlight.cjs +5 -2
- package/dist/highlight.js +5 -2
- package/dist/index.cjs +326 -104
- package/dist/index.js +326 -104
- package/dist/internal.cjs +329 -109
- package/dist/internal.d.cts +66 -25
- package/dist/internal.d.ts +66 -25
- package/dist/internal.js +329 -109
- package/gallery/fixtures/map-choropleth.dgmo +7 -7
- package/gallery/fixtures/map-pois.dgmo +4 -4
- package/gallery/fixtures/map-region-scope.dgmo +8 -8
- package/gallery/fixtures/map-route.dgmo +5 -6
- package/package.json +1 -1
- package/src/completion.ts +9 -4
- package/src/editor/keywords.ts +5 -2
- package/src/map/layout.ts +141 -67
- package/src/map/parser.ts +122 -33
- package/src/map/renderer.ts +13 -6
- package/src/map/resolved-types.ts +13 -2
- package/src/map/resolver.ts +179 -34
- package/src/map/types.ts +39 -14
- package/src/utils/reserved-key-registry.ts +7 -7
package/src/map/parser.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
MapRegion,
|
|
29
29
|
MapPoi,
|
|
30
30
|
MapRoute,
|
|
31
|
-
|
|
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
|
-
'
|
|
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.
|
|
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.
|
|
302
|
-
d.
|
|
313
|
+
case 'region-metric':
|
|
314
|
+
dup(d.regionMetric);
|
|
315
|
+
d.regionMetric = value;
|
|
303
316
|
break;
|
|
304
|
-
case '
|
|
305
|
-
dup(d.
|
|
306
|
-
d.
|
|
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
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
437
|
-
delete (meta as Record<string, string>)['
|
|
438
|
-
|
|
439
|
-
if (!Number.isFinite(
|
|
440
|
-
pushError(line, `
|
|
441
|
-
|
|
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 `
|
|
445
|
-
// selectable colouring dimensions (the legend flips between the
|
|
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 (
|
|
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; `
|
|
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
|
|
496
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
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 {
|
package/src/map/renderer.ts
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
|
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 "
|
|
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() || '
|
|
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
|
|
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
|
|
95
|
+
readonly legs: readonly ResolvedRouteLeg[];
|
|
85
96
|
readonly lineNumber: number;
|
|
86
97
|
}
|
|
87
98
|
|
package/src/map/resolver.ts
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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.
|
|
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)
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
...(
|
|
470
|
-
lat:
|
|
471
|
-
lon:
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
54
|
-
*
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
|
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
|
|
75
|
-
readonly
|
|
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
|
|