@diagrammo/dgmo 0.47.0 → 0.48.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.js +11 -11
- package/dist/auto.js +16 -16
- package/dist/auto.mjs +41 -12
- package/dist/block.js +4 -4
- package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
- package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
- package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
- package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
- package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
- package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
- package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
- package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
- package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
- package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
- package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
- package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
- package/dist/cli.cjs +15 -15
- package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
- package/dist/element.js +16 -16
- package/dist/element.mjs +41 -12
- package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
- package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
- package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
- package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
- package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
- package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
- package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
- package/package.json +23 -71
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/map/resolver.ts
DELETED
|
@@ -1,1146 +0,0 @@
|
|
|
1
|
-
// Resolver (step 3): ParsedMap + MapData → ResolvedMap. SYNCHRONOUS + PURE
|
|
2
|
-
// (deterministic, no I/O — the async asset load lives in ./load-data.ts; DI).
|
|
3
|
-
// Resolves region names → ISO geometry, POI/endpoint names → coords, infers the
|
|
4
|
-
// basemap/scope/extent/projection, and emits resolved-identity diagnostics. See
|
|
5
|
-
// §24B.2/.8/.10 and the tech-spec adversarial resolutions R1–R19.
|
|
6
|
-
import { emit, formatDgmoError, suggest } from '../diagnostics';
|
|
7
|
-
import type { DgmoError } from '../diagnostics';
|
|
8
|
-
import { MAP_DX } from './diagnostics';
|
|
9
|
-
import type { Writable } from '../utils/brand';
|
|
10
|
-
import type { ParsedMap, PoiPos } from './types';
|
|
11
|
-
import type {
|
|
12
|
-
MapData,
|
|
13
|
-
ResolvedMap,
|
|
14
|
-
ResolvedRegion,
|
|
15
|
-
ResolvedPoi,
|
|
16
|
-
ResolvedEdge,
|
|
17
|
-
ResolvedRoute,
|
|
18
|
-
ResolvedRouteLeg,
|
|
19
|
-
ProjectionFamily,
|
|
20
|
-
GeoExtent,
|
|
21
|
-
} from './resolved-types';
|
|
22
|
-
import {
|
|
23
|
-
featureIndex,
|
|
24
|
-
featureBbox,
|
|
25
|
-
featureBboxPrimary,
|
|
26
|
-
unionExtent,
|
|
27
|
-
fold,
|
|
28
|
-
decodeFeatures,
|
|
29
|
-
regionAt,
|
|
30
|
-
} from './geo';
|
|
31
|
-
|
|
32
|
-
/** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
|
|
33
|
-
* retry in pass B with inferred scope" — distinct from `miss` (errored, drop) so
|
|
34
|
-
* pass-A deferral never has to infer state from unrelated same-line diagnostics. */
|
|
35
|
-
type LookupResult =
|
|
36
|
-
| { kind: 'ok'; lat: number; lon: number; iso: string }
|
|
37
|
-
| { kind: 'defer' }
|
|
38
|
-
| { kind: 'miss' };
|
|
39
|
-
|
|
40
|
-
// Projection / tier thresholds (degrees of span) — tunable (R10).
|
|
41
|
-
const WORLD_SPAN = 90;
|
|
42
|
-
// Mercator is used for everything sub-world (tight clusters AND single-continent
|
|
43
|
-
// regional views — a mid-latitude continent reads with its familiar conventional
|
|
44
|
-
// shape). Two guards push back to the equirectangular world projection: a
|
|
45
|
-
// world/multi-continent `span` (> WORLD_SPAN), or
|
|
46
|
-
// a frame that reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's
|
|
47
|
-
// sec(φ) area blow-up turns gross. Europe (≈71°N) and East Asia stay on Mercator.
|
|
48
|
-
const MERCATOR_MAX_LAT = 80;
|
|
49
|
-
const PAD_FRACTION = 0.05;
|
|
50
|
-
// Region/choropleth maps frame to the NAMED countries/states; a tight 5% pad cuts
|
|
51
|
-
// the surrounding continent right at the data edge (a Europe choropleth stops at
|
|
52
|
-
// ~31°E, slicing off western Russia/Ukraine). A larger pad lets the neighbouring
|
|
53
|
-
// land peek in as gray context for orientation. POI maps keep the tight pad — they
|
|
54
|
-
// already get container-region framing + the zoom floor.
|
|
55
|
-
const REGION_PAD_FRACTION = 0.12;
|
|
56
|
-
// Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
|
|
57
|
-
// Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
|
|
58
|
-
// populated continents fill the frame rather than waste it on ice.
|
|
59
|
-
const WORLD_LAT_SOUTH = -58;
|
|
60
|
-
const WORLD_LAT_NORTH = 78;
|
|
61
|
-
// Tightest zoom for a POI-only cluster: never frame narrower than this many
|
|
62
|
-
// degrees on the longer axis. The basemap is state/country geometry only (no
|
|
63
|
-
// counties/cities/roads), so a metro-scale frame is an empty box — clamp up to a
|
|
64
|
-
// multi-state window that always shows coastline + a state outline or two around
|
|
65
|
-
// the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
|
|
66
|
-
// home state + neighbours rather than the whole nation. Tunable.
|
|
67
|
-
const POI_ZOOM_FLOOR_DEG = 7;
|
|
68
|
-
// POI-only container framing reveals the region(s) that CONTAIN the dots, but a
|
|
69
|
-
// single POI near the edge of a tall/wide country (e.g. Cartagena at the north
|
|
70
|
-
// tip of Colombia) would otherwise drag the frame to that country's far edge —
|
|
71
|
-
// all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
|
|
72
|
-
// union so it reveals at most `containerOvershoot(cluster)` degrees of container
|
|
73
|
-
// BEYOND the POI cluster on each side: northern Colombia stays for orientation,
|
|
74
|
-
// the empty interior is cropped.
|
|
75
|
-
//
|
|
76
|
-
// For NON-US clusters the overshoot SHRINKS as the cluster grows (tuned
|
|
77
|
-
// 2026-06-19). A tiny cluster has no context of its own, so it keeps the full MAX
|
|
78
|
-
// (8°) — enough to reveal its whole modest container (e.g. a small European
|
|
79
|
-
// country). A LARGE cluster already supplies its own context, so a fixed 8° just
|
|
80
|
-
// padded a huge container with empty land (a Ukraine/Russia strike map framed
|
|
81
|
-
// ~2.4× the cluster, a wide dead band above the dots). Linearly decaying the
|
|
82
|
-
// overshoot to MIN (3°) tightens big clusters (~1.7× cluster) without cropping
|
|
83
|
-
// small ones. The POI_ZOOM_FLOOR_DEG floor still guards the lower bound.
|
|
84
|
-
//
|
|
85
|
-
// US-ORIENTED maps are EXEMPT (keep the flat MAX): the national-vs-regional
|
|
86
|
-
// projection gate (US_NATIONAL_LON_SPAN, albers-usa vs regional Mercator) is
|
|
87
|
-
// calibrated against the 8°-overshoot frame span — a coast-to-Caribbean US cruise
|
|
88
|
-
// route clears the national threshold only because the west overshoot reaches ~8°
|
|
89
|
-
// past Denver. Shrinking it there would silently flip such maps off albers-usa.
|
|
90
|
-
// US containers (a state, CONUS) aren't the huge-empty-container problem anyway.
|
|
91
|
-
const CONTAINER_OVERSHOOT_MAX = 8;
|
|
92
|
-
const CONTAINER_OVERSHOOT_MIN = 3;
|
|
93
|
-
// Degrees of overshoot shed per degree of cluster span (larger span ⇒ less slack).
|
|
94
|
-
const CONTAINER_OVERSHOOT_DECAY = 0.3;
|
|
95
|
-
|
|
96
|
-
/** Per-cluster container overshoot (deg). US-oriented maps keep the flat MAX (the
|
|
97
|
-
* albers-usa national gate is calibrated to it); other maps get full MAX for a
|
|
98
|
-
* tight cluster, decaying to MIN for a large one. `span` = the cluster's larger
|
|
99
|
-
* lon/lat extent. */
|
|
100
|
-
function containerOvershoot(span: number, usOriented: boolean): number {
|
|
101
|
-
if (usOriented) return CONTAINER_OVERSHOOT_MAX;
|
|
102
|
-
return Math.max(
|
|
103
|
-
CONTAINER_OVERSHOOT_MIN,
|
|
104
|
-
Math.min(
|
|
105
|
-
CONTAINER_OVERSHOOT_MAX,
|
|
106
|
-
CONTAINER_OVERSHOOT_MAX - CONTAINER_OVERSHOOT_DECAY * span
|
|
107
|
-
)
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
// Above this longitudinal span a US POI-only extent is "national" — use the
|
|
111
|
-
// albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
|
|
112
|
-
// CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
|
|
113
|
-
const US_NATIONAL_LON_SPAN = 48;
|
|
114
|
-
// Sub-national US auto-zoom (map-us-subnational-zoom). A US region/choropleth map
|
|
115
|
-
// whose authored data occupies a geographically COMPACT slice of the country
|
|
116
|
-
// zooms to that slice (conic-equal-area, like every non-US regional view) instead
|
|
117
|
-
// of always framing the whole nation on albers-usa. "Compact" = the raw data bbox
|
|
118
|
-
// covers less than US_SUBNATIONAL_AREA_FRACTION of the CONUS bbox AREA. Area (not
|
|
119
|
-
// lon-span) is used so a near-national diagonal (e.g. WA+FL) stays national while
|
|
120
|
-
// a tight cluster (the Northeast) or a tall corridor zooms. CONUS_BBOX ≈ the
|
|
121
|
-
// contiguous 48 (≈1416 deg²); the fraction bisects the empty gap between a
|
|
122
|
-
// regional cluster (≲0.15) and a national map (≳0.9). Tunable.
|
|
123
|
-
const CONUS_BBOX: GeoExtent = [
|
|
124
|
-
[-125, 25],
|
|
125
|
-
[-66, 49],
|
|
126
|
-
];
|
|
127
|
-
const US_SUBNATIONAL_AREA_FRACTION = 0.4;
|
|
128
|
-
|
|
129
|
-
/** Rectangular (lon×lat) area of a bbox in deg² — NOT d3 `geoArea` (spherical).
|
|
130
|
-
* The gate frames with a lon/lat bbox anyway, so a planar ratio is the honest
|
|
131
|
-
* measure of "how much of the country does this occupy". */
|
|
132
|
-
function bboxArea(b: GeoExtent): number {
|
|
133
|
-
return (b[1][0] - b[0][0]) * (b[1][1] - b[0][1]);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** True when a US data extent is compact enough to zoom to (sub-national) rather
|
|
137
|
-
* than frame the whole nation. Pure + side-effect-free (unit-tested in isolation
|
|
138
|
-
* with no fixtures/render). Pass the RAW unpadded data-union bbox, not the padded
|
|
139
|
-
* framing extent — the fraction is calibrated on raw data bboxes. */
|
|
140
|
-
export function isSubNationalUsExtent(bbox: GeoExtent | null): boolean {
|
|
141
|
-
if (!bbox) return false;
|
|
142
|
-
return bboxArea(bbox) / bboxArea(CONUS_BBOX) < US_SUBNATIONAL_AREA_FRACTION;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Long-form (or common-alias) country name → the folded Natural-Earth display
|
|
146
|
-
// name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
|
|
147
|
-
// handful of names ("Dem. Rep. Congo", "W. Sahara", …) that a user would never
|
|
148
|
-
// type; this rescues them. ISO-code matching (featureIndex id keys) covers the
|
|
149
|
-
// rest. Keys/values are pre-folded (lowercase, diacritics stripped).
|
|
150
|
-
const REGION_ALIASES: Readonly<Record<string, string>> = {
|
|
151
|
-
// Common everyday names → the Natural-Earth display name actually shipped.
|
|
152
|
-
'united states': 'united states of america',
|
|
153
|
-
usa: 'united states of america',
|
|
154
|
-
america: 'united states of america',
|
|
155
|
-
uk: 'united kingdom',
|
|
156
|
-
'western sahara': 'w. sahara',
|
|
157
|
-
'democratic republic of the congo': 'dem. rep. congo',
|
|
158
|
-
'dr congo': 'dem. rep. congo',
|
|
159
|
-
drc: 'dem. rep. congo',
|
|
160
|
-
'dominican republic': 'dominican rep.',
|
|
161
|
-
'falkland islands': 'falkland is.',
|
|
162
|
-
'ivory coast': "cote d'ivoire",
|
|
163
|
-
'central african republic': 'central african rep.',
|
|
164
|
-
'equatorial guinea': 'eq. guinea',
|
|
165
|
-
'solomon islands': 'solomon is.',
|
|
166
|
-
'bosnia and herzegovina': 'bosnia and herz.',
|
|
167
|
-
'south sudan': 's. sudan',
|
|
168
|
-
'north macedonia': 'macedonia',
|
|
169
|
-
'czech republic': 'czechia',
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
// US state (+ DC) postal abbreviations. A bare trailing two-letter scope token
|
|
173
|
-
// that is one of these resolves to the US STATE `US-XX` (not the colliding
|
|
174
|
-
// ISO 3166-1 country code — e.g. `CA` = California, not Canada) and signals US
|
|
175
|
-
// scope (§24B.8, 2026-06-01). Non-state two-letter tokens (`CR`, `FR`) stay
|
|
176
|
-
// country codes. Trade-off: a foreign country whose code collides with a state
|
|
177
|
-
// (DE/IN/AR/CO/LA/PA/…) can't be picked by bare code — use the city name alone
|
|
178
|
-
// (most-populous) or coordinates.
|
|
179
|
-
const US_STATE_POSTAL: ReadonlySet<string> = new Set([
|
|
180
|
-
'AL',
|
|
181
|
-
'AK',
|
|
182
|
-
'AZ',
|
|
183
|
-
'AR',
|
|
184
|
-
'CA',
|
|
185
|
-
'CO',
|
|
186
|
-
'CT',
|
|
187
|
-
'DE',
|
|
188
|
-
'FL',
|
|
189
|
-
'GA',
|
|
190
|
-
'HI',
|
|
191
|
-
'ID',
|
|
192
|
-
'IL',
|
|
193
|
-
'IN',
|
|
194
|
-
'IA',
|
|
195
|
-
'KS',
|
|
196
|
-
'KY',
|
|
197
|
-
'LA',
|
|
198
|
-
'ME',
|
|
199
|
-
'MD',
|
|
200
|
-
'MA',
|
|
201
|
-
'MI',
|
|
202
|
-
'MN',
|
|
203
|
-
'MS',
|
|
204
|
-
'MO',
|
|
205
|
-
'MT',
|
|
206
|
-
'NE',
|
|
207
|
-
'NV',
|
|
208
|
-
'NH',
|
|
209
|
-
'NJ',
|
|
210
|
-
'NM',
|
|
211
|
-
'NY',
|
|
212
|
-
'NC',
|
|
213
|
-
'ND',
|
|
214
|
-
'OH',
|
|
215
|
-
'OK',
|
|
216
|
-
'OR',
|
|
217
|
-
'PA',
|
|
218
|
-
'RI',
|
|
219
|
-
'SC',
|
|
220
|
-
'SD',
|
|
221
|
-
'TN',
|
|
222
|
-
'TX',
|
|
223
|
-
'UT',
|
|
224
|
-
'VT',
|
|
225
|
-
'VA',
|
|
226
|
-
'WA',
|
|
227
|
-
'WV',
|
|
228
|
-
'WI',
|
|
229
|
-
'WY',
|
|
230
|
-
'DC',
|
|
231
|
-
]);
|
|
232
|
-
|
|
233
|
-
/** A bare two-letter scope token that names a US state → its `US-XX` 3166-2 id
|
|
234
|
-
* (`OR` → `US-OR`), else null. Case-insensitive. */
|
|
235
|
-
function usStateFromBareScope(scope: string | undefined): string | null {
|
|
236
|
-
if (!scope) return null;
|
|
237
|
-
const up = scope.toUpperCase();
|
|
238
|
-
return US_STATE_POSTAL.has(up) ? `US-${up}` : null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Rough US bounding box (incl. AK across the dateline, HI, PR) for classifying
|
|
242
|
-
* bare coordinate POIs as US-or-not when deciding `albers-usa` (#13). */
|
|
243
|
-
function looksUS(lat: number, lon: number): boolean {
|
|
244
|
-
if (lat < 15 || lat > 72) return false;
|
|
245
|
-
// continental + AK + HI + Caribbean territories: lon in [-180, -64];
|
|
246
|
-
// Aleutians wrap past the antimeridian to ~+172.
|
|
247
|
-
return (lon >= -180 && lon <= -64) || lon >= 172;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Classifies a bare-coordinate POI as a North-American neighbour (vs a far-away
|
|
251
|
-
* blocker) when deciding `albers-usa`. Used only for bare coords — named POIs
|
|
252
|
-
* carry an ISO. This deliberately only covers what `looksUS`'s broad box does
|
|
253
|
-
* NOT: the Atlantic-Canada strip east of −64° (Newfoundland/Labrador/Nova
|
|
254
|
-
* Scotia, e.g. St. John's at −52.7) which falls outside `looksUS`'s
|
|
255
|
-
* [−180, −64] longitude window. Most of Canada and all of Mexico already pass
|
|
256
|
-
* `looksUS` (their lon is ≤ −64, lat ≥ 15) so they never reach here. The
|
|
257
|
-
* latitude is capped at 72° (matching `looksUS`) so a high-Arctic coord is NOT
|
|
258
|
-
* treated as a neighbour — that would both distort the conus fit and collide
|
|
259
|
-
* with the Alaska inset bbox (`inAlaska`, lat ≥ 51 ∧ lon ≤ −129). NOTE: the
|
|
260
|
-
* east strip can also catch the SE-Greenland coast (rare, acceptable; named
|
|
261
|
-
* GL/DK POIs are unaffected since they classify by ISO). */
|
|
262
|
-
function looksNorthAmericaNeighbor(lat: number, lon: number): boolean {
|
|
263
|
-
return lat >= 14 && lat <= 72 && lon >= -141 && lon <= -52;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** A bbox that (near-)spans the whole globe — the sentinel spherical geoBounds
|
|
267
|
-
* returns for a malformed/missing feature. Such a box is useless for framing. */
|
|
268
|
-
function isWholeSphere(bb: GeoExtent): boolean {
|
|
269
|
-
return (
|
|
270
|
-
bb[0][0] <= -179 && bb[1][0] >= 179 && bb[0][1] <= -89 && bb[1][1] >= 89
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
275
|
-
const diagnostics: DgmoError[] = [...parsed.diagnostics]; // seed with parse diags (R14)
|
|
276
|
-
// Folded tokens already flagged as "city shadows an airport code" — emit the
|
|
277
|
-
// W_MAP_AIRPORT_SHADOWED_BY_CITY hint at most once per code (ADR-2, F9).
|
|
278
|
-
const shadowedAirports = new Set<string>();
|
|
279
|
-
|
|
280
|
-
const result: Writable<ResolvedMap> = {
|
|
281
|
-
title: parsed.title,
|
|
282
|
-
...(parsed.directives.caption !== undefined && {
|
|
283
|
-
caption: parsed.directives.caption,
|
|
284
|
-
}),
|
|
285
|
-
tagGroups: [...parsed.tagGroups],
|
|
286
|
-
// Shallow-copy so the resolved model never aliases the parser's mutable
|
|
287
|
-
// directives object (#11). NOTE: tag→region-fill COLOR binding is the
|
|
288
|
-
// renderer's job (step 4) — the resolver only carries `tags` + `tagGroups`
|
|
289
|
-
// through; it never resolves a tag value to a palette color (#10).
|
|
290
|
-
directives: { ...parsed.directives },
|
|
291
|
-
basemaps: { world: 'detail', subdivisions: [] },
|
|
292
|
-
regions: [],
|
|
293
|
-
pois: [],
|
|
294
|
-
edges: [],
|
|
295
|
-
routes: [],
|
|
296
|
-
extent: [
|
|
297
|
-
[-180, -85],
|
|
298
|
-
[180, 85],
|
|
299
|
-
] as GeoExtent,
|
|
300
|
-
projection: 'equirectangular',
|
|
301
|
-
poiFrameContainers: [],
|
|
302
|
-
diagnostics,
|
|
303
|
-
error: parsed.error,
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
// Per-layer indexes (never merged — R12; coarse is the authoritative name
|
|
307
|
-
// index, ids shared with detail — R13). LOAD-BEARING: `worldCoarse` (110m) is
|
|
308
|
-
// NOT used for rendering anymore (the world basemap is pinned to detail/50m,
|
|
309
|
-
// see basemaps assignment below) — it is retained solely as this name index
|
|
310
|
-
// (featureIndex) and the dominant-landmass bbox source (featureBboxPrimary).
|
|
311
|
-
// Do not delete it.
|
|
312
|
-
const countryIndex = featureIndex(data.worldCoarse);
|
|
313
|
-
const usStateIndex = featureIndex(data.usStates);
|
|
314
|
-
const allNames = [
|
|
315
|
-
...[...countryIndex.values()].map((v) => v.name),
|
|
316
|
-
...[...usStateIndex.values()].map((v) => v.name),
|
|
317
|
-
];
|
|
318
|
-
|
|
319
|
-
// ── locale <ISO> → country + optional subdivision (§24B.8) ──
|
|
320
|
-
const localeRaw = parsed.directives.locale?.toUpperCase();
|
|
321
|
-
const localeCountry = localeRaw ? localeRaw.split('-')[0] : undefined;
|
|
322
|
-
const localeSubdivision =
|
|
323
|
-
localeRaw && /^[A-Z]{2}-/.test(localeRaw) ? localeRaw : undefined;
|
|
324
|
-
|
|
325
|
-
// ── US-scope signal (drives the country-vs-state collision, R2) ──
|
|
326
|
-
const usScoped =
|
|
327
|
-
localeCountry === 'US' ||
|
|
328
|
-
parsed.regions.some((r) => {
|
|
329
|
-
const f = fold(r.name);
|
|
330
|
-
return usStateIndex.has(f) && !countryIndex.has(f);
|
|
331
|
-
}) ||
|
|
332
|
-
parsed.regions.some(
|
|
333
|
-
(r) =>
|
|
334
|
-
r.scope === 'US' ||
|
|
335
|
-
r.scope?.startsWith('US-') ||
|
|
336
|
-
usStateFromBareScope(r.scope) !== null
|
|
337
|
-
) ||
|
|
338
|
-
parsed.pois.some(
|
|
339
|
-
(p) =>
|
|
340
|
-
p.pos.kind === 'name' &&
|
|
341
|
-
(p.pos.scope?.startsWith('US-') ||
|
|
342
|
-
usStateFromBareScope(p.pos.scope) !== null)
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
// ── Regions (R2/R12) ──
|
|
346
|
-
const regions: ResolvedRegion[] = [];
|
|
347
|
-
const seenRegion = new Map<string, number>(); // iso → index in regions
|
|
348
|
-
let usSubdivisionReferenced = false;
|
|
349
|
-
// AK/HI national guard (map-us-subnational-zoom): a choropleth that colors
|
|
350
|
-
// Alaska or Hawaii is inherently a national composite map (the insets only make
|
|
351
|
-
// sense on albers-usa), so it never sub-national auto-zooms. Tracked ISO-based
|
|
352
|
-
// here because `referencedRegionIds` is not in scope at the projection block.
|
|
353
|
-
let hasAlaskaRef = false;
|
|
354
|
-
let hasHawaiiRef = false;
|
|
355
|
-
const referencedRegionIds: { topo: 'us'; id: string }[] = [];
|
|
356
|
-
for (const r of parsed.regions) {
|
|
357
|
-
const f = fold(r.name);
|
|
358
|
-
// Country match: folded name, then ISO-code key (featureIndex id keys), then
|
|
359
|
-
// the long-form→NE-abbrev alias table (#6).
|
|
360
|
-
const inCountry =
|
|
361
|
-
countryIndex.get(f) ??
|
|
362
|
-
(REGION_ALIASES[f] ? countryIndex.get(REGION_ALIASES[f]!) : undefined);
|
|
363
|
-
const inState = usStateIndex.get(f);
|
|
364
|
-
let chosen: {
|
|
365
|
-
id: string;
|
|
366
|
-
name: string;
|
|
367
|
-
layer: 'country' | 'us-state';
|
|
368
|
-
} | null = null;
|
|
369
|
-
// Explicit ISO scope (§24B.8): force the country-vs-state pick and skip the
|
|
370
|
-
// ambiguity warning. `US`/`US-XX` → state; any other 2-letter code → country.
|
|
371
|
-
const scope = r.scope;
|
|
372
|
-
if (scope) {
|
|
373
|
-
const wantsState = scope === 'US' || scope.startsWith('US-');
|
|
374
|
-
if (wantsState && inState) {
|
|
375
|
-
if (scope.startsWith('US-') && inState.id !== scope) {
|
|
376
|
-
diagnostics.push(
|
|
377
|
-
emit(MAP_DX.SCOPE_MISS, r.lineNumber, {
|
|
378
|
-
name: r.name,
|
|
379
|
-
scope,
|
|
380
|
-
iso: inState.id,
|
|
381
|
-
})
|
|
382
|
-
);
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
chosen = { ...inState, layer: 'us-state' };
|
|
386
|
-
} else if (!wantsState && inCountry) {
|
|
387
|
-
chosen = { ...inCountry, layer: 'country' };
|
|
388
|
-
} else {
|
|
389
|
-
diagnostics.push(
|
|
390
|
-
emit(MAP_DX.SCOPE_MISS, r.lineNumber, { name: r.name, scope })
|
|
391
|
-
);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
} else if (inCountry && inState) {
|
|
395
|
-
if (usScoped) {
|
|
396
|
-
// A US scope (e.g. `locale US`) makes the state the unambiguous
|
|
397
|
-
// intent — resolve silently, no disambiguation warning needed.
|
|
398
|
-
chosen = { ...inState, layer: 'us-state' };
|
|
399
|
-
} else {
|
|
400
|
-
chosen = { ...inCountry, layer: 'country' };
|
|
401
|
-
// Teach the disambiguation syntax so the author can pin it explicitly.
|
|
402
|
-
// Suggest the non-redundant forms: a bare ISO code, or name + scope.
|
|
403
|
-
diagnostics.push(
|
|
404
|
-
emit(MAP_DX.REGION_AMBIGUOUS, r.lineNumber, {
|
|
405
|
-
name: r.name,
|
|
406
|
-
layer: chosen.layer,
|
|
407
|
-
chosenId: chosen.id,
|
|
408
|
-
stateId: inState.id,
|
|
409
|
-
countryId: inCountry.id,
|
|
410
|
-
})
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
} else if (inState) {
|
|
414
|
-
chosen = { ...inState, layer: 'us-state' };
|
|
415
|
-
} else if (inCountry) {
|
|
416
|
-
chosen = { ...inCountry, layer: 'country' };
|
|
417
|
-
}
|
|
418
|
-
if (!chosen) {
|
|
419
|
-
const hint = suggest(r.name, allNames);
|
|
420
|
-
diagnostics.push(
|
|
421
|
-
emit(MAP_DX.UNKNOWN_SUBDIVISION, r.lineNumber, { name: r.name, hint })
|
|
422
|
-
);
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
if (chosen.layer === 'us-state') {
|
|
426
|
-
usSubdivisionReferenced = true;
|
|
427
|
-
if (chosen.id === 'US-AK') hasAlaskaRef = true;
|
|
428
|
-
if (chosen.id === 'US-HI') hasHawaiiRef = true;
|
|
429
|
-
referencedRegionIds.push({ topo: 'us', id: chosen.id });
|
|
430
|
-
}
|
|
431
|
-
const resolved: ResolvedRegion = {
|
|
432
|
-
iso: chosen.id,
|
|
433
|
-
name: chosen.name,
|
|
434
|
-
layer: chosen.layer,
|
|
435
|
-
...(r.value !== undefined && { value: r.value }),
|
|
436
|
-
...(r.color !== undefined && { color: r.color }),
|
|
437
|
-
tags: r.tags,
|
|
438
|
-
meta: r.meta,
|
|
439
|
-
lineNumber: r.lineNumber,
|
|
440
|
-
};
|
|
441
|
-
const prev = seenRegion.get(chosen.id);
|
|
442
|
-
if (prev !== undefined) {
|
|
443
|
-
diagnostics.push(
|
|
444
|
-
emit(MAP_DX.DUPLICATE_REGION, r.lineNumber, { name: chosen.name })
|
|
445
|
-
);
|
|
446
|
-
regions[prev] = resolved;
|
|
447
|
-
} else {
|
|
448
|
-
seenRegion.set(chosen.id, regions.length);
|
|
449
|
-
regions.push(resolved);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ── POIs: two-pass (R9) ──
|
|
454
|
-
const registry = new Map<string, Writable<ResolvedPoi>>();
|
|
455
|
-
const pois: Writable<ResolvedPoi>[] = [];
|
|
456
|
-
|
|
457
|
-
const registerPoi = (
|
|
458
|
-
id: string,
|
|
459
|
-
poi: Writable<ResolvedPoi>,
|
|
460
|
-
line: number
|
|
461
|
-
): void => {
|
|
462
|
-
const existing = registry.get(id);
|
|
463
|
-
if (existing) {
|
|
464
|
-
// An implicit endpoint POI must never clobber an explicitly declared one
|
|
465
|
-
// (#8). resolveEndpoint already guards with `registry.has`, but keep the
|
|
466
|
-
// invariant local to registration so no caller can violate it.
|
|
467
|
-
if (poi.implicit && !existing.implicit) return;
|
|
468
|
-
// Only a declared-over-declared collision is a user-facing duplicate.
|
|
469
|
-
if (!poi.implicit && !existing.implicit) {
|
|
470
|
-
diagnostics.push(emit(MAP_DX.DUPLICATE_POI, line, { id }));
|
|
471
|
-
}
|
|
472
|
-
const idx = pois.indexOf(existing);
|
|
473
|
-
if (idx >= 0) pois[idx] = poi;
|
|
474
|
-
} else {
|
|
475
|
-
pois.push(poi);
|
|
476
|
-
}
|
|
477
|
-
registry.set(id, poi);
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
/** Resolve a name (+optional scope) against the gazetteer (#5 discriminated). */
|
|
481
|
-
const lookupName = (
|
|
482
|
-
name: string,
|
|
483
|
-
scope: string | undefined,
|
|
484
|
-
line: number,
|
|
485
|
-
scopeHint: string | undefined,
|
|
486
|
-
allowAmbiguous: boolean
|
|
487
|
-
): LookupResult => {
|
|
488
|
-
const f = fold(name);
|
|
489
|
-
let idxs = data.gazetteer.byName[f];
|
|
490
|
-
if (!idxs?.length) {
|
|
491
|
-
const aliasIdx = data.gazetteer.alt[f];
|
|
492
|
-
if (aliasIdx !== undefined) idxs = [aliasIdx];
|
|
493
|
-
}
|
|
494
|
-
if (!idxs?.length) {
|
|
495
|
-
// Airports are the LOWEST-precedence identifier tier (ADR-2): consulted
|
|
496
|
-
// only after city `byName` + `alt` miss, so a real city always wins a
|
|
497
|
-
// shared token. Exact folded-code match — never defers. Returns just
|
|
498
|
-
// {lat,lon,iso} like a city; the POI renders the user's typed token ("JFK")
|
|
499
|
-
// as its label, so there is no airport-specific label work.
|
|
500
|
-
const airIdx = data.airports?.airportIata?.[f];
|
|
501
|
-
if (airIdx !== undefined) {
|
|
502
|
-
const a = data.airports!.airports[airIdx];
|
|
503
|
-
if (a) return { kind: 'ok', lat: a[0], lon: a[1], iso: a[2] };
|
|
504
|
-
}
|
|
505
|
-
// A 3-letter token that resolved to neither a city nor a bundled airport is
|
|
506
|
-
// almost always an airport code the set doesn't cover — give the targeted
|
|
507
|
-
// hint (with the `as XXX` coords escape hatch) instead of the generic miss.
|
|
508
|
-
if (/^[A-Za-z]{3}$/.test(name)) {
|
|
509
|
-
const code = name.toUpperCase();
|
|
510
|
-
diagnostics.push(emit(MAP_DX.UNKNOWN_AIRPORT_CODE, line, { code }));
|
|
511
|
-
return { kind: 'miss' };
|
|
512
|
-
}
|
|
513
|
-
const cityNames = data.gazetteer.cities.map((c) => c[4]);
|
|
514
|
-
const hint = suggest(name, cityNames);
|
|
515
|
-
diagnostics.push(emit(MAP_DX.UNKNOWN_PLACE, line, { name, hint }));
|
|
516
|
-
return { kind: 'miss' };
|
|
517
|
-
}
|
|
518
|
-
let cands = idxs.map((i) => data.gazetteer.cities[i]!);
|
|
519
|
-
const scopeUse = scope ?? scopeHint;
|
|
520
|
-
if (scopeUse) {
|
|
521
|
-
// ISO 3166-2 subdivision scope is `XX-…` (two letters + dash). A bare
|
|
522
|
-
// two-letter token that names a US state resolves as the `US-XX`
|
|
523
|
-
// subdivision (`CA` = California, §24B.8) — NOT the colliding country
|
|
524
|
-
// code; any other bare two-letter token is a 3166-1 country code.
|
|
525
|
-
const bareState = usStateFromBareScope(scopeUse);
|
|
526
|
-
const subScope = /^[A-Za-z]{2}-/.test(scopeUse) ? scopeUse : bareState;
|
|
527
|
-
const filtered = cands.filter((c) =>
|
|
528
|
-
subScope ? c[5] === subScope : c[2] === scopeUse
|
|
529
|
-
);
|
|
530
|
-
if (filtered.length) cands = filtered;
|
|
531
|
-
else if (scope) {
|
|
532
|
-
diagnostics.push(
|
|
533
|
-
emit(MAP_DX.SCOPE_MISS, line, { name, scope, place: true })
|
|
534
|
-
);
|
|
535
|
-
return { kind: 'miss' };
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (cands.length > 1) {
|
|
539
|
-
if (!allowAmbiguous && !scopeUse) {
|
|
540
|
-
return { kind: 'defer' }; // ambiguous, no scope → pass B
|
|
541
|
-
}
|
|
542
|
-
// most-populous; tie-break lowest index (R11 — byName is NOT pop-ordered).
|
|
543
|
-
cands = [...cands].sort((a, b) => b[3] - a[3]);
|
|
544
|
-
if (!scope) diagnostics.push(emit(MAP_DX.AMBIGUOUS_NAME, line, { name }));
|
|
545
|
-
}
|
|
546
|
-
const c = cands[0]!;
|
|
547
|
-
// Shadow hint (ADR-2/F9): the token resolved to a city but is ALSO a bundled
|
|
548
|
-
// IATA code (e.g. `Ufa` city vs UFA airport). Non-blocking, once per code, and
|
|
549
|
-
// only for the `byName` overlap set (an `alt`-alias hit whose key differs from
|
|
550
|
-
// the IATA fold is not flagged — a documented limit).
|
|
551
|
-
if (
|
|
552
|
-
data.airports?.airportIata?.[f] !== undefined &&
|
|
553
|
-
!shadowedAirports.has(f)
|
|
554
|
-
) {
|
|
555
|
-
shadowedAirports.add(f);
|
|
556
|
-
const code = name.toUpperCase();
|
|
557
|
-
diagnostics.push(
|
|
558
|
-
emit(MAP_DX.AIRPORT_SHADOWED_BY_CITY, line, { name, code })
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
return { kind: 'ok', lat: c[0], lon: c[1], iso: c[2] };
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
const poiIdFor = (pos: PoiPos, alias: string | undefined): string => {
|
|
565
|
-
if (alias) return fold(alias);
|
|
566
|
-
if (pos.kind === 'coords') return `@${pos.lat},${pos.lon}`;
|
|
567
|
-
return fold(pos.name);
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
// POI-country tally, fed to default-country inference (#3) and the US-dominant
|
|
571
|
-
// projection test (#13). Named POIs contribute their gazetteer ISO; bare-coord
|
|
572
|
-
// POIs contribute a rough US-or-not classification (no reverse-geocode).
|
|
573
|
-
const poiCountries: string[] = [];
|
|
574
|
-
let anyUsPoi = false;
|
|
575
|
-
// `anyNonNaPoi` = a POI outside North America (not US/CA/MX). Gates the relaxed
|
|
576
|
-
// US-orientation test (#13): US + Canada/Mexico content keeps `albers-usa`, but
|
|
577
|
-
// anything beyond NA forces a geographic projection that frames all content.
|
|
578
|
-
let anyNonNaPoi = false;
|
|
579
|
-
const noteCountry = (iso: string | undefined): void => {
|
|
580
|
-
if (iso) {
|
|
581
|
-
poiCountries.push(iso);
|
|
582
|
-
if (iso === 'US') anyUsPoi = true;
|
|
583
|
-
if (iso !== 'US' && iso !== 'CA' && iso !== 'MX') anyNonNaPoi = true;
|
|
584
|
-
}
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
// Pass A: coords + scoped + single-candidate (unambiguous). Defer ambiguous.
|
|
588
|
-
const deferred: (typeof parsed.pois)[number][] = [];
|
|
589
|
-
for (const p of parsed.pois) {
|
|
590
|
-
if (p.pos.kind === 'coords') {
|
|
591
|
-
if (looksUS(p.pos.lat, p.pos.lon)) anyUsPoi = true;
|
|
592
|
-
else if (!looksNorthAmericaNeighbor(p.pos.lat, p.pos.lon))
|
|
593
|
-
anyNonNaPoi = true;
|
|
594
|
-
addResolvedPoi(p.pos.lat, p.pos.lon, p);
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
const got = lookupName(
|
|
598
|
-
p.pos.name,
|
|
599
|
-
p.pos.scope,
|
|
600
|
-
p.lineNumber,
|
|
601
|
-
undefined,
|
|
602
|
-
false
|
|
603
|
-
);
|
|
604
|
-
if (got.kind === 'ok') {
|
|
605
|
-
noteCountry(got.iso);
|
|
606
|
-
addResolvedPoi(got.lat, got.lon, p);
|
|
607
|
-
} else if (got.kind === 'defer') {
|
|
608
|
-
deferred.push(p); // ambiguous, not an error → pass B
|
|
609
|
-
}
|
|
610
|
-
// `miss` already errored; drop.
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Infer default-country from explicit directive or the most common ISO across
|
|
614
|
-
// resolved regions AND Pass-A POIs (#3 — POIs were previously voided, so a
|
|
615
|
-
// POI-only US map never inferred US).
|
|
616
|
-
const inferredCountry =
|
|
617
|
-
localeCountry ?? mostCommonCountry(regions, poiCountries) ?? undefined;
|
|
618
|
-
// A `locale US-GA` subdivision further scopes ambiguous bare cities to that
|
|
619
|
-
// state (soft preference — see lookupName); else fall back to the country.
|
|
620
|
-
const inferredScope = localeSubdivision ?? inferredCountry;
|
|
621
|
-
|
|
622
|
-
// Pass B: ambiguous bare names, scoped by the inferred locale.
|
|
623
|
-
for (const p of deferred) {
|
|
624
|
-
if (p.pos.kind !== 'name') continue;
|
|
625
|
-
const got = lookupName(
|
|
626
|
-
p.pos.name,
|
|
627
|
-
p.pos.scope,
|
|
628
|
-
p.lineNumber,
|
|
629
|
-
inferredScope,
|
|
630
|
-
true
|
|
631
|
-
);
|
|
632
|
-
if (got.kind === 'ok') {
|
|
633
|
-
noteCountry(got.iso);
|
|
634
|
-
addResolvedPoi(got.lat, got.lon, p);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function addResolvedPoi(
|
|
639
|
-
lat: number,
|
|
640
|
-
lon: number,
|
|
641
|
-
p: (typeof parsed.pois)[number]
|
|
642
|
-
): void {
|
|
643
|
-
const id = poiIdFor(p.pos, p.alias);
|
|
644
|
-
const name = p.pos.kind === 'name' ? p.pos.name : p.alias;
|
|
645
|
-
const poi: Writable<ResolvedPoi> = {
|
|
646
|
-
id,
|
|
647
|
-
...(name !== undefined && { name }),
|
|
648
|
-
lat,
|
|
649
|
-
lon,
|
|
650
|
-
...(p.label !== undefined && { label: p.label }),
|
|
651
|
-
...(p.color !== undefined && { color: p.color }),
|
|
652
|
-
tags: p.tags,
|
|
653
|
-
meta: p.meta,
|
|
654
|
-
lineNumber: p.lineNumber,
|
|
655
|
-
};
|
|
656
|
-
registerPoi(id, poi, p.lineNumber);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// ── Edges + routes: bind endpoints, create implicit POIs (R7/R8) ──
|
|
660
|
-
// A POI declared with an alias (`poi Chicago as central`) registers under its
|
|
661
|
-
// id (`central`), so an endpoint that references it by NAME (`… -> Chicago`)
|
|
662
|
-
// misses the id registry. Map declared names → id so such a reference binds to
|
|
663
|
-
// the existing POI instead of spawning a duplicate implicit one on the same
|
|
664
|
-
// spot (only aliased POIs need this — a name-only POI's id already IS its
|
|
665
|
-
// folded name).
|
|
666
|
-
const declaredByName = new Map<string, string>();
|
|
667
|
-
for (const p of pois) {
|
|
668
|
-
const fn = p.name ? fold(p.name) : undefined;
|
|
669
|
-
if (fn && fn !== p.id && !declaredByName.has(fn))
|
|
670
|
-
declaredByName.set(fn, p.id);
|
|
671
|
-
}
|
|
672
|
-
const resolveEndpoint = (ref: string, line: number): string | null => {
|
|
673
|
-
const f = fold(ref);
|
|
674
|
-
if (registry.has(f)) return f;
|
|
675
|
-
const aliased = declaredByName.get(f);
|
|
676
|
-
if (aliased) return aliased;
|
|
677
|
-
const got = lookupName(ref, undefined, line, inferredCountry, true);
|
|
678
|
-
if (got.kind !== 'ok') return null;
|
|
679
|
-
noteCountry(got.iso);
|
|
680
|
-
const poi: Writable<ResolvedPoi> = {
|
|
681
|
-
id: f,
|
|
682
|
-
name: ref,
|
|
683
|
-
lat: got.lat,
|
|
684
|
-
lon: got.lon,
|
|
685
|
-
tags: {},
|
|
686
|
-
meta: {},
|
|
687
|
-
lineNumber: line,
|
|
688
|
-
implicit: true,
|
|
689
|
-
};
|
|
690
|
-
registerPoi(f, poi, line);
|
|
691
|
-
return f;
|
|
692
|
-
};
|
|
693
|
-
|
|
694
|
-
const edges: ResolvedEdge[] = [];
|
|
695
|
-
for (const e of parsed.edges) {
|
|
696
|
-
const fromId = resolveEndpoint(e.from, e.lineNumber);
|
|
697
|
-
const toId = resolveEndpoint(e.to, e.lineNumber);
|
|
698
|
-
if (!fromId || !toId) continue; // ungeocodable endpoint → drop (error already pushed)
|
|
699
|
-
edges.push({
|
|
700
|
-
fromId,
|
|
701
|
-
toId,
|
|
702
|
-
...(e.label !== undefined && { label: e.label }),
|
|
703
|
-
directed: e.directed,
|
|
704
|
-
style: e.style,
|
|
705
|
-
meta: e.meta,
|
|
706
|
-
tags: e.tags,
|
|
707
|
-
lineNumber: e.lineNumber,
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Resolve a route stop to a POI id, registering it (with its inline tags/label/
|
|
712
|
-
// size value) or binding to an already-declared POI. Returns null if a named
|
|
713
|
-
// stop can't be geocoded. Unlike the old code, stop metadata is NO LONGER
|
|
714
|
-
// dropped — it rides onto the created POI (fixes the named-stop meta bug).
|
|
715
|
-
const resolveStop = (
|
|
716
|
-
pos: PoiPos,
|
|
717
|
-
alias: string | undefined,
|
|
718
|
-
label: string | undefined,
|
|
719
|
-
tags: Readonly<Record<string, string>>,
|
|
720
|
-
sizeValue: string | undefined,
|
|
721
|
-
line: number
|
|
722
|
-
): string | null => {
|
|
723
|
-
const meta: Record<string, string> =
|
|
724
|
-
sizeValue !== undefined ? { value: sizeValue } : {};
|
|
725
|
-
if (pos.kind === 'coords') {
|
|
726
|
-
const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
|
|
727
|
-
if (looksUS(pos.lat, pos.lon)) anyUsPoi = true;
|
|
728
|
-
else if (!looksNorthAmericaNeighbor(pos.lat, pos.lon)) anyNonNaPoi = true;
|
|
729
|
-
if (!registry.has(id)) {
|
|
730
|
-
registerPoi(
|
|
731
|
-
id,
|
|
732
|
-
{
|
|
733
|
-
id,
|
|
734
|
-
...(alias !== undefined && { name: alias }),
|
|
735
|
-
lat: pos.lat,
|
|
736
|
-
lon: pos.lon,
|
|
737
|
-
...(label !== undefined && { label }),
|
|
738
|
-
tags,
|
|
739
|
-
meta,
|
|
740
|
-
lineNumber: line,
|
|
741
|
-
},
|
|
742
|
-
line
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
return id;
|
|
746
|
-
}
|
|
747
|
-
// Named stop: bind to an existing declared POI if present, else geocode + create.
|
|
748
|
-
const f = fold(pos.name);
|
|
749
|
-
if (registry.has(f)) return f;
|
|
750
|
-
const aliased = declaredByName.get(f);
|
|
751
|
-
if (aliased) return aliased;
|
|
752
|
-
const got = lookupName(pos.name, pos.scope, line, inferredScope, true);
|
|
753
|
-
if (got.kind !== 'ok') return null;
|
|
754
|
-
noteCountry(got.iso);
|
|
755
|
-
registerPoi(
|
|
756
|
-
f,
|
|
757
|
-
{
|
|
758
|
-
id: f,
|
|
759
|
-
name: pos.name,
|
|
760
|
-
lat: got.lat,
|
|
761
|
-
lon: got.lon,
|
|
762
|
-
...(label !== undefined && { label }),
|
|
763
|
-
tags,
|
|
764
|
-
meta,
|
|
765
|
-
lineNumber: line,
|
|
766
|
-
},
|
|
767
|
-
line
|
|
768
|
-
);
|
|
769
|
-
return f;
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
const routes: ResolvedRoute[] = [];
|
|
773
|
-
for (const rt of parsed.routes) {
|
|
774
|
-
const originId = resolveStop(
|
|
775
|
-
rt.origin,
|
|
776
|
-
rt.originAlias,
|
|
777
|
-
rt.originLabel,
|
|
778
|
-
rt.originTags,
|
|
779
|
-
rt.originValue,
|
|
780
|
-
rt.lineNumber
|
|
781
|
-
);
|
|
782
|
-
if (!originId) continue; // can't anchor the route → drop (error already pushed)
|
|
783
|
-
const stopIds: string[] = [originId];
|
|
784
|
-
const legs: ResolvedRouteLeg[] = [];
|
|
785
|
-
let prevId = originId;
|
|
786
|
-
for (const leg of rt.legs) {
|
|
787
|
-
const destId = resolveStop(
|
|
788
|
-
leg.dest,
|
|
789
|
-
leg.destAlias,
|
|
790
|
-
leg.destLabel,
|
|
791
|
-
{}, // a leg tag colours the LINE (§24B.6), not the destination stop
|
|
792
|
-
undefined, // a leg's `width:` is leg thickness, not the dest's size
|
|
793
|
-
leg.lineNumber
|
|
794
|
-
);
|
|
795
|
-
if (!destId) continue; // ungeocodable destination → skip this leg
|
|
796
|
-
legs.push({
|
|
797
|
-
fromId: prevId,
|
|
798
|
-
toId: destId,
|
|
799
|
-
...(leg.label !== undefined && { label: leg.label }),
|
|
800
|
-
style: leg.style,
|
|
801
|
-
...(leg.value !== undefined && { value: leg.value }),
|
|
802
|
-
tags: leg.tags,
|
|
803
|
-
lineNumber: leg.lineNumber,
|
|
804
|
-
});
|
|
805
|
-
if (!stopIds.includes(destId)) stopIds.push(destId); // unique markers (loop-close dedupe)
|
|
806
|
-
prevId = destId;
|
|
807
|
-
}
|
|
808
|
-
routes.push({ stopIds, legs, lineNumber: rt.lineNumber });
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// ── Basemaps + scope ──
|
|
812
|
-
// "US-oriented" = there is US content AND all other content stays within North
|
|
813
|
-
// America (US, Canada, or Mexico — no non-NA POI, no non-US/CA/MX country
|
|
814
|
-
// fill). Such a map renders as the conventional US states map: the full state
|
|
815
|
-
// mesh on albers-usa, every state outlined even with no data — including a
|
|
816
|
-
// POI-only named-city map (§24B.2), and now also a US map with a Canadian or
|
|
817
|
-
// Mexican neighbour POI/fill (the neighbour is framed alongside, never clipped).
|
|
818
|
-
// "Has US content" is STATE-level (a US state fill, a US POI, or `locale US`)
|
|
819
|
-
// — NOT a country-level `United States` fill, which means "treat the US as one
|
|
820
|
-
// unit" and should keep the country shape, not explode into 50 states.
|
|
821
|
-
const hasUsContent =
|
|
822
|
-
usSubdivisionReferenced || anyUsPoi || localeCountry === 'US';
|
|
823
|
-
const usOriented =
|
|
824
|
-
!anyNonNaPoi &&
|
|
825
|
-
!regions.some(
|
|
826
|
-
(r) => r.layer === 'country' && !['US', 'CA', 'MX'].includes(r.iso)
|
|
827
|
-
) &&
|
|
828
|
-
hasUsContent;
|
|
829
|
-
|
|
830
|
-
const subdivisions: Array<'us-states'> = [];
|
|
831
|
-
// Draw the US state mesh in two cases:
|
|
832
|
-
// 1. `usSubdivisionReferenced` — a US state is named as a data region
|
|
833
|
-
// (e.g. `California heat: 92`), so the states ARE the subject; detail
|
|
834
|
-
// them even on a global projection alongside non-NA content.
|
|
835
|
-
// 2. `usOriented` — US content with everything else inside North America,
|
|
836
|
-
// i.e. the conventional US states map (including a POI-only named-city
|
|
837
|
-
// map, or a US map with a Canadian/Mexican neighbour).
|
|
838
|
-
// Deliberately NOT drawn for bare US POIs on an otherwise-global map (e.g. a
|
|
839
|
-
// worldwide backbone with `us-east-1` + Tokyo + Mumbai): the map already knows
|
|
840
|
-
// it spans beyond NA (`anyNonNaPoi`), so exploding the US into 50 states reads
|
|
841
|
-
// as noise, not signal. Such a map renders country-only.
|
|
842
|
-
if (usSubdivisionReferenced || usOriented) subdivisions.push('us-states');
|
|
843
|
-
|
|
844
|
-
// ── Extent + projection (R5/R10) ──
|
|
845
|
-
const regionBoxes: GeoExtent[] = [];
|
|
846
|
-
for (const ref of referencedRegionIds) {
|
|
847
|
-
const bb = featureBbox(data.usStates, ref.id);
|
|
848
|
-
if (bb) regionBoxes.push(bb);
|
|
849
|
-
}
|
|
850
|
-
// country regions contribute their country bbox — but framed on the dominant
|
|
851
|
-
// landmass, ignoring far-detached minor territories (e.g. French Guiana) so a
|
|
852
|
-
// Europe map naming France doesn't auto-fit across the Atlantic (R5).
|
|
853
|
-
for (const r of regions) {
|
|
854
|
-
if (r.layer === 'country') {
|
|
855
|
-
const bb = featureBboxPrimary(data.worldCoarse, r.iso);
|
|
856
|
-
if (bb) regionBoxes.push(bb);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
const points: Array<[number, number]> = pois.map((p) => [p.lon, p.lat]);
|
|
860
|
-
const unioned = unionExtent(regionBoxes, points);
|
|
861
|
-
// Sub-national US auto-zoom gate (map-us-subnational-zoom). Computed from the
|
|
862
|
-
// RAW unpadded union (NOT the padded `extent` below, which inflates area ~1.5×).
|
|
863
|
-
// `usSubNational` decides WHETHER to zoom; the projection-family picker below
|
|
864
|
-
// still keys off span as before. `localeUsForced` = `locale US` with no
|
|
865
|
-
// subdivision → the author asked for the whole-country frame, so stay national
|
|
866
|
-
// even on compact data.
|
|
867
|
-
const usSubNational = isSubNationalUsExtent(unioned);
|
|
868
|
-
const localeUsForced = localeCountry === 'US' && !localeSubdivision;
|
|
869
|
-
const DEFAULT_EXTENT: GeoExtent = [
|
|
870
|
-
[-180, -85],
|
|
871
|
-
[180, 85],
|
|
872
|
-
];
|
|
873
|
-
// Region/choropleth maps get a wider context pad; POI maps stay tight (the
|
|
874
|
-
// isPoiOnly block below re-pads container bboxes with PAD_FRACTION anyway).
|
|
875
|
-
const basePad = regions.length > 0 ? REGION_PAD_FRACTION : PAD_FRACTION;
|
|
876
|
-
let extent: GeoExtent = unioned ? pad(unioned, basePad) : DEFAULT_EXTENT; // empty → default
|
|
877
|
-
|
|
878
|
-
const isPoiOnly = pois.length > 0 && regions.length === 0;
|
|
879
|
-
|
|
880
|
-
// POI-only region framing (R-poi-region): snap the frame to the region(s) that
|
|
881
|
-
// CONTAIN the POIs, not an arbitrary window. Reverse-geocode each POI to its US
|
|
882
|
-
// state / country (point-in-polygon), then frame to the union of those regions'
|
|
883
|
-
// bboxes — so a cluster of Bay Area cities shows the whole of California, with
|
|
884
|
-
// the containing region available for labelling. Falls back to the raw POI
|
|
885
|
-
// extent when a POI sits outside every polygon (open ocean). The floor below
|
|
886
|
-
// still applies as a *minimum* so a tiny container (e.g. Rhode Island) keeps
|
|
887
|
-
// breathing room.
|
|
888
|
-
const containerRegionIds: string[] = []; // 'US-CA' (state) or 'FR' (country)
|
|
889
|
-
if (isPoiOnly) {
|
|
890
|
-
const countries = decodeFeatures(data.worldDetail);
|
|
891
|
-
const states = decodeFeatures(data.usStates);
|
|
892
|
-
const seen = new Set<string>();
|
|
893
|
-
const containerBoxes: GeoExtent[] = [];
|
|
894
|
-
for (const p of pois) {
|
|
895
|
-
const { country, state } = regionAt([p.lon, p.lat], countries, states);
|
|
896
|
-
const id = state?.iso ?? country?.iso;
|
|
897
|
-
if (!id || seen.has(id)) continue;
|
|
898
|
-
seen.add(id);
|
|
899
|
-
containerRegionIds.push(id);
|
|
900
|
-
// On a US-oriented map a foreign-NEIGHBOUR POI (a Canadian/Mexican city)
|
|
901
|
-
// frames by just its POINT, never its whole country — §24B.2: "A Canada/
|
|
902
|
-
// Mexico POI expands the frame by just its point (the US barely shrinks)".
|
|
903
|
-
// Pulling in Canada's full bbox here would drag the frame up to ~83°N (the
|
|
904
|
-
// point is already in `points`, so it still shows). US states still
|
|
905
|
-
// contribute their bbox (so a Bay Area cluster frames all of California).
|
|
906
|
-
const bb = state
|
|
907
|
-
? featureBbox(data.usStates, id)
|
|
908
|
-
: usOriented
|
|
909
|
-
? null
|
|
910
|
-
: featureBboxPrimary(data.worldCoarse, id);
|
|
911
|
-
// Skip a degenerate whole-sphere bbox: spherical geoBounds returns the full
|
|
912
|
-
// globe for a malformed/missing geometry, which would blow the frame out to
|
|
913
|
-
// the entire world. Real region geometry never spans the full sphere — fall
|
|
914
|
-
// back to the raw POI extent (+ floor) instead.
|
|
915
|
-
if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
|
|
916
|
-
}
|
|
917
|
-
const containerUnion = unionExtent(containerBoxes, points);
|
|
918
|
-
if (containerUnion)
|
|
919
|
-
extent = pad(
|
|
920
|
-
clampContainerToCluster(containerUnion, points, usOriented),
|
|
921
|
-
PAD_FRACTION
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// POI-only fit-to-cluster zoom floor. With region framing above, the extent is
|
|
926
|
-
// usually the containing region(s); the floor only kicks in for a tiny region
|
|
927
|
-
// or a POI that fell outside every polygon. Expand a too-tight extent
|
|
928
|
-
// symmetrically about its centroid until the longer axis reaches
|
|
929
|
-
// POI_ZOOM_FLOOR_DEG so recognizable land always frames the dots. Uniform scale
|
|
930
|
-
// preserves the aspect; the layout's fitExtent letterboxes to canvas.
|
|
931
|
-
// Also applies to a sub-national US region map (map-us-subnational-zoom) so a
|
|
932
|
-
// single tiny state (e.g. Rhode Island) keeps neighbour context instead of
|
|
933
|
-
// over-zooming into near-blank land. `usSubNational` is derived from the raw
|
|
934
|
-
// union above, so flooring the framing `extent` here doesn't feed back into it.
|
|
935
|
-
// Gated on `usOriented` — `usSubNational` is a pure area-vs-CONUS ratio, true
|
|
936
|
-
// for ANY small bbox, so without this guard a compact non-US region (e.g. a
|
|
937
|
-
// single small European country) would be floored too, regressing AC10.
|
|
938
|
-
if (isPoiOnly || (usSubNational && usOriented)) {
|
|
939
|
-
const cx = (extent[0][0] + extent[1][0]) / 2;
|
|
940
|
-
const cy = (extent[0][1] + extent[1][1]) / 2;
|
|
941
|
-
const lon = extent[1][0] - extent[0][0];
|
|
942
|
-
const lat = extent[1][1] - extent[0][1];
|
|
943
|
-
const longer = Math.max(lon, lat);
|
|
944
|
-
if (longer > 0 && longer < POI_ZOOM_FLOOR_DEG) {
|
|
945
|
-
const k = POI_ZOOM_FLOOR_DEG / longer;
|
|
946
|
-
const halfLon = (lon * k) / 2;
|
|
947
|
-
const halfLat = (lat * k) / 2;
|
|
948
|
-
extent = [
|
|
949
|
-
[cx - halfLon, cy - halfLat],
|
|
950
|
-
[cx + halfLon, cy + halfLat],
|
|
951
|
-
];
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const lonSpan = extent[1][0] - extent[0][0];
|
|
956
|
-
const latSpan = extent[1][1] - extent[0][1];
|
|
957
|
-
const span = Math.max(lonSpan, latSpan);
|
|
958
|
-
const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
|
|
959
|
-
// albers-usa is the national US composite (insets AK/HI when referenced, the
|
|
960
|
-
// conic projects neighbour land around the states). Choose it exactly when the
|
|
961
|
-
// map is US-oriented (#13): US content plus only US/Canada/Mexico elsewhere.
|
|
962
|
-
// Content reaching BEYOND North America fails `usOriented` and stays on a
|
|
963
|
-
// geographic world/regional projection that frames everything. Note: this
|
|
964
|
-
// intentionally snaps a POI-only US city map to the national frame ("show all
|
|
965
|
-
// states") rather than fit-zooming to the cluster on a geographic projection.
|
|
966
|
-
// (§24B.2 — projection is inferred, never configured.)
|
|
967
|
-
// A compact US region/choropleth that should zoom rather than frame the nation
|
|
968
|
-
// (map-us-subnational-zoom): US-oriented, area-compact, not a POI map, and not
|
|
969
|
-
// forced national by an AK/HI inset or `locale US`.
|
|
970
|
-
const usSubNationalZoom =
|
|
971
|
-
usOriented &&
|
|
972
|
-
usSubNational &&
|
|
973
|
-
!isPoiOnly &&
|
|
974
|
-
!hasAlaskaRef &&
|
|
975
|
-
!hasHawaiiRef &&
|
|
976
|
-
!localeUsForced;
|
|
977
|
-
let projection: ProjectionFamily;
|
|
978
|
-
if (
|
|
979
|
-
usOriented &&
|
|
980
|
-
lonSpan < US_NATIONAL_LON_SPAN &&
|
|
981
|
-
(isPoiOnly || usSubNationalZoom)
|
|
982
|
-
) {
|
|
983
|
-
// Sub-national US, zoomed: regional Mercator. North is straight up everywhere
|
|
984
|
-
// (vertical meridians) — a conic equal-area fans its meridians toward the pole
|
|
985
|
-
// so an off-centre region visibly tilts, which reads wrong for a "zoom into the
|
|
986
|
-
// US" view. Area distortion across a sub-national frame is negligible, and the
|
|
987
|
-
// POI-cluster path already uses Mercator here. Covers both a POI cluster (fit
|
|
988
|
-
// to the floored extent) and a compact region/choropleth. The us-states mesh
|
|
989
|
-
// is still drawn (subdivisions via usOriented), so neighbours frame the data.
|
|
990
|
-
projection = 'mercator';
|
|
991
|
-
} else if (usOriented) {
|
|
992
|
-
// National US frame: spread-out region data, a wide corridor (lonSpan ≥ the
|
|
993
|
-
// national threshold), a POI map that spans the country, an AK/HI composite,
|
|
994
|
-
// or an explicit `locale US` whole-country request. (§24B.2 — albers-usa.)
|
|
995
|
-
projection = 'albers-usa';
|
|
996
|
-
} else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
|
|
997
|
-
// World/multi-continent scale (or a polar-reaching frame). Every world map —
|
|
998
|
-
// data choropleth OR dataless reference — gets equirectangular: a clean,
|
|
999
|
-
// conventional rectangular wall-map frame. (Trade: not equal-area, so a
|
|
1000
|
-
// choropleth's high-latitude regions stretch vertically — accepted for the
|
|
1001
|
-
// consistent rectangular look over Equal Earth's area honesty.)
|
|
1002
|
-
projection = 'equirectangular';
|
|
1003
|
-
} else {
|
|
1004
|
-
// Tight clusters AND single-continent regional views: a conic equal-area
|
|
1005
|
-
// (Albers) projection, parameterized per-extent in the layout. This is the
|
|
1006
|
-
// conventional atlas projection for a single mid-latitude continent — it
|
|
1007
|
-
// keeps familiar country shapes (no world-projection horizontal squash) and,
|
|
1008
|
-
// unlike Mercator, does NOT vertically inflate high-latitude land. That
|
|
1009
|
-
// inflation is what pushed a Europe choropleth's colored mass into the bottom
|
|
1010
|
-
// ~30% of the frame (Scandinavia's stretch wasted the upper half on empty
|
|
1011
|
-
// ocean); equal-area framing re-centers the data and scales the land larger.
|
|
1012
|
-
projection = 'conic-equal-area';
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// World-scale framing (R10): a multi-continent spread frames most cleanly as
|
|
1016
|
-
// the conventional Greenwich-centred world rectangle. The tight-arc longitude
|
|
1017
|
-
// union is unstable for sparse global points — and an antimeridian-crossing
|
|
1018
|
-
// country box (the US, via its Aleutians) wraps the union to an Asia-centred
|
|
1019
|
-
// window that splits the Americas at the seam. When the data occupies at
|
|
1020
|
-
// least half the globe in longitude, snap to full longitude AND widen the
|
|
1021
|
-
// latitude band to the populated world (≈ Tierra del Fuego → northern
|
|
1022
|
-
// Russia/Canada) so the map reads as a standard world view with every
|
|
1023
|
-
// continent shown — not a thin band cropped to the data's latitudes (which
|
|
1024
|
-
// would slice off South Africa, southern Argentina, northern Russia, …). The
|
|
1025
|
-
// ≥180° gate leaves regional spreads tight — `region` continents (Europe
|
|
1026
|
-
// ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
|
|
1027
|
-
// Applies to the equirectangular world projection (data + reference alike).
|
|
1028
|
-
if (lonSpan >= 180) {
|
|
1029
|
-
extent = [
|
|
1030
|
-
[-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
|
|
1031
|
-
[180, Math.max(extent[1][1], WORLD_LAT_NORTH)],
|
|
1032
|
-
];
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
result.regions = regions;
|
|
1036
|
-
result.pois = pois;
|
|
1037
|
-
result.edges = edges;
|
|
1038
|
-
result.routes = routes;
|
|
1039
|
-
result.basemaps = {
|
|
1040
|
-
// Tier is intentionally pinned to detail (50m) at ALL scales. Diagrammo maps
|
|
1041
|
-
// are presentational (palette tints, relief hachures, POI hubs), not
|
|
1042
|
-
// survey-grade — recognizability > generalization: 110m coarse drops the
|
|
1043
|
-
// Italian boot to a stump at world scale. `WORLD_SPAN` lives on only for the
|
|
1044
|
-
// projection decision (the `usOriented`/`span > WORLD_SPAN` chain above); it
|
|
1045
|
-
// no longer gates basemap resolution.
|
|
1046
|
-
// `worldCoarse` is still loaded — it's the authoritative name/bbox index
|
|
1047
|
-
// (featureIndex, featureBboxPrimary), not dead code.
|
|
1048
|
-
world: 'detail',
|
|
1049
|
-
subdivisions,
|
|
1050
|
-
};
|
|
1051
|
-
result.extent = extent;
|
|
1052
|
-
result.projection = projection;
|
|
1053
|
-
result.poiFrameContainers = containerRegionIds;
|
|
1054
|
-
result.error = parsed.error ?? firstError(diagnostics);
|
|
1055
|
-
// `Writable` widens the GeoExtent tuple to an array; the runtime value is a
|
|
1056
|
-
// correct GeoExtent, so cast back on return (through unknown — tuple vs array).
|
|
1057
|
-
return result as unknown as ResolvedMap;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function mostCommonCountry(
|
|
1061
|
-
regions: ResolvedRegion[],
|
|
1062
|
-
poiCountries: string[]
|
|
1063
|
-
): string | undefined {
|
|
1064
|
-
const counts = new Map<string, number>();
|
|
1065
|
-
for (const r of regions) {
|
|
1066
|
-
const iso = r.layer === 'us-state' ? 'US' : r.iso;
|
|
1067
|
-
counts.set(iso, (counts.get(iso) ?? 0) + 1);
|
|
1068
|
-
}
|
|
1069
|
-
// Pass-A-resolved POI countries now count too (#3): a POI-only US map infers
|
|
1070
|
-
// `default-country=US`.
|
|
1071
|
-
for (const iso of poiCountries) {
|
|
1072
|
-
counts.set(iso, (counts.get(iso) ?? 0) + 1);
|
|
1073
|
-
}
|
|
1074
|
-
// Iterate alphabetically and keep on strictly-greater count, so a tie resolves
|
|
1075
|
-
// to the alphabetically-first ISO — deterministic (#14).
|
|
1076
|
-
let best: string | undefined;
|
|
1077
|
-
let bestN = 0;
|
|
1078
|
-
for (const [iso, n] of [...counts.entries()].sort((a, b) =>
|
|
1079
|
-
a[0] < b[0] ? -1 : 1
|
|
1080
|
-
)) {
|
|
1081
|
-
if (n > bestN) {
|
|
1082
|
-
bestN = n;
|
|
1083
|
-
best = iso;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
return best;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
|
|
1090
|
-
* reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
|
|
1091
|
-
* country drags the frame to that country's far edge. Cap how far the frame
|
|
1092
|
-
* extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG, while
|
|
1093
|
-
* letting a genuinely tighter container edge still bound the frame (so a small
|
|
1094
|
-
* country shows whole, but a cluster inside a giant one stays on the cluster).
|
|
1095
|
-
*
|
|
1096
|
-
* Each longitude side clamps independently. A container edge is a usable outer
|
|
1097
|
-
* bound only when it sits within the normal [-180, 180] range AND on the correct
|
|
1098
|
-
* side of the cluster; an antimeridian-crossing container (Russia, Fiji, NZ, the
|
|
1099
|
-
* US via the Aleutians) reports a degenerate east (> 180, or numerically < its
|
|
1100
|
-
* own west), so that side falls back to cluster ± overshoot instead of skipping
|
|
1101
|
-
* the clamp entirely (which previously blew a western-Russia cluster out to a
|
|
1102
|
-
* world frame). Latitude never wraps, so it always clamps. Assumes the POI
|
|
1103
|
-
* cluster itself does not straddle the seam — true for any regional cluster. */
|
|
1104
|
-
function clampContainerToCluster(
|
|
1105
|
-
container: GeoExtent,
|
|
1106
|
-
points: Array<[number, number]>,
|
|
1107
|
-
usOriented: boolean
|
|
1108
|
-
): GeoExtent {
|
|
1109
|
-
const poi = unionExtent([], points);
|
|
1110
|
-
if (!poi) return container;
|
|
1111
|
-
let [[west, south], [east, north]] = container;
|
|
1112
|
-
const [[pWest, pSouth], [pEast, pNorth]] = poi;
|
|
1113
|
-
// Overshoot shrinks with cluster size for non-US maps (see containerOvershoot):
|
|
1114
|
-
// a big cluster already orients itself, so it gets less surrounding slack than a
|
|
1115
|
-
// tiny one. US-oriented maps keep the flat MAX (the albers-usa gate needs it).
|
|
1116
|
-
const over = containerOvershoot(
|
|
1117
|
-
Math.max(pEast - pWest, pNorth - pSouth),
|
|
1118
|
-
usOriented
|
|
1119
|
-
);
|
|
1120
|
-
south = Math.max(south, pSouth - over);
|
|
1121
|
-
north = Math.min(north, pNorth + over);
|
|
1122
|
-
const wOver = pWest - over;
|
|
1123
|
-
const eOver = pEast + over;
|
|
1124
|
-
// West edge usable iff in range and not east of the cluster's west.
|
|
1125
|
-
west = west >= -180 && west <= pWest ? Math.max(west, wOver) : wOver;
|
|
1126
|
-
// East edge usable iff in range and not west of the cluster's east.
|
|
1127
|
-
east = east <= 180 && east >= pEast ? Math.min(east, eOver) : eOver;
|
|
1128
|
-
return [
|
|
1129
|
-
[west, south],
|
|
1130
|
-
[east, north],
|
|
1131
|
-
];
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
function pad(e: GeoExtent, frac: number): GeoExtent {
|
|
1135
|
-
const dLon = (e[1][0] - e[0][0]) * frac || 1;
|
|
1136
|
-
const dLat = (e[1][1] - e[0][1]) * frac || 1;
|
|
1137
|
-
return [
|
|
1138
|
-
[Math.max(-180, e[0][0] - dLon), Math.max(-90, e[0][1] - dLat)],
|
|
1139
|
-
[Math.min(540, e[1][0] + dLon), Math.min(90, e[1][1] + dLat)],
|
|
1140
|
-
] as GeoExtent;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function firstError(diags: DgmoError[]): string | null {
|
|
1144
|
-
const e = diags.find((d) => d.severity === 'error');
|
|
1145
|
-
return e ? formatDgmoError(e) : null;
|
|
1146
|
-
}
|