@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.
- package/dist/advanced.cjs +867 -286
- package/dist/advanced.js +866 -286
- package/dist/auto.cjs +635 -284
- package/dist/auto.js +113 -113
- package/dist/auto.mjs +635 -284
- package/dist/cli.cjs +156 -156
- package/dist/editor.cjs +6 -2
- package/dist/editor.js +6 -2
- package/dist/highlight.cjs +6 -2
- package/dist/highlight.js +6 -2
- package/dist/index.cjs +628 -281
- package/dist/index.js +628 -281
- package/dist/internal.cjs +867 -286
- package/dist/internal.js +866 -286
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -0
- package/docs/language-reference.md +27 -25
- package/gallery/fixtures/map-choropleth.dgmo +7 -7
- package/gallery/fixtures/map-direct-color.dgmo +10 -0
- 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/advanced.ts +14 -0
- package/src/completion.ts +10 -4
- package/src/d3.ts +15 -9
- package/src/editor/keywords.ts +6 -2
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -0
- package/src/map/geo-query.ts +277 -0
- package/src/map/geo.ts +258 -1
- package/src/map/invert.ts +111 -0
- package/src/map/layout.ts +333 -139
- package/src/map/load-data.ts +7 -1
- package/src/map/parser.ts +142 -33
- package/src/map/renderer.ts +57 -6
- package/src/map/resolved-types.ts +21 -2
- package/src/map/resolver.ts +219 -53
- package/src/map/types.ts +57 -14
- package/src/utils/reserved-key-registry.ts +7 -7
- package/dist/advanced.d.cts +0 -5290
- package/dist/advanced.d.ts +0 -5290
- package/dist/auto.d.cts +0 -39
- package/dist/auto.d.ts +0 -39
- package/dist/index.d.cts +0 -336
- package/dist/index.d.ts +0 -336
- package/dist/internal.d.cts +0 -5290
- package/dist/internal.d.ts +0 -5290
package/src/map/load-data.ts
CHANGED
|
@@ -43,6 +43,7 @@ const FILES = {
|
|
|
43
43
|
usStates: 'us-states.json',
|
|
44
44
|
lakes: 'lakes.json',
|
|
45
45
|
rivers: 'rivers.json',
|
|
46
|
+
mountainRanges: 'mountain-ranges.json',
|
|
46
47
|
naLand: 'na-land.json',
|
|
47
48
|
naLakes: 'na-lakes.json',
|
|
48
49
|
gazetteer: 'gazetteer.json',
|
|
@@ -131,6 +132,7 @@ export function loadMapData(): Promise<MapData> {
|
|
|
131
132
|
usStates,
|
|
132
133
|
lakes,
|
|
133
134
|
rivers,
|
|
135
|
+
mountainRanges,
|
|
134
136
|
naLand,
|
|
135
137
|
naLakes,
|
|
136
138
|
gazetteer,
|
|
@@ -138,9 +140,12 @@ export function loadMapData(): Promise<MapData> {
|
|
|
138
140
|
readJson<BoundaryTopology>(nb, dir, FILES.worldCoarse),
|
|
139
141
|
readJson<BoundaryTopology>(nb, dir, FILES.worldDetail),
|
|
140
142
|
readJson<BoundaryTopology>(nb, dir, FILES.usStates),
|
|
141
|
-
// Lakes/rivers/NA assets are optional — older bundles may predate them.
|
|
143
|
+
// Lakes/rivers/mountain/NA assets are optional — older bundles may predate them.
|
|
142
144
|
readJson<BoundaryTopology>(nb, dir, FILES.lakes).catch(() => undefined),
|
|
143
145
|
readJson<BoundaryTopology>(nb, dir, FILES.rivers).catch(() => undefined),
|
|
146
|
+
readJson<BoundaryTopology>(nb, dir, FILES.mountainRanges).catch(
|
|
147
|
+
() => undefined
|
|
148
|
+
),
|
|
144
149
|
readJson<BoundaryTopology>(nb, dir, FILES.naLand).catch(() => undefined),
|
|
145
150
|
readJson<BoundaryTopology>(nb, dir, FILES.naLakes).catch(() => undefined),
|
|
146
151
|
readJson<Gazetteer>(nb, dir, FILES.gazetteer),
|
|
@@ -152,6 +157,7 @@ export function loadMapData(): Promise<MapData> {
|
|
|
152
157
|
gazetteer,
|
|
153
158
|
...(lakes && { lakes }),
|
|
154
159
|
...(rivers && { rivers }),
|
|
160
|
+
...(mountainRanges && { mountainRanges }),
|
|
155
161
|
...(naLand && { naLand }),
|
|
156
162
|
...(naLakes && { naLakes }),
|
|
157
163
|
});
|
package/src/map/parser.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
measureIndent,
|
|
10
10
|
splitNameAndMeta,
|
|
11
11
|
extractColor,
|
|
12
|
+
peelTrailingColorName,
|
|
12
13
|
} from '../utils/parsing';
|
|
13
14
|
import {
|
|
14
15
|
MAP_REGISTRY,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
MapRegion,
|
|
29
30
|
MapPoi,
|
|
30
31
|
MapRoute,
|
|
31
|
-
|
|
32
|
+
MapRouteLeg,
|
|
32
33
|
MapEdge,
|
|
33
34
|
PoiPos,
|
|
34
35
|
MapScale,
|
|
@@ -42,13 +43,16 @@ const SCOPE_RE = /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/;
|
|
|
42
43
|
// whitespace, so hyphens inside names (`office-east`) and `foo-bar` are safe.
|
|
43
44
|
const ARROW_SPLIT = /\s+(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+/;
|
|
44
45
|
const HUB_RE = /^(->|~>)\s+(.+)$/;
|
|
46
|
+
// A route leg line: an optional leading arrow (with in-arrow label) + a destination.
|
|
47
|
+
const LEG_ARROW_RE = /^(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+(.+)$/;
|
|
45
48
|
const AT_RE = /(^|[\s,])at\s*:/i; // the removed `at:` coord form (§24B.9)
|
|
46
49
|
|
|
47
50
|
const DIRECTIVE_SET: ReadonlySet<string> = new Set([
|
|
48
51
|
'region',
|
|
49
52
|
'projection',
|
|
50
|
-
'metric',
|
|
51
|
-
'
|
|
53
|
+
'region-metric',
|
|
54
|
+
'poi-metric',
|
|
55
|
+
'flow-metric',
|
|
52
56
|
'scale',
|
|
53
57
|
'region-labels',
|
|
54
58
|
'poi-labels',
|
|
@@ -56,6 +60,8 @@ const DIRECTIVE_SET: ReadonlySet<string> = new Set([
|
|
|
56
60
|
'default-state',
|
|
57
61
|
'active-tag',
|
|
58
62
|
'no-legend',
|
|
63
|
+
'no-insets',
|
|
64
|
+
'relief',
|
|
59
65
|
'subtitle',
|
|
60
66
|
'caption',
|
|
61
67
|
]);
|
|
@@ -177,9 +183,10 @@ export function parseMap(content: string): ParsedMap {
|
|
|
177
183
|
addTagEntry(open.tag, trimmed, lineNumber);
|
|
178
184
|
continue;
|
|
179
185
|
}
|
|
180
|
-
// (1a) Indented child of an open route → a stop.
|
|
186
|
+
// (1a) Indented child of an open route → a leg (an edge from the prev stop).
|
|
181
187
|
if (open.route && indent > open.route.indent) {
|
|
182
|
-
open.route.route.
|
|
188
|
+
const leg = parseLeg(trimmed, lineNumber, open.route.route.style);
|
|
189
|
+
(open.route.route.legs as MapRouteLeg[]).push(leg);
|
|
183
190
|
continue;
|
|
184
191
|
}
|
|
185
192
|
// (1b) Indented child of an open POI → hub edge or extra metadata.
|
|
@@ -217,6 +224,15 @@ export function parseMap(content: string): ParsedMap {
|
|
|
217
224
|
handleTag(trimmed, lineNumber);
|
|
218
225
|
continue;
|
|
219
226
|
}
|
|
227
|
+
// Bare-flag directives (no value) — only when the line is exactly the flag,
|
|
228
|
+
// so a region named e.g. "Natural Bridge" still parses as a region.
|
|
229
|
+
if (
|
|
230
|
+
(firstWord === 'muted' || firstWord === 'natural') &&
|
|
231
|
+
trimmed === firstWord
|
|
232
|
+
) {
|
|
233
|
+
handleDirective(firstWord, '', lineNumber);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
220
236
|
if (
|
|
221
237
|
DIRECTIVE_SET.has(firstWord) &&
|
|
222
238
|
!trimmed.slice(firstWord.length).trimStart().startsWith(':')
|
|
@@ -297,13 +313,23 @@ export function parseMap(content: string): ParsedMap {
|
|
|
297
313
|
);
|
|
298
314
|
d.projection = value;
|
|
299
315
|
break;
|
|
300
|
-
case 'metric':
|
|
301
|
-
dup(d.
|
|
302
|
-
|
|
316
|
+
case 'region-metric': {
|
|
317
|
+
dup(d.regionMetric);
|
|
318
|
+
// A trailing color names the choropleth ramp hue (§24B.3): the
|
|
319
|
+
// label keeps the rest. `region-metric Sales ($M) blue` → blue ramp.
|
|
320
|
+
const { label: rmLabel, colorName: rmColor } =
|
|
321
|
+
peelTrailingColorName(value);
|
|
322
|
+
d.regionMetric = rmLabel;
|
|
323
|
+
if (rmColor) d.regionMetricColor = rmColor;
|
|
303
324
|
break;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
d.
|
|
325
|
+
}
|
|
326
|
+
case 'poi-metric':
|
|
327
|
+
dup(d.poiMetric);
|
|
328
|
+
d.poiMetric = value;
|
|
329
|
+
break;
|
|
330
|
+
case 'flow-metric':
|
|
331
|
+
dup(d.flowMetric);
|
|
332
|
+
d.flowMetric = value;
|
|
307
333
|
break;
|
|
308
334
|
case 'scale':
|
|
309
335
|
dup(d.scale);
|
|
@@ -345,6 +371,22 @@ export function parseMap(content: string): ParsedMap {
|
|
|
345
371
|
case 'no-legend':
|
|
346
372
|
d.noLegend = true;
|
|
347
373
|
break;
|
|
374
|
+
case 'no-insets':
|
|
375
|
+
d.noInsets = true;
|
|
376
|
+
break;
|
|
377
|
+
case 'relief':
|
|
378
|
+
// Bare flag (idempotent like no-insets — `relief\nrelief` is no warning).
|
|
379
|
+
d.relief = true;
|
|
380
|
+
break;
|
|
381
|
+
case 'muted':
|
|
382
|
+
case 'natural':
|
|
383
|
+
if (d.basemapStyle !== undefined && d.basemapStyle !== key)
|
|
384
|
+
pushWarning(
|
|
385
|
+
line,
|
|
386
|
+
`Conflicting basemap dress — "${d.basemapStyle}" then "${key}"; last wins.`
|
|
387
|
+
);
|
|
388
|
+
d.basemapStyle = key;
|
|
389
|
+
break;
|
|
348
390
|
case 'subtitle':
|
|
349
391
|
dup(d.subtitle);
|
|
350
392
|
d.subtitle = value;
|
|
@@ -431,18 +473,18 @@ export function parseMap(content: string): ParsedMap {
|
|
|
431
473
|
line
|
|
432
474
|
);
|
|
433
475
|
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
|
-
|
|
476
|
+
let valueNum: number | undefined;
|
|
477
|
+
const value = meta['value'];
|
|
478
|
+
if (value !== undefined) {
|
|
479
|
+
delete (meta as Record<string, string>)['value']; // lifted out of meta
|
|
480
|
+
valueNum = Number(value);
|
|
481
|
+
if (!Number.isFinite(valueNum)) {
|
|
482
|
+
pushError(line, `value must be a number (got "${value}").`);
|
|
483
|
+
valueNum = undefined;
|
|
442
484
|
}
|
|
443
485
|
}
|
|
444
|
-
// A region may carry BOTH a `
|
|
445
|
-
// selectable colouring dimensions (the legend flips between the
|
|
486
|
+
// A region may carry BOTH a `value:` and a tag value — they are two
|
|
487
|
+
// selectable colouring dimensions (the legend flips between the value ramp
|
|
446
488
|
// and the tag group), so this is no longer warned (bivariate is handled).
|
|
447
489
|
// Peel a trailing ISO scope token (§24B.8) — same qualifier POIs accept,
|
|
448
490
|
// so `Georgia US-GA` / `Georgia US` can force the country-vs-state pick.
|
|
@@ -461,7 +503,9 @@ export function parseMap(content: string): ParsedMap {
|
|
|
461
503
|
lineNumber: line,
|
|
462
504
|
};
|
|
463
505
|
if (regionScope !== undefined) region.scope = regionScope;
|
|
464
|
-
if (
|
|
506
|
+
if (valueNum !== undefined) region.value = valueNum;
|
|
507
|
+
// §1.5 trailing color → flat categorical override fill (§24B.4).
|
|
508
|
+
if (split.color) region.color = split.color;
|
|
465
509
|
regions.push(region);
|
|
466
510
|
}
|
|
467
511
|
|
|
@@ -482,35 +526,100 @@ export function parseMap(content: string): ParsedMap {
|
|
|
482
526
|
const pos = parsePos(split.name, line);
|
|
483
527
|
if (!pos) return; // error already pushed
|
|
484
528
|
const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
|
|
485
|
-
const label = meta['label']; // label lifted out of meta; `
|
|
529
|
+
const label = meta['label']; // label lifted out of meta; `value` (→ marker size) stays in meta
|
|
486
530
|
if (label !== undefined) delete (meta as Record<string, string>)['label'];
|
|
487
531
|
const poi: Writable<MapPoi> = { pos, tags, meta, lineNumber: line };
|
|
488
532
|
if (split.alias) poi.alias = split.alias;
|
|
489
533
|
if (label !== undefined) poi.label = label;
|
|
534
|
+
// §1.5 trailing color → flat marker fill (§24B.5); wins over a tag color.
|
|
535
|
+
if (split.color) poi.color = split.color;
|
|
490
536
|
pois.push(poi);
|
|
491
537
|
open.poi = { poi, indent };
|
|
492
538
|
}
|
|
493
539
|
|
|
494
540
|
function handleRoute(rest: string, line: number, indent: number): void {
|
|
495
|
-
const
|
|
496
|
-
|
|
541
|
+
const split = rest
|
|
542
|
+
? splitNameAndMeta(
|
|
543
|
+
rest,
|
|
544
|
+
registry(),
|
|
545
|
+
aliasMap,
|
|
546
|
+
undefined,
|
|
547
|
+
diagnostics,
|
|
548
|
+
line
|
|
549
|
+
)
|
|
550
|
+
: { name: '', meta: {} as Record<string, string>, alias: undefined };
|
|
551
|
+
const pos = parsePos(split.name, line);
|
|
552
|
+
if (!pos || (pos.kind === 'name' && !pos.name)) {
|
|
553
|
+
pushError(
|
|
554
|
+
line,
|
|
555
|
+
'route requires an origin: `route <origin> [style: arc]`.'
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
|
|
560
|
+
const originLabel = meta['label'];
|
|
561
|
+
const originValue = meta['value'];
|
|
562
|
+
const style: 'straight' | 'arc' =
|
|
563
|
+
meta['style'] === 'arc' ? 'arc' : 'straight';
|
|
564
|
+
const route: Writable<MapRoute> = {
|
|
565
|
+
origin: pos,
|
|
566
|
+
...(split.alias !== undefined && { originAlias: split.alias }),
|
|
567
|
+
...(originLabel !== undefined && { originLabel }),
|
|
568
|
+
...(originValue !== undefined && { originValue }),
|
|
569
|
+
originTags: tags,
|
|
570
|
+
style,
|
|
571
|
+
legs: [],
|
|
572
|
+
lineNumber: line,
|
|
573
|
+
};
|
|
497
574
|
routes.push(route);
|
|
498
575
|
open.route = { route, indent };
|
|
499
576
|
}
|
|
500
577
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
578
|
+
/** Parse one route body line into a leg: `[-label->] <destination> [keys]`.
|
|
579
|
+
* The arrow (if any) gives the leg label + shape; `value:` is leg thickness;
|
|
580
|
+
* a tag / `label:` decorate the destination stop. Bare `<dest>` = plain leg. */
|
|
581
|
+
function parseLeg(
|
|
582
|
+
trimmed: string,
|
|
583
|
+
line: number,
|
|
584
|
+
headerStyle: 'straight' | 'arc'
|
|
585
|
+
): MapRouteLeg {
|
|
586
|
+
let arrowStyle: 'straight' | 'arc' = 'straight';
|
|
587
|
+
let label: string | undefined;
|
|
588
|
+
let rest = trimmed;
|
|
589
|
+
const m = trimmed.match(LEG_ARROW_RE);
|
|
590
|
+
if (m) {
|
|
591
|
+
const arr = classifyArrow(m[1]!, line);
|
|
592
|
+
arrowStyle = arr.style;
|
|
593
|
+
label = arr.label;
|
|
594
|
+
rest = m[2]!;
|
|
595
|
+
}
|
|
596
|
+
const split = splitNameAndMeta(
|
|
597
|
+
rest,
|
|
598
|
+
registry(),
|
|
599
|
+
aliasMap,
|
|
600
|
+
undefined,
|
|
601
|
+
diagnostics,
|
|
602
|
+
line
|
|
603
|
+
);
|
|
604
|
+
const pos = parsePos(split.name, line) ?? {
|
|
504
605
|
kind: 'name',
|
|
505
606
|
name: split.name,
|
|
506
607
|
};
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
608
|
+
const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
|
|
609
|
+
const value = meta['value'];
|
|
610
|
+
const destLabel = meta['label'];
|
|
611
|
+
const style: 'straight' | 'arc' =
|
|
612
|
+
arrowStyle === 'arc' || headerStyle === 'arc' ? 'arc' : 'straight';
|
|
613
|
+
return {
|
|
614
|
+
...(label !== undefined && { label }),
|
|
615
|
+
style,
|
|
616
|
+
...(value !== undefined && { value }),
|
|
617
|
+
dest: pos,
|
|
618
|
+
...(split.alias !== undefined && { destAlias: split.alias }),
|
|
619
|
+
...(destLabel !== undefined && { destLabel }),
|
|
620
|
+
destTags: tags,
|
|
510
621
|
lineNumber: line,
|
|
511
622
|
};
|
|
512
|
-
if (split.alias) stop.alias = split.alias;
|
|
513
|
-
return stop;
|
|
514
623
|
}
|
|
515
624
|
|
|
516
625
|
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());
|
|
@@ -123,6 +123,49 @@ export function renderMap(
|
|
|
123
123
|
};
|
|
124
124
|
for (const r of layout.regions) drawRegion(gRegions, r, 0.5);
|
|
125
125
|
|
|
126
|
+
// ── Relief (mountain-range hachure over ALL land, under rivers/POIs/labels) ──
|
|
127
|
+
// Rule horizontal lines across the whole canvas, clipped to the INTERSECTION
|
|
128
|
+
// of (a) the union of range polygons and (b) the land — nested clipPaths, so
|
|
129
|
+
// the hachure never bleeds onto water (coarse range polygons overrun the
|
|
130
|
+
// coast, and horizontal lines on the sea read as the water convention). The
|
|
131
|
+
// land clip is every drawn region except lakes — INCLUDING value-/tag-coloured
|
|
132
|
+
// regions, so the relief texture sits ATOP the choropleth/tag fills (a range
|
|
133
|
+
// crossing a valued state still reads as mountains there). It stays below
|
|
134
|
+
// rivers, POIs, and labels. Explicit <line>s in a <clipPath> (not a tiled
|
|
135
|
+
// <pattern>) dodge WKWebView/resvg pattern quirks. A non-scaling stroke keeps
|
|
136
|
+
// the width constant in device px at any zoom/DPR (uniform, no moire); kept
|
|
137
|
+
// sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
|
|
138
|
+
if (layout.relief.length && layout.reliefHatch) {
|
|
139
|
+
const h = layout.reliefHatch;
|
|
140
|
+
const rangeClipId = 'dgmo-relief-clip';
|
|
141
|
+
const landClipId = 'dgmo-relief-land';
|
|
142
|
+
const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
|
|
143
|
+
for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
|
|
144
|
+
const landClip = defs.append('clipPath').attr('id', landClipId);
|
|
145
|
+
for (const r of layout.regions)
|
|
146
|
+
if (r.id !== 'lake') landClip.append('path').attr('d', r.d);
|
|
147
|
+
const gRelief = svg
|
|
148
|
+
.append('g')
|
|
149
|
+
.attr('clip-path', `url(#${landClipId})`) // outer: land only
|
|
150
|
+
.append('g')
|
|
151
|
+
.attr('class', 'dgmo-map-relief')
|
|
152
|
+
.attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
|
|
153
|
+
.attr('stroke', h.color)
|
|
154
|
+
.attr('stroke-width', h.width)
|
|
155
|
+
// Non-scaling stroke = constant device width at any zoom/DPR (uniform,
|
|
156
|
+
// no moire). NOT crispEdges — that snaps to a solid ~1px in WebKit and
|
|
157
|
+
// reads far too heavy; plain AA keeps the sub-pixel lines whisper-thin.
|
|
158
|
+
.attr('vector-effect', 'non-scaling-stroke');
|
|
159
|
+
for (let y = h.spacing; y < height; y += h.spacing) {
|
|
160
|
+
gRelief
|
|
161
|
+
.append('line')
|
|
162
|
+
.attr('x1', 0)
|
|
163
|
+
.attr('y1', y)
|
|
164
|
+
.attr('x2', width)
|
|
165
|
+
.attr('y2', y);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
126
169
|
// ── Rivers (thin water centerlines over the land, under POIs/edges) ──
|
|
127
170
|
if (layout.rivers.length) {
|
|
128
171
|
const gRivers = svg
|
|
@@ -230,6 +273,13 @@ export function renderMap(
|
|
|
230
273
|
.attr('stroke-width', 1)
|
|
231
274
|
.attr('data-line-number', poi.lineNumber)
|
|
232
275
|
.attr('data-poi', poi.id);
|
|
276
|
+
// Tag the marker per tag value (lowercased, matching the lowercased
|
|
277
|
+
// legend-entry attributes) so the app can spotlight it on legend hover.
|
|
278
|
+
if (poi.tags) {
|
|
279
|
+
for (const [group, value] of Object.entries(poi.tags)) {
|
|
280
|
+
c.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
|
|
281
|
+
}
|
|
282
|
+
}
|
|
233
283
|
if (onClickItem) {
|
|
234
284
|
c.style('cursor', 'pointer').on('click', () =>
|
|
235
285
|
onClickItem(poi.lineNumber)
|
|
@@ -296,14 +346,14 @@ export function renderMap(
|
|
|
296
346
|
.append('g')
|
|
297
347
|
.attr('class', 'dgmo-map-legend')
|
|
298
348
|
.attr('transform', `translate(0, ${legendY})`);
|
|
299
|
-
// The
|
|
349
|
+
// The value ramp is a selectable colouring group alongside the tag groups
|
|
300
350
|
// (the user flips between them); its capsule renders the gradient inline.
|
|
301
|
-
// Reserved name "
|
|
302
|
-
// in layout.ts so the resolved activeGroup selects it.
|
|
351
|
+
// Reserved name "Value" when no region-metric label is set — must match
|
|
352
|
+
// VALUE_NAME in layout.ts so the resolved activeGroup selects it.
|
|
303
353
|
const ramp = layout.legend.ramp;
|
|
304
354
|
const scoreGroup = ramp
|
|
305
355
|
? {
|
|
306
|
-
name: ramp.metric?.trim() || '
|
|
356
|
+
name: ramp.metric?.trim() || 'Value',
|
|
307
357
|
entries: [],
|
|
308
358
|
gradient: {
|
|
309
359
|
min: ramp.min,
|
|
@@ -340,6 +390,7 @@ export function renderMap(
|
|
|
340
390
|
if (layout.title) {
|
|
341
391
|
svg
|
|
342
392
|
.append('text')
|
|
393
|
+
.attr('class', 'dgmo-map-title')
|
|
343
394
|
.attr('x', width / 2)
|
|
344
395
|
.attr('y', TITLE_Y)
|
|
345
396
|
.attr('text-anchor', 'middle')
|
|
@@ -17,6 +17,10 @@ export interface MapData {
|
|
|
17
17
|
/** Major river centerlines (Natural Earth 110m) drawn as thin water lines over
|
|
18
18
|
* land — e.g. the Amazon, Nile, Mississippi. Optional, like `lakes`. */
|
|
19
19
|
rivers?: BoundaryTopology;
|
|
20
|
+
/** Notable mountain-range polygons (Natural Earth 50m geography regions) drawn
|
|
21
|
+
* as a subtle gradient relief cue over base land when the `relief` directive
|
|
22
|
+
* is on — e.g. the Rockies, Andes, Himalayas. Optional, like `lakes`. */
|
|
23
|
+
mountainRanges?: BoundaryTopology;
|
|
20
24
|
/** North-America-clipped 10m country land, used as crisp neighbour context
|
|
21
25
|
* under the albers-usa US view so Canada/Mexico match the 10m states instead
|
|
22
26
|
* of the coarser world tiers. Optional, like `lakes`. */
|
|
@@ -46,7 +50,9 @@ export interface ResolvedRegion {
|
|
|
46
50
|
readonly iso: string;
|
|
47
51
|
readonly name: string; // display name
|
|
48
52
|
readonly layer: 'country' | 'us-state';
|
|
49
|
-
readonly
|
|
53
|
+
readonly value?: number;
|
|
54
|
+
/** §1.5 trailing-token color NAME → flat override fill (§24B.4). */
|
|
55
|
+
readonly color?: string;
|
|
50
56
|
readonly tags: Readonly<Record<string, string>>;
|
|
51
57
|
readonly meta: Readonly<Record<string, string>>;
|
|
52
58
|
readonly lineNumber: number;
|
|
@@ -62,6 +68,8 @@ export interface ResolvedPoi {
|
|
|
62
68
|
readonly lat: number;
|
|
63
69
|
readonly lon: number;
|
|
64
70
|
readonly label?: string;
|
|
71
|
+
/** §1.5 trailing-token color NAME → flat marker fill (§24B.5). */
|
|
72
|
+
readonly color?: string;
|
|
65
73
|
readonly tags: Readonly<Record<string, string>>;
|
|
66
74
|
readonly meta: Readonly<Record<string, string>>;
|
|
67
75
|
readonly lineNumber: number;
|
|
@@ -79,9 +87,20 @@ export interface ResolvedEdge {
|
|
|
79
87
|
readonly lineNumber: number;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
export interface ResolvedRouteLeg {
|
|
91
|
+
readonly fromId: string;
|
|
92
|
+
readonly toId: string;
|
|
93
|
+
readonly label?: string;
|
|
94
|
+
readonly style: 'straight' | 'arc';
|
|
95
|
+
readonly value?: string; // leg thickness
|
|
96
|
+
readonly lineNumber: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
export interface ResolvedRoute {
|
|
100
|
+
/** Ordered UNIQUE stop ids (for numbering + the origin marker). A loop-closing
|
|
101
|
+
* leg whose destination is an earlier stop adds a leg but no duplicate stop. */
|
|
83
102
|
readonly stopIds: readonly string[];
|
|
84
|
-
readonly
|
|
103
|
+
readonly legs: readonly ResolvedRouteLeg[];
|
|
85
104
|
readonly lineNumber: number;
|
|
86
105
|
}
|
|
87
106
|
|