@diagrammo/dgmo 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -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
- }