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