@diagrammo/dgmo 0.21.0 → 0.22.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 (76) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2521 -623
  3. package/dist/advanced.d.cts +917 -534
  4. package/dist/advanced.d.ts +917 -534
  5. package/dist/advanced.js +2516 -623
  6. package/dist/auto.cjs +2333 -608
  7. package/dist/auto.js +119 -119
  8. package/dist/auto.mjs +2335 -609
  9. package/dist/cli.cjs +168 -168
  10. package/dist/editor.cjs +13 -15
  11. package/dist/editor.js +13 -15
  12. package/dist/highlight.cjs +15 -12
  13. package/dist/highlight.js +15 -12
  14. package/dist/index.cjs +2317 -595
  15. package/dist/index.d.cts +4 -1
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +2319 -596
  18. package/dist/internal.cjs +2521 -623
  19. package/dist/internal.d.cts +917 -534
  20. package/dist/internal.d.ts +917 -534
  21. package/dist/internal.js +2516 -623
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -0
  24. package/dist/map-data/water-bodies.json +1 -0
  25. package/docs/language-reference.md +44 -31
  26. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  27. package/gallery/fixtures/map-categorical.dgmo +0 -1
  28. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  29. package/gallery/fixtures/map-coastline.dgmo +7 -0
  30. package/gallery/fixtures/map-colorize.dgmo +11 -0
  31. package/gallery/fixtures/map-direct-color.dgmo +9 -0
  32. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  33. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  34. package/gallery/fixtures/map-route.dgmo +0 -1
  35. package/package.json +1 -1
  36. package/src/advanced.ts +26 -1
  37. package/src/boxes-and-lines/renderer.ts +39 -12
  38. package/src/cli.ts +1 -1
  39. package/src/completion.ts +32 -24
  40. package/src/cycle/renderer.ts +14 -1
  41. package/src/d3.ts +23 -11
  42. package/src/editor/highlight-api.ts +4 -0
  43. package/src/editor/keywords.ts +13 -15
  44. package/src/infra/renderer.ts +35 -7
  45. package/src/map/colorize.ts +54 -0
  46. package/src/map/context-labels.ts +429 -0
  47. package/src/map/data/PROVENANCE.json +1 -1
  48. package/src/map/data/mountain-ranges.json +1 -0
  49. package/src/map/data/types.ts +34 -0
  50. package/src/map/data/water-bodies.json +1 -0
  51. package/src/map/dimensions.ts +117 -0
  52. package/src/map/geo-query.ts +295 -0
  53. package/src/map/geo.ts +305 -2
  54. package/src/map/invert.ts +111 -0
  55. package/src/map/layout.ts +1504 -335
  56. package/src/map/load-data.ts +16 -2
  57. package/src/map/parser.ts +57 -111
  58. package/src/map/renderer.ts +556 -13
  59. package/src/map/resolved-types.ts +24 -2
  60. package/src/map/resolver.ts +237 -67
  61. package/src/map/types.ts +39 -23
  62. package/src/mindmap/renderer.ts +10 -1
  63. package/src/palettes/atlas.ts +77 -0
  64. package/src/palettes/blueprint.ts +73 -0
  65. package/src/palettes/color-utils.ts +58 -1
  66. package/src/palettes/index.ts +12 -3
  67. package/src/palettes/slate.ts +73 -0
  68. package/src/palettes/tidewater.ts +73 -0
  69. package/src/render.ts +8 -1
  70. package/src/tech-radar/renderer.ts +3 -0
  71. package/src/tech-radar/types.ts +3 -0
  72. package/src/utils/d3-types.ts +5 -0
  73. package/src/utils/legend-layout.ts +21 -4
  74. package/src/utils/legend-types.ts +7 -0
  75. package/src/utils/reserved-key-registry.ts +3 -0
  76. package/src/palettes/bold.ts +0 -67
@@ -4,7 +4,7 @@
4
4
  import type { DgmoError } from '../diagnostics';
5
5
  import type { TagGroup } from '../utils/tag-groups';
6
6
  import type { MapDirectives } from './types';
7
- import type { Gazetteer, BoundaryTopology } from './data/types';
7
+ import type { Gazetteer, BoundaryTopology, WaterBodies } from './data/types';
8
8
 
9
9
  /** The four static assets, injected into the pure resolver (DI). */
10
10
  export interface MapData {
@@ -17,6 +17,10 @@ export interface MapData {
17
17
  /** Major river centerlines (Natural Earth 110m) drawn as thin water lines over
18
18
  * land — e.g. the Amazon, Nile, Mississippi. Optional, like `lakes`. */
19
19
  rivers?: BoundaryTopology;
20
+ /** Notable mountain-range polygons (Natural Earth 50m geography regions) drawn
21
+ * as a subtle gradient relief cue over base land when the `relief` directive
22
+ * is on — e.g. the Rockies, Andes, Himalayas. Optional, like `lakes`. */
23
+ mountainRanges?: BoundaryTopology;
20
24
  /** North-America-clipped 10m country land, used as crisp neighbour context
21
25
  * under the albers-usa US view so Canada/Mexico match the 10m states instead
22
26
  * of the coarser world tiers. Optional, like `lakes`. */
@@ -24,12 +28,17 @@ export interface MapData {
24
28
  /** North-America-clipped 10m major lakes (Great Lakes etc.), used in place of
25
29
  * the coarse `lakes` under the albers-usa US view. Optional. */
26
30
  naLakes?: BoundaryTopology;
31
+ /** Water-body orientation labels (Natural Earth marine polys) drawn when the
32
+ * `context-labels` directive is on — oceans/seas/gulfs/bays/etc. Optional, so
33
+ * hand-built test fixtures and older bundles need not supply it. */
34
+ waterBodies?: WaterBodies;
27
35
  gazetteer: Gazetteer;
28
36
  }
29
37
 
30
38
  export type ProjectionFamily =
31
- | 'equirectangular'
39
+ | 'equal-earth'
32
40
  | 'natural-earth'
41
+ | 'equirectangular'
33
42
  | 'albers-usa'
34
43
  | 'mercator';
35
44
 
@@ -47,6 +56,8 @@ export interface ResolvedRegion {
47
56
  readonly name: string; // display name
48
57
  readonly layer: 'country' | 'us-state';
49
58
  readonly value?: number;
59
+ /** §1.5 trailing-token color NAME → flat override fill (§24B.4). */
60
+ readonly color?: string;
50
61
  readonly tags: Readonly<Record<string, string>>;
51
62
  readonly meta: Readonly<Record<string, string>>;
52
63
  readonly lineNumber: number;
@@ -62,6 +73,8 @@ export interface ResolvedPoi {
62
73
  readonly lat: number;
63
74
  readonly lon: number;
64
75
  readonly label?: string;
76
+ /** §1.5 trailing-token color NAME → flat marker fill (§24B.5). */
77
+ readonly color?: string;
65
78
  readonly tags: Readonly<Record<string, string>>;
66
79
  readonly meta: Readonly<Record<string, string>>;
67
80
  readonly lineNumber: number;
@@ -108,6 +121,9 @@ export type GeoExtent = [[number, number], [number, number]];
108
121
 
109
122
  export interface ResolvedMap {
110
123
  readonly title: string | null;
124
+ /** DEAD — the `subtitle` directive was removed (2026-06-02 defaults-on review).
125
+ * Never populated; the renderer's subtitle branch is now unreachable. Left for
126
+ * a later cleanup pass. */
111
127
  readonly subtitle?: string;
112
128
  readonly caption?: string;
113
129
  readonly tagGroups: readonly TagGroup[];
@@ -119,6 +135,12 @@ export interface ResolvedMap {
119
135
  readonly routes: readonly ResolvedRoute[];
120
136
  readonly extent: GeoExtent;
121
137
  readonly projection: ProjectionFamily;
138
+ /** POI-only region framing: the region(s) that CONTAIN the POIs — us-state ids
139
+ * (`US-CA`) or country isos (`FR`). The frame is snapped to the union of their
140
+ * bboxes, and the layout labels them prominently (vs. muted neighbours). Empty
141
+ * for non-POI-only maps or when POIs fall outside every polygon. Optional so
142
+ * older/foreign ResolvedMap literals need not supply it. */
143
+ readonly poiFrameContainers?: readonly string[];
122
144
  readonly diagnostics: readonly DgmoError[];
123
145
  readonly error: string | null;
124
146
  }
@@ -18,7 +18,15 @@ import type {
18
18
  ProjectionFamily,
19
19
  GeoExtent,
20
20
  } from './resolved-types';
21
- import { featureIndex, featureBbox, unionExtent, fold } from './geo';
21
+ import {
22
+ featureIndex,
23
+ featureBbox,
24
+ featureBboxPrimary,
25
+ unionExtent,
26
+ fold,
27
+ decodeFeatures,
28
+ regionAt,
29
+ } from './geo';
22
30
 
23
31
  /** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
24
32
  * retry in pass B with inferred scope" — distinct from `miss` (errored, drop) so
@@ -30,13 +38,36 @@ type LookupResult =
30
38
 
31
39
  // Projection / tier thresholds (degrees of span) — tunable (R10).
32
40
  const WORLD_SPAN = 90;
33
- const MERCATOR_MAX_SPAN = 25;
41
+ // Mercator is used for everything sub-world (tight clusters AND single-continent
42
+ // regional views — a mid-latitude continent reads with its familiar conventional
43
+ // shape). Two guards push back to the equirectangular world projection: a
44
+ // world/multi-continent `span` (> WORLD_SPAN), or
45
+ // a frame that reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's
46
+ // sec(φ) area blow-up turns gross. Europe (≈71°N) and East Asia stay on Mercator.
47
+ const MERCATOR_MAX_LAT = 80;
34
48
  const PAD_FRACTION = 0.05;
49
+ // Region/choropleth maps frame to the NAMED countries/states; a tight 5% pad cuts
50
+ // the surrounding continent right at the data edge (a Europe choropleth stops at
51
+ // ~31°E, slicing off western Russia/Ukraine). A larger pad lets the neighbouring
52
+ // land peek in as gray context for orientation. POI maps keep the tight pad — they
53
+ // already get container-region framing + the zoom floor.
54
+ const REGION_PAD_FRACTION = 0.12;
35
55
  // Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
36
56
  // Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
37
57
  // populated continents fill the frame rather than waste it on ice.
38
58
  const WORLD_LAT_SOUTH = -58;
39
59
  const WORLD_LAT_NORTH = 78;
60
+ // Tightest zoom for a POI-only cluster: never frame narrower than this many
61
+ // degrees on the longer axis. The basemap is state/country geometry only (no
62
+ // counties/cities/roads), so a metro-scale frame is an empty box — clamp up to a
63
+ // multi-state window that always shows coastline + a state outline or two around
64
+ // the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
65
+ // home state + neighbours rather than the whole nation. Tunable.
66
+ const POI_ZOOM_FLOOR_DEG = 7;
67
+ // Above this longitudinal span a US POI-only extent is "national" — use the
68
+ // albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
69
+ // CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
70
+ const US_NATIONAL_LON_SPAN = 48;
40
71
 
41
72
  // Long-form (or common-alias) country name → the folded Natural-Earth display
42
73
  // name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
@@ -143,6 +174,30 @@ function looksUS(lat: number, lon: number): boolean {
143
174
  return (lon >= -180 && lon <= -64) || lon >= 172;
144
175
  }
145
176
 
177
+ /** Classifies a bare-coordinate POI as a North-American neighbour (vs a far-away
178
+ * blocker) when deciding `albers-usa`. Used only for bare coords — named POIs
179
+ * carry an ISO. This deliberately only covers what `looksUS`'s broad box does
180
+ * NOT: the Atlantic-Canada strip east of −64° (Newfoundland/Labrador/Nova
181
+ * Scotia, e.g. St. John's at −52.7) which falls outside `looksUS`'s
182
+ * [−180, −64] longitude window. Most of Canada and all of Mexico already pass
183
+ * `looksUS` (their lon is ≤ −64, lat ≥ 15) so they never reach here. The
184
+ * latitude is capped at 72° (matching `looksUS`) so a high-Arctic coord is NOT
185
+ * treated as a neighbour — that would both distort the conus fit and collide
186
+ * with the Alaska inset bbox (`inAlaska`, lat ≥ 51 ∧ lon ≤ −129). NOTE: the
187
+ * east strip can also catch the SE-Greenland coast (rare, acceptable; named
188
+ * GL/DK POIs are unaffected since they classify by ISO). */
189
+ function looksNorthAmericaNeighbor(lat: number, lon: number): boolean {
190
+ return lat >= 14 && lat <= 72 && lon >= -141 && lon <= -52;
191
+ }
192
+
193
+ /** A bbox that (near-)spans the whole globe — the sentinel spherical geoBounds
194
+ * returns for a malformed/missing feature. Such a box is useless for framing. */
195
+ function isWholeSphere(bb: GeoExtent): boolean {
196
+ return (
197
+ bb[0][0] <= -179 && bb[1][0] >= 179 && bb[0][1] <= -89 && bb[1][1] >= 89
198
+ );
199
+ }
200
+
146
201
  export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
147
202
  const diagnostics: DgmoError[] = [...parsed.diagnostics]; // seed with parse diags (R14)
148
203
  const err = (line: number, message: string, code?: string): void => {
@@ -154,9 +209,6 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
154
209
 
155
210
  const result: Writable<ResolvedMap> = {
156
211
  title: parsed.title,
157
- ...(parsed.directives.subtitle !== undefined && {
158
- subtitle: parsed.directives.subtitle,
159
- }),
160
212
  ...(parsed.directives.caption !== undefined && {
161
213
  caption: parsed.directives.caption,
162
214
  }),
@@ -166,7 +218,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
166
218
  // renderer's job (step 4) — the resolver only carries `tags` + `tagGroups`
167
219
  // through; it never resolves a tag value to a palette color (#10).
168
220
  directives: { ...parsed.directives },
169
- basemaps: { world: 'coarse', subdivisions: [] },
221
+ basemaps: { world: 'detail', subdivisions: [] },
170
222
  regions: [],
171
223
  pois: [],
172
224
  edges: [],
@@ -175,13 +227,18 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
175
227
  [-180, -85],
176
228
  [180, 85],
177
229
  ] as GeoExtent,
178
- projection: 'natural-earth',
230
+ projection: 'equirectangular',
231
+ poiFrameContainers: [],
179
232
  diagnostics,
180
233
  error: parsed.error,
181
234
  };
182
235
 
183
236
  // Per-layer indexes (never merged — R12; coarse is the authoritative name
184
- // index, ids shared with detail — R13).
237
+ // index, ids shared with detail — R13). LOAD-BEARING: `worldCoarse` (110m) is
238
+ // NOT used for rendering anymore (the world basemap is pinned to detail/50m,
239
+ // see basemaps assignment below) — it is retained solely as this name index
240
+ // (featureIndex) and the dominant-landmass bbox source (featureBboxPrimary).
241
+ // Do not delete it.
185
242
  const countryIndex = featureIndex(data.worldCoarse);
186
243
  const usStateIndex = featureIndex(data.usStates);
187
244
  const allNames = [
@@ -189,10 +246,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
189
246
  ...[...usStateIndex.values()].map((v) => v.name),
190
247
  ];
191
248
 
249
+ // ── locale <ISO> → country + optional subdivision (§24B.8) ──
250
+ const localeRaw = parsed.directives.locale?.toUpperCase();
251
+ const localeCountry = localeRaw ? localeRaw.split('-')[0] : undefined;
252
+ const localeSubdivision =
253
+ localeRaw && /^[A-Z]{2}-/.test(localeRaw) ? localeRaw : undefined;
254
+
192
255
  // ── US-scope signal (drives the country-vs-state collision, R2) ──
193
256
  const usScoped =
194
- parsed.directives.region === 'us-states' ||
195
- parsed.directives.defaultCountry?.toUpperCase() === 'US' ||
257
+ localeCountry === 'US' ||
196
258
  parsed.regions.some((r) => {
197
259
  const f = fold(r.name);
198
260
  return usStateIndex.has(f) && !countryIndex.has(f);
@@ -255,17 +317,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
255
317
  }
256
318
  } else if (inCountry && inState) {
257
319
  if (usScoped) {
320
+ // A US scope (e.g. `locale US`) makes the state the unambiguous
321
+ // intent — resolve silently, no disambiguation warning needed.
258
322
  chosen = { ...inState, layer: 'us-state' };
259
323
  } else {
260
324
  chosen = { ...inCountry, layer: 'country' };
325
+ // Teach the disambiguation syntax so the author can pin it explicitly.
326
+ // Suggest the non-redundant forms: a bare ISO code, or name + scope.
327
+ warn(
328
+ r.lineNumber,
329
+ `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
330
+ 'W_MAP_REGION_AMBIGUOUS'
331
+ );
261
332
  }
262
- // Teach the disambiguation syntax so the author can pin it explicitly.
263
- // Suggest the non-redundant forms: a bare ISO code, or name + scope.
264
- warn(
265
- r.lineNumber,
266
- `"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
267
- 'W_MAP_REGION_AMBIGUOUS'
268
- );
269
333
  } else if (inState) {
270
334
  chosen = { ...inState, layer: 'us-state' };
271
335
  } else if (inCountry) {
@@ -289,6 +353,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
289
353
  name: chosen.name,
290
354
  layer: chosen.layer,
291
355
  ...(r.value !== undefined && { value: r.value }),
356
+ ...(r.color !== undefined && { color: r.color }),
292
357
  tags: r.tags,
293
358
  meta: r.meta,
294
359
  lineNumber: r.lineNumber,
@@ -389,7 +454,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
389
454
  if (!scope)
390
455
  warn(
391
456
  line,
392
- `"${name}" is ambiguous — resolved to the most-populous match.`,
457
+ `"${name}" is ambiguous — resolved to the most-populous match. Set a default with \`locale <ISO>\` (e.g. \`locale US\` / \`locale US-GA\`) to steer it.`,
393
458
  'W_MAP_AMBIGUOUS_NAME'
394
459
  );
395
460
  }
@@ -407,11 +472,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
407
472
  // projection test (#13). Named POIs contribute their gazetteer ISO; bare-coord
408
473
  // POIs contribute a rough US-or-not classification (no reverse-geocode).
409
474
  const poiCountries: string[] = [];
410
- let anyNonUsPoi = false;
475
+ let anyUsPoi = false;
476
+ // `anyNonNaPoi` = a POI outside North America (not US/CA/MX). Gates the relaxed
477
+ // US-orientation test (#13): US + Canada/Mexico content keeps `albers-usa`, but
478
+ // anything beyond NA forces a geographic projection that frames all content.
479
+ let anyNonNaPoi = false;
411
480
  const noteCountry = (iso: string | undefined): void => {
412
481
  if (iso) {
413
482
  poiCountries.push(iso);
414
- if (iso !== 'US') anyNonUsPoi = true;
483
+ if (iso === 'US') anyUsPoi = true;
484
+ if (iso !== 'US' && iso !== 'CA' && iso !== 'MX') anyNonNaPoi = true;
415
485
  }
416
486
  };
417
487
 
@@ -419,7 +489,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
419
489
  const deferred: (typeof parsed.pois)[number][] = [];
420
490
  for (const p of parsed.pois) {
421
491
  if (p.pos.kind === 'coords') {
422
- if (!looksUS(p.pos.lat, p.pos.lon)) anyNonUsPoi = true;
492
+ if (looksUS(p.pos.lat, p.pos.lon)) anyUsPoi = true;
493
+ else if (!looksNorthAmericaNeighbor(p.pos.lat, p.pos.lon))
494
+ anyNonNaPoi = true;
423
495
  addResolvedPoi(p.pos.lat, p.pos.lon, p);
424
496
  continue;
425
497
  }
@@ -443,18 +515,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
443
515
  // resolved regions AND Pass-A POIs (#3 — POIs were previously voided, so a
444
516
  // POI-only US map never inferred US).
445
517
  const inferredCountry =
446
- parsed.directives.defaultCountry?.toUpperCase() ??
447
- mostCommonCountry(regions, poiCountries) ??
448
- undefined;
518
+ localeCountry ?? mostCommonCountry(regions, poiCountries) ?? undefined;
519
+ // A `locale US-GA` subdivision further scopes ambiguous bare cities to that
520
+ // state (soft preference — see lookupName); else fall back to the country.
521
+ const inferredScope = localeSubdivision ?? inferredCountry;
449
522
 
450
- // Pass B: ambiguous bare names, scoped by inferred default-country.
523
+ // Pass B: ambiguous bare names, scoped by the inferred locale.
451
524
  for (const p of deferred) {
452
525
  if (p.pos.kind !== 'name') continue;
453
526
  const got = lookupName(
454
527
  p.pos.name,
455
528
  p.pos.scope,
456
529
  p.lineNumber,
457
- inferredCountry,
530
+ inferredScope,
458
531
  true
459
532
  );
460
533
  if (got.kind === 'ok') {
@@ -476,6 +549,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
476
549
  lat,
477
550
  lon,
478
551
  ...(p.label !== undefined && { label: p.label }),
552
+ ...(p.color !== undefined && { color: p.color }),
479
553
  tags: p.tags,
480
554
  meta: p.meta,
481
555
  lineNumber: p.lineNumber,
@@ -550,7 +624,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
550
624
  sizeValue !== undefined ? { value: sizeValue } : {};
551
625
  if (pos.kind === 'coords') {
552
626
  const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
553
- if (!looksUS(pos.lat, pos.lon)) anyNonUsPoi = true;
627
+ if (looksUS(pos.lat, pos.lon)) anyUsPoi = true;
628
+ else if (!looksNorthAmericaNeighbor(pos.lat, pos.lon)) anyNonNaPoi = true;
554
629
  if (!registry.has(id)) {
555
630
  registerPoi(
556
631
  id,
@@ -574,7 +649,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
574
649
  if (registry.has(f)) return f;
575
650
  const aliased = declaredByName.get(f);
576
651
  if (aliased) return aliased;
577
- const got = lookupName(pos.name, pos.scope, line, inferredCountry, true);
652
+ const got = lookupName(pos.name, pos.scope, line, inferredScope, true);
578
653
  if (got.kind !== 'ok') return null;
579
654
  noteCountry(got.iso);
580
655
  registerPoi(
@@ -633,9 +708,37 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
633
708
  }
634
709
 
635
710
  // ── Basemaps + scope ──
711
+ // "US-oriented" = there is US content AND all other content stays within North
712
+ // America (US, Canada, or Mexico — no non-NA POI, no non-US/CA/MX country
713
+ // fill). Such a map renders as the conventional US states map: the full state
714
+ // mesh on albers-usa, every state outlined even with no data — including a
715
+ // POI-only named-city map (§24B.2), and now also a US map with a Canadian or
716
+ // Mexican neighbour POI/fill (the neighbour is framed alongside, never clipped).
717
+ // "Has US content" is STATE-level (a US state fill, a US POI, or `locale US`)
718
+ // — NOT a country-level `United States` fill, which means "treat the US as one
719
+ // unit" and should keep the country shape, not explode into 50 states.
720
+ const hasUsContent =
721
+ usSubdivisionReferenced || anyUsPoi || localeCountry === 'US';
722
+ const usOriented =
723
+ !anyNonNaPoi &&
724
+ !regions.some(
725
+ (r) => r.layer === 'country' && !['US', 'CA', 'MX'].includes(r.iso)
726
+ ) &&
727
+ hasUsContent;
728
+
636
729
  const subdivisions: Array<'us-states'> = [];
637
- if (usSubdivisionReferenced || parsed.directives.region === 'us-states')
638
- subdivisions.push('us-states');
730
+ // Draw the US state mesh in two cases:
731
+ // 1. `usSubdivisionReferenced` — a US state is named as a data region
732
+ // (e.g. `California value: 92`), so the states ARE the subject; detail
733
+ // them even on a global projection alongside non-NA content.
734
+ // 2. `usOriented` — US content with everything else inside North America,
735
+ // i.e. the conventional US states map (including a POI-only named-city
736
+ // map, or a US map with a Canadian/Mexican neighbour).
737
+ // Deliberately NOT drawn for bare US POIs on an otherwise-global map (e.g. a
738
+ // worldwide backbone with `us-east-1` + Tokyo + Mumbai): the map already knows
739
+ // it spans beyond NA (`anyNonNaPoi`), so exploding the US into 50 states reads
740
+ // as noise, not signal. Such a map renders country-only.
741
+ if (usSubdivisionReferenced || usOriented) subdivisions.push('us-states');
639
742
 
640
743
  // ── Extent + projection (R5/R10) ──
641
744
  const regionBoxes: GeoExtent[] = [];
@@ -643,10 +746,12 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
643
746
  const bb = featureBbox(data.usStates, ref.id);
644
747
  if (bb) regionBoxes.push(bb);
645
748
  }
646
- // country regions contribute their country bbox
749
+ // country regions contribute their country bbox — but framed on the dominant
750
+ // landmass, ignoring far-detached minor territories (e.g. French Guiana) so a
751
+ // Europe map naming France doesn't auto-fit across the Atlantic (R5).
647
752
  for (const r of regions) {
648
753
  if (r.layer === 'country') {
649
- const bb = featureBbox(data.worldCoarse, r.iso);
754
+ const bb = featureBboxPrimary(data.worldCoarse, r.iso);
650
755
  if (bb) regionBoxes.push(bb);
651
756
  }
652
757
  }
@@ -656,47 +761,103 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
656
761
  [-180, -85],
657
762
  [180, 85],
658
763
  ];
659
- let extent: GeoExtent = unioned ? pad(unioned, PAD_FRACTION) : DEFAULT_EXTENT; // empty → default
764
+ // Region/choropleth maps get a wider context pad; POI maps stay tight (the
765
+ // isPoiOnly block below re-pads container bboxes with PAD_FRACTION anyway).
766
+ const basePad = regions.length > 0 ? REGION_PAD_FRACTION : PAD_FRACTION;
767
+ let extent: GeoExtent = unioned ? pad(unioned, basePad) : DEFAULT_EXTENT; // empty → default
768
+
769
+ const isPoiOnly = pois.length > 0 && regions.length === 0;
770
+
771
+ // POI-only region framing (R-poi-region): snap the frame to the region(s) that
772
+ // CONTAIN the POIs, not an arbitrary window. Reverse-geocode each POI to its US
773
+ // state / country (point-in-polygon), then frame to the union of those regions'
774
+ // bboxes — so a cluster of Bay Area cities shows the whole of California, with
775
+ // the containing region available for labelling. Falls back to the raw POI
776
+ // extent when a POI sits outside every polygon (open ocean). The floor below
777
+ // still applies as a *minimum* so a tiny container (e.g. Rhode Island) keeps
778
+ // breathing room.
779
+ const containerRegionIds: string[] = []; // 'US-CA' (state) or 'FR' (country)
780
+ if (isPoiOnly) {
781
+ const countries = decodeFeatures(data.worldDetail);
782
+ const states = decodeFeatures(data.usStates);
783
+ const seen = new Set<string>();
784
+ const containerBoxes: GeoExtent[] = [];
785
+ for (const p of pois) {
786
+ const { country, state } = regionAt([p.lon, p.lat], countries, states);
787
+ const id = state?.iso ?? country?.iso;
788
+ if (!id || seen.has(id)) continue;
789
+ seen.add(id);
790
+ containerRegionIds.push(id);
791
+ const bb = state
792
+ ? featureBbox(data.usStates, id)
793
+ : featureBboxPrimary(data.worldCoarse, id);
794
+ // Skip a degenerate whole-sphere bbox: spherical geoBounds returns the full
795
+ // globe for a malformed/missing geometry, which would blow the frame out to
796
+ // the entire world. Real region geometry never spans the full sphere — fall
797
+ // back to the raw POI extent (+ floor) instead.
798
+ if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
799
+ }
800
+ const containerUnion = unionExtent(containerBoxes, points);
801
+ if (containerUnion) extent = pad(containerUnion, PAD_FRACTION);
802
+ }
803
+
804
+ // POI-only fit-to-cluster zoom floor. With region framing above, the extent is
805
+ // usually the containing region(s); the floor only kicks in for a tiny region
806
+ // or a POI that fell outside every polygon. Expand a too-tight extent
807
+ // symmetrically about its centroid until the longer axis reaches
808
+ // POI_ZOOM_FLOOR_DEG so recognizable land always frames the dots. Uniform scale
809
+ // preserves the aspect; the layout's fitExtent letterboxes to canvas.
810
+ if (isPoiOnly) {
811
+ const cx = (extent[0][0] + extent[1][0]) / 2;
812
+ const cy = (extent[0][1] + extent[1][1]) / 2;
813
+ const lon = extent[1][0] - extent[0][0];
814
+ const lat = extent[1][1] - extent[0][1];
815
+ const longer = Math.max(lon, lat);
816
+ if (longer > 0 && longer < POI_ZOOM_FLOOR_DEG) {
817
+ const k = POI_ZOOM_FLOOR_DEG / longer;
818
+ const halfLon = (lon * k) / 2;
819
+ const halfLat = (lat * k) / 2;
820
+ extent = [
821
+ [cx - halfLon, cy - halfLat],
822
+ [cx + halfLon, cy + halfLat],
823
+ ];
824
+ }
825
+ }
660
826
 
661
827
  const lonSpan = extent[1][0] - extent[0][0];
662
828
  const latSpan = extent[1][1] - extent[0][1];
663
829
  const span = Math.max(lonSpan, latSpan);
664
- // albers-usa only covers US territory: choose it only when the map is truly
665
- // US-only no non-US country region AND no POI outside the US (#13). Without
666
- // the POI guard a `default-country US` + Tokyo map projected to garbage.
667
- // albers-usa is the US-only composite projection it insets AK/HI and clips
668
- // out all non-US land. Use it only when the map actually renders US STATES (an
669
- // explicit `region us-states` or US-state region fills), NOT merely because the
670
- // POIs happen to be US: a pure POI/route map across the US should stay on a
671
- // geographic projection so neighbour land (Mexico, Central America, the
672
- // Caribbean, Canada) still draws.
673
- const usDominant =
674
- (subdivisions.includes('us-states') ||
675
- regions.some((r) => r.layer === 'us-state')) &&
676
- !regions.some((r) => r.layer === 'country' && r.iso !== 'US') &&
677
- !anyNonUsPoi;
678
-
830
+ const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
831
+ // albers-usa is the national US composite (insets AK/HI when referenced, the
832
+ // conic projects neighbour land around the states). Choose it exactly when the
833
+ // map is US-oriented (#13): US content plus only US/Canada/Mexico elsewhere.
834
+ // Content reaching BEYOND North America fails `usOriented` and stays on a
835
+ // geographic world/regional projection that frames everything. Note: this
836
+ // intentionally snaps a POI-only US city map to the national frame ("show all
837
+ // states") rather than fit-zooming to the cluster on a geographic projection.
838
+ // (§24B.2 — projection is inferred, never configured.)
679
839
  let projection: ProjectionFamily;
680
- const override = parsed.directives.projection;
681
- if (
682
- override === 'equirectangular' ||
683
- override === 'natural-earth' ||
684
- override === 'albers-usa' ||
685
- override === 'mercator'
686
- ) {
687
- projection = override;
688
- } else if (usDominant) {
840
+ if (isPoiOnly && usOriented && lonSpan < US_NATIONAL_LON_SPAN) {
841
+ // Sub-national US POI cluster: regional Mercator (familiar shapes), fit to
842
+ // the floored extent above. The us-states mesh is still drawn (subdivisions
843
+ // pushed via usOriented), so the home state + neighbours frame the dots.
844
+ // albers-usa is reserved for genuinely national-span content below — a local
845
+ // cluster no longer snaps to the whole-nation composite (#13, §24B.2).
846
+ projection = 'mercator';
847
+ } else if (usOriented) {
689
848
  projection = 'albers-usa';
690
- } else if (span > WORLD_SPAN) {
691
- // World/continental scale: equirectangular fills the frame edge-to-edge and
692
- // never clips the continents at the boundary (naturalEarth's curved sides
693
- // overrun a corner-based fit). `projection natural-earth` opts back into the
694
- // curved look explicitly.
849
+ } else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
850
+ // World/multi-continent scale (or a polar-reaching frame). Every world map —
851
+ // data choropleth OR dataless reference gets equirectangular: a clean,
852
+ // conventional rectangular wall-map frame. (Trade: not equal-area, so a
853
+ // choropleth's high-latitude regions stretch vertically — accepted for the
854
+ // consistent rectangular look over Equal Earth's area honesty.)
695
855
  projection = 'equirectangular';
696
- } else if (span < MERCATOR_MAX_SPAN) {
697
- projection = 'mercator';
698
856
  } else {
699
- projection = 'equirectangular';
857
+ // Tight clusters AND single-continent regional views: Mercator gives every
858
+ // mid-latitude landmass its familiar conventional shape (a world projection
859
+ // squashes a continent like Europe horizontally).
860
+ projection = 'mercator';
700
861
  }
701
862
 
702
863
  // World-scale framing (R10): a multi-continent spread frames most cleanly as
@@ -711,7 +872,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
711
872
  // would slice off South Africa, southern Argentina, northern Russia, …). The
712
873
  // ≥180° gate leaves regional spreads tight — `region` continents (Europe
713
874
  // ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
714
- // Applies to both world projections (equirectangular default + natural-earth).
875
+ // Applies to the equirectangular world projection (data + reference alike).
715
876
  if (lonSpan >= 180) {
716
877
  extent = [
717
878
  [-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
@@ -724,11 +885,20 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
724
885
  result.edges = edges;
725
886
  result.routes = routes;
726
887
  result.basemaps = {
727
- world: span > WORLD_SPAN ? 'coarse' : 'detail',
888
+ // Tier is intentionally pinned to detail (50m) at ALL scales. Diagrammo maps
889
+ // are presentational (palette tints, relief hachures, POI hubs), not
890
+ // survey-grade — recognizability > generalization: 110m coarse drops the
891
+ // Italian boot to a stump at world scale. `WORLD_SPAN` lives on only for the
892
+ // projection decision (the `usOriented`/`span > WORLD_SPAN` chain above); it
893
+ // no longer gates basemap resolution.
894
+ // `worldCoarse` is still loaded — it's the authoritative name/bbox index
895
+ // (featureIndex, featureBboxPrimary), not dead code.
896
+ world: 'detail',
728
897
  subdivisions,
729
898
  };
730
899
  result.extent = extent;
731
900
  result.projection = projection;
901
+ result.poiFrameContainers = containerRegionIds;
732
902
  result.error = parsed.error ?? firstError(diagnostics);
733
903
  // `Writable` widens the GeoExtent tuple to an array; the runtime value is a
734
904
  // correct GeoExtent, so cast back on return (through unknown — tuple vs array).