@diagrammo/dgmo 0.20.0 → 0.20.2

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.
@@ -74,7 +74,8 @@ tag GroupName as <alias>
74
74
  - Alias: optional postfix `as <alias>` per §2A (universal alias syntax — `[A-Za-z][A-Za-z0-9_]{0,11}`)
75
75
  - Inline values also supported: `tag Priority as p Low green, High red`
76
76
  - Color follows the value as a bare trailing token (see §1.5). Capitalize the color word (`Red`, `Yellow`) to keep it as a literal value with no color.
77
- - First entry is the default — reorder to change
77
+ - First entry is the default value — reorder to change
78
+ - The first declared group is active by default (colors nodes immediately); `active-tag <GroupName>` only matters with ≥2 groups to pick a non-first group, and `active-tag none` suppresses all coloring
78
79
  - Must appear before diagram content
79
80
  - Legacy bare shorthand (`tag Priority p`) and `alias` keyword (`tag Priority alias p`) emit `E_TAG_SHORTHAND_REMOVED` per TD-18
80
81
 
@@ -1362,7 +1363,7 @@ recruit crew 1 2 4 c: Quartermaster
1362
1363
  load powder 0.5 1 2 c: Bosun
1363
1364
  ```
1364
1365
 
1365
- Coloring is opt-in: without an `active-tag <GroupName>` directive (or a click in the app), the legend renders all groups as collapsed pills and nodes stay neutral. When a group is active, the activity card's middle (name) band picks up the tag color while the border continues to communicate criticality. Milestone diamonds adopt the tag color across the full pill.
1366
+ The first declared tag group is active by default and colors nodes; `active-tag <GroupName>` only matters with two or more groups when you want a non-first group active, and `active-tag none` suppresses coloring entirely. When a group is active, the activity card's middle (name) band picks up the tag color while the border continues to communicate criticality. Milestone diamonds adopt the tag color across the full pill.
1366
1367
 
1367
1368
  ### Date anchoring
1368
1369
 
@@ -2672,7 +2673,7 @@ Florida score: 51
2672
2673
 
2673
2674
  ### Region fill — categorical (tags)
2674
2675
 
2675
- Uses the universal tag model (§1.3): declare a `tag` group, apply its alias as a key, `active-tag` to color.
2676
+ Uses the universal tag model (§1.3): declare a `tag` group and apply its alias as a key. The first declared group colors regions by default; `active-tag` only selects a different dimension (another group, or the `score` ramp).
2676
2677
 
2677
2678
  ```
2678
2679
  map Global Presence
@@ -2682,7 +2683,6 @@ tag Market as m
2682
2683
  HQ blue
2683
2684
  Region teal
2684
2685
  Prospect orange
2685
- active-tag Market
2686
2686
 
2687
2687
  United States m: HQ
2688
2688
  Germany m: Region
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/advanced.ts CHANGED
@@ -631,10 +631,7 @@ export {
631
631
  } from './echarts';
632
632
  export type { ScatterLabelPoint } from './echarts';
633
633
  export { ScaleContext } from './utils/scaling';
634
- export {
635
- renderLegendSvg,
636
- renderLegendSvgFromConfig,
637
- } from './utils/legend-svg';
634
+ export { renderLegendSvg, renderLegendSvgFromConfig } from './utils/legend-svg';
638
635
  export type { LegendGroupData } from './utils/legend-types';
639
636
  export { LEGEND_HEIGHT, LEGEND_GEAR_PILL_W } from './utils/legend-constants';
640
637
  export { renderLegendD3 } from './utils/legend-d3';
package/src/map/layout.ts CHANGED
@@ -313,12 +313,35 @@ export function layoutMap(
313
313
  // Lakes match the states' resolution. Falls back to the world tiers otherwise.
314
314
  const usCrisp =
315
315
  resolved.projection === 'albers-usa' && wantsUsStates && !!data.naLand;
316
+ // Base world layer. In a US view use the DETAIL tier (full global coverage) so
317
+ // distant context — South America, northern Canada, etc. — is present and can
318
+ // draw when it falls inside the frame. (`naLand` alone is bbox-clipped to lon
319
+ // -140..-52 / lat 10..66, so it has no S. America and a truncated Canada; using
320
+ // it as the base would leave ocean where that land belongs.)
316
321
  const worldTopo = usCrisp
317
- ? data.naLand!
322
+ ? data.worldDetail
318
323
  : resolved.basemaps.world === 'detail'
319
324
  ? data.worldDetail
320
325
  : data.worldCoarse;
321
326
  const worldLayer = decodeLayer(worldTopo);
327
+ // Crisp upgrade: `naLand` is 10m country land (vs the base's 50m) but clipped to
328
+ // a North-America bbox. Swap a country's geometry to the crisp version ONLY when
329
+ // its full (base) bounds lie inside that clip box — so contained neighbours
330
+ // (Mexico, Central America, the Caribbean) sharpen to match the 10m states,
331
+ // while countries the clip would truncate (Canada, Greenland) keep their full
332
+ // base shape. Coast off-frame still bleeds; nothing is lost.
333
+ if (usCrisp && data.naLand) {
334
+ // NA clip bbox from the data build (scripts/build-map-data.mjs NA_BBOX).
335
+ const [nbW, nbS, nbE, nbN] = [-140, 10, -52, 66];
336
+ const crisp = decodeLayer(data.naLand);
337
+ for (const [iso, cf] of crisp) {
338
+ const base = worldLayer.get(iso);
339
+ if (!base) continue; // crisp-only id with no base → skip (avoid orphans)
340
+ const [[bw, bs], [be, bn]] = geoBounds(base as never);
341
+ if (bw >= nbW && be <= nbE && bs >= nbS && bn <= nbN)
342
+ worldLayer.set(iso, cf);
343
+ }
344
+ }
322
345
  const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
323
346
 
324
347
  // Land is a muted green; the ocean/backdrop is blue. Scored/tagged regions
@@ -571,6 +594,19 @@ export function layoutMap(
571
594
  return p ? stretch(p[0], p[1]) : null;
572
595
  };
573
596
  } else {
597
+ // Clip the projected geometry to the canvas. fitExtent frames the focus
598
+ // region, but the rest of the world mesh still projects to coordinates far
599
+ // off-canvas — invisible under our viewBox, but they bloat the SVG and,
600
+ // critically, blow up any downstream getBBox()/bbox recompute (remark-dgmo
601
+ // embeddings tighten the viewBox to real content bounds, which would
602
+ // otherwise shrink the map to a dot). clipExtent trims the path `d` data to
603
+ // the viewport so drawn content == frame. Point projection (POIs/edges,
604
+ // albers-usa coast sampling) ignores clipExtent so positions are unaffected,
605
+ // and the AK/HI insets use their own dedicated projection — both safe.
606
+ projection.clipExtent([
607
+ [0, 0],
608
+ [width, height],
609
+ ]);
574
610
  path = geoPath(projection);
575
611
  project = (lon, lat) => projection([lon, lat]) ?? null;
576
612
  }
@@ -777,67 +813,72 @@ export function layoutMap(
777
813
  // unclipped conic doesn't paint frame-filling garbage; the us-states layer
778
814
  // itself is never culled (every conus state is in frame by construction).
779
815
  const conusFit = resolved.projection === 'albers-usa' && !!usLayer;
780
- const cullExtent = conusFit
816
+ // Extent used only to classify a near-global view (draw everything) vs a
817
+ // regional one (cull to the canvas). For an albers fit that's the CONUS bounds;
818
+ // else the resolved data extent.
819
+ const classifyExtent = conusFit
781
820
  ? (geoBounds(fitTarget as never) as [[number, number], [number, number]])
782
821
  : resolved.extent;
783
- const [[exW, exS], [exE, exN]] = cullExtent;
784
- const lonSpan = exE - exW;
785
- const latSpan = exN - exS;
786
- // A near-global view draws everything. (albers-usa is handled per-layer at the
787
- // pushRegionLayer calls: the world layer IS culled by the contiguous-US extent
788
- // so far countries don't project to frame-filling garbage, while the us-states
789
- // layer is NEVER culled so Alaska & Hawaii far outside that extent — survive.)
790
- const isGlobalView = lonSpan >= 270 || latSpan >= 130;
791
- const padLon = Math.max(8, lonSpan * 0.35);
792
- const padLat = Math.max(8, latSpan * 0.35);
793
- const vW = exW - padLon;
794
- const vE = exE + padLon;
795
- const vS = exS - padLat;
796
- const vN = exN + padLat;
797
- // Pacific-crossing extents use extended longitudes (e.g. 247 = 113°W), but
798
- // ring vertices are in [-180,180]. Shift each vertex into the extent's frame
799
- // so the overlap test compares like-for-like.
800
- const vLonCenter = (exW + exE) / 2;
822
+ const dLonSpan = classifyExtent[1][0] - classifyExtent[0][0];
823
+ const dLatSpan = classifyExtent[1][1] - classifyExtent[0][1];
824
+ // A near-global view draws everything; a regional one culls each ring to what
825
+ // actually projects onto the canvas (see ringOverlapsView projection-based,
826
+ // so it's correct for the US conic and for route maps alike).
827
+ const isGlobalView = dLonSpan >= 270 || dLatSpan >= 130;
828
+ // Pacific-crossing extents use extended longitudes (e.g. 247 = 113°W), but ring
829
+ // vertices are in [-180,180]. Normalize each ring lon into the view's frame so
830
+ // the circumpolar / antimeridian-sliver guards compare like-for-like.
831
+ const vLonCenter = (classifyExtent[0][0] + classifyExtent[1][0]) / 2;
801
832
  const normLon = (lon: number): number => {
802
833
  let L = lon;
803
834
  while (L < vLonCenter - 180) L += 360;
804
835
  while (L > vLonCenter + 180) L -= 360;
805
836
  return L;
806
837
  };
807
- // True if an outer ring overlaps the padded view box. A ring with a vertex
808
- // inside is in; otherwise a non-wrapping bbox overlap also counts (a big
809
- // coastal polygon whose edge clips the box). Antimeridian-wrapping rings with
810
- // no in-view vertex are dropped they are the frame-fill artifact source.
838
+ // True if an outer ring should be drawn in a regional view. Visibility is
839
+ // decided by the PROJECTION, not a lat/lon box: the ring is kept iff its
840
+ // projected screen bbox intersects the canvas (the projection's clipExtent then
841
+ // trims it to the viewport). A lat/lon box can't model what a conic actually
842
+ // shows — under the US Albers conic, Panama/Colombia land on-canvas at a tall
843
+ // aspect yet sit outside any tidy CONUS-ish box. Two geometry guards still drop
844
+ // the antimeridian/circumpolar rings that would otherwise project to a
845
+ // frame-filling garbage fill (their projected bbox spuriously covers the
846
+ // canvas): a near-circumpolar ring (>270° span) and an antimeridian sliver
847
+ // (raw span >180° but a small normalized arc, e.g. Fiji).
811
848
  type Ring = ReadonlyArray<readonly [number, number]>;
812
849
  const ringOverlapsView = (ring: Ring): boolean => {
813
- let anyIn = false;
814
850
  let loMin = Infinity,
815
851
  loMax = -Infinity,
816
- laMin = Infinity,
817
- laMax = -Infinity,
818
852
  rawMin = Infinity,
819
853
  rawMax = -Infinity;
820
- for (const [rawLon, lat] of ring) {
854
+ for (const [rawLon] of ring) {
821
855
  const lon = normLon(rawLon);
822
- if (lon >= vW && lon <= vE && lat >= vS && lat <= vN) anyIn = true;
823
856
  if (lon < loMin) loMin = lon;
824
857
  if (lon > loMax) loMax = lon;
825
858
  if (rawLon < rawMin) rawMin = rawLon;
826
859
  if (rawLon > rawMax) rawMax = rawLon;
827
- if (lat < laMin) laMin = lat;
828
- if (lat > laMax) laMax = lat;
829
860
  }
830
- // A near-circumpolar ring (Antarctica, polar wrap) spans almost all
831
- // longitudes and projects to a frame-filling fill at regional zoom drop it.
832
- if (loMax - loMin > 270) return false;
833
- // An antimeridian-crossing ring (raw lons span >180 but normalize to a small
834
- // arc e.g. Fiji at 177°E..178°W) inverts under a rotated projection and
835
- // fills the frame. At coarse tier these are tiny islands; drop them in
836
- // regional views rather than paint the whole ocean as land.
837
- if (rawMax - rawMin > 180 && loMax - loMin < 90) return false;
838
- if (anyIn) return true;
839
- if (loMax - loMin > 180) return false; // wraps antimeridian, none in view
840
- return !(loMax < vW || loMin > vE || laMax < vS || laMin > vN);
861
+ if (loMax - loMin > 270) return false; // circumpolar/polar-wrap garbage
862
+ if (rawMax - rawMin > 180 && loMax - loMin < 90) return false; // seam sliver
863
+ // Projected-bbox canvas. project() honours the active projection (and
864
+ // ignores clipExtent, so positions are true), so this is exactly "does any
865
+ // of this ring fall on the canvas".
866
+ let px0 = Infinity,
867
+ py0 = Infinity,
868
+ px1 = -Infinity,
869
+ py1 = -Infinity,
870
+ anyFinite = false;
871
+ for (const [lon, lat] of ring) {
872
+ const p = project(lon, lat);
873
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
874
+ anyFinite = true;
875
+ if (p[0] < px0) px0 = p[0];
876
+ if (p[0] > px1) px1 = p[0];
877
+ if (p[1] < py0) py0 = p[1];
878
+ if (p[1] > py1) py1 = p[1];
879
+ }
880
+ if (!anyFinite) return false;
881
+ return !(px1 < 0 || px0 > width || py1 < 0 || py0 > height);
841
882
  };
842
883
  // Drop a feature's sub-polygons that don't touch the view (e.g. Alaska's
843
884
  // Aleutians on a US feature framed over the Caribbean). Returns null if the
@@ -1364,24 +1405,50 @@ export function layoutMap(
1364
1405
  const text = labelText(p);
1365
1406
  return { text, w: measureLegendText(text, FONT) };
1366
1407
  };
1408
+ // Candidate inline placements around a marker, in escalation order: the two
1409
+ // horizontal sides first (most legible), then above/below for a hub whose
1410
+ // edges all leave sideways and block both flanks (e.g. a POI fed by routes
1411
+ // from the east AND west — Boulder in the route-cluster gauntlet).
1412
+ type Side = 'right' | 'left' | 'above' | 'below';
1413
+ const GAP = 3;
1414
+ const inlineRect = (p: MapLayoutPoi, w: number, side: Side): LabelRect => {
1415
+ switch (side) {
1416
+ case 'right':
1417
+ return { x: p.cx + p.r + GAP, y: p.cy - poiLabH / 2, w, h: poiLabH };
1418
+ case 'left':
1419
+ return {
1420
+ x: p.cx - p.r - GAP - w,
1421
+ y: p.cy - poiLabH / 2,
1422
+ w,
1423
+ h: poiLabH,
1424
+ };
1425
+ case 'above':
1426
+ return {
1427
+ x: p.cx - w / 2,
1428
+ y: p.cy - p.r - GAP - poiLabH,
1429
+ w,
1430
+ h: poiLabH,
1431
+ };
1432
+ case 'below':
1433
+ return { x: p.cx - w / 2, y: p.cy + p.r + GAP, w, h: poiLabH };
1434
+ }
1435
+ };
1367
1436
  const pushInline = (
1368
1437
  p: MapLayoutPoi,
1369
1438
  text: string,
1370
1439
  w: number,
1371
- side: 'right' | 'left'
1440
+ side: Side
1372
1441
  ): void => {
1373
- const tx = side === 'right' ? p.cx + p.r + 3 : p.cx - p.r - 3;
1374
- obstacles.push({
1375
- x: side === 'right' ? tx : tx - w,
1376
- y: p.cy - poiLabH / 2,
1377
- w,
1378
- h: poiLabH,
1379
- });
1442
+ const rect = inlineRect(p, w, side);
1443
+ obstacles.push(rect);
1444
+ const anchor =
1445
+ side === 'right' ? 'start' : side === 'left' ? 'end' : 'middle';
1446
+ const x = side === 'right' ? rect.x : side === 'left' ? rect.x + w : p.cx;
1380
1447
  labels.push({
1381
- x: tx,
1382
- y: p.cy + FONT / 3,
1448
+ x,
1449
+ y: rect.y + poiLabH / 2 + FONT / 3,
1383
1450
  text,
1384
- anchor: side === 'right' ? 'start' : 'end',
1451
+ anchor,
1385
1452
  color: palette.text,
1386
1453
  halo: true,
1387
1454
  haloColor: palette.bg,
@@ -1389,19 +1456,15 @@ export function layoutMap(
1389
1456
  lineNumber: p.lineNumber,
1390
1457
  });
1391
1458
  };
1392
- const inlineFits = (
1393
- p: MapLayoutPoi,
1394
- w: number,
1395
- side: 'right' | 'left'
1396
- ): boolean => {
1397
- const tx = side === 'right' ? p.cx + p.r + 3 : p.cx - p.r - 3;
1398
- const rect: LabelRect = {
1399
- x: side === 'right' ? tx : tx - w,
1400
- y: p.cy - poiLabH / 2,
1401
- w,
1402
- h: poiLabH,
1403
- };
1404
- return rect.x >= 0 && rect.x + rect.w <= width && !collides(rect);
1459
+ const inlineFits = (p: MapLayoutPoi, w: number, side: Side): boolean => {
1460
+ const rect = inlineRect(p, w, side);
1461
+ return (
1462
+ rect.x >= 0 &&
1463
+ rect.x + rect.w <= width &&
1464
+ rect.y >= 0 &&
1465
+ rect.y + rect.h <= height &&
1466
+ !collides(rect)
1467
+ );
1405
1468
  };
1406
1469
 
1407
1470
  // Pre-group POIs by proximity. A tight cluster (offshore platforms, a metro
@@ -1477,12 +1540,11 @@ export function layoutMap(
1477
1540
  if (g.length === 1) {
1478
1541
  const p = g[0]!;
1479
1542
  const { text, w } = labelInfo(p);
1480
- if (inlineFits(p, w, 'right')) {
1481
- pushInline(p, text, w, 'right');
1482
- continue;
1483
- }
1484
- if (inlineFits(p, w, 'left')) {
1485
- pushInline(p, text, w, 'left');
1543
+ const side = (['right', 'left', 'above', 'below'] as const).find((s) =>
1544
+ inlineFits(p, w, s)
1545
+ );
1546
+ if (side) {
1547
+ pushInline(p, text, w, side);
1486
1548
  continue;
1487
1549
  }
1488
1550
  }
@@ -10,12 +10,33 @@
10
10
  // build must inject `MapData` (or supply a fetch/bundle loader) — `resolveMap`
11
11
  // takes `MapData` by DI precisely so the browser path can differ. Do NOT assume
12
12
  // a green Node smoke test proves the browser load.
13
- import { readFile } from 'node:fs/promises';
14
- import { fileURLToPath } from 'node:url';
15
- import { dirname, resolve } from 'node:path';
13
+ //
14
+ // Node builtins (`fs/promises`, `url`, `path`) are imported LAZILY inside
15
+ // `loadMapData` never at module top level — so this file can be pulled into a
16
+ // browser bundle (Obsidian's esbuild `platform: browser`, the app's Vite/Rollup
17
+ // web build) without the bundler trying to resolve `node:*`. Mirrors the
18
+ // `await import('jsdom')` seam in render.ts. The web build injects `MapData` via
19
+ // DI and never calls `loadMapData`, so the dynamic import only runs in Node.
16
20
  import type { MapData } from './resolved-types';
17
21
  import type { BoundaryTopology, Gazetteer } from './data/types';
18
22
 
23
+ type NodeBuiltins = {
24
+ readFile: typeof import('node:fs/promises').readFile;
25
+ fileURLToPath: typeof import('node:url').fileURLToPath;
26
+ dirname: typeof import('node:path').dirname;
27
+ resolve: typeof import('node:path').resolve;
28
+ };
29
+
30
+ async function loadNodeBuiltins(): Promise<NodeBuiltins> {
31
+ const [{ readFile }, { fileURLToPath }, { dirname, resolve }] =
32
+ await Promise.all([
33
+ import('node:fs/promises'),
34
+ import('node:url'),
35
+ import('node:path'),
36
+ ]);
37
+ return { readFile, fileURLToPath, dirname, resolve };
38
+ }
39
+
19
40
  const FILES = {
20
41
  worldCoarse: 'world-coarse.json',
21
42
  worldDetail: 'world-detail.json',
@@ -37,15 +58,22 @@ const CANDIDATE_DIRS = [
37
58
 
38
59
  let cache: Promise<MapData> | undefined;
39
60
 
40
- async function readJson<T>(dir: string, name: string): Promise<T> {
41
- return JSON.parse(await readFile(resolve(dir, name), 'utf8')) as T;
61
+ async function readJson<T>(
62
+ nb: NodeBuiltins,
63
+ dir: string,
64
+ name: string
65
+ ): Promise<T> {
66
+ return JSON.parse(await nb.readFile(nb.resolve(dir, name), 'utf8')) as T;
42
67
  }
43
68
 
44
- async function firstExistingDir(baseDir: string): Promise<string> {
69
+ async function firstExistingDir(
70
+ nb: NodeBuiltins,
71
+ baseDir: string
72
+ ): Promise<string> {
45
73
  for (const rel of CANDIDATE_DIRS) {
46
- const dir = resolve(baseDir, rel);
74
+ const dir = nb.resolve(baseDir, rel);
47
75
  try {
48
- await readFile(resolve(dir, FILES.gazetteer), 'utf8');
76
+ await nb.readFile(nb.resolve(dir, FILES.gazetteer), 'utf8');
49
77
  return dir;
50
78
  } catch {
51
79
  /* try next candidate */
@@ -78,10 +106,10 @@ function validate(data: MapData): MapData {
78
106
  * (dist/cli.cjs, where `import.meta.url` is `undefined` → `fileURLToPath`
79
107
  * throws). The `typeof __dirname` guard is safe in ESM (evaluates to
80
108
  * 'undefined' without a ReferenceError). */
81
- function moduleBaseDir(): string {
109
+ function moduleBaseDir(nb: NodeBuiltins): string {
82
110
  try {
83
111
  const url = import.meta.url;
84
- if (url) return dirname(fileURLToPath(url));
112
+ if (url) return nb.dirname(nb.fileURLToPath(url));
85
113
  } catch {
86
114
  /* CJS: import.meta unavailable — fall through */
87
115
  }
@@ -95,7 +123,8 @@ function moduleBaseDir(): string {
95
123
  * call can retry rather than inheriting a poisoned promise. */
96
124
  export function loadMapData(): Promise<MapData> {
97
125
  cache ??= (async (): Promise<MapData> => {
98
- const dir = await firstExistingDir(moduleBaseDir());
126
+ const nb = await loadNodeBuiltins();
127
+ const dir = await firstExistingDir(nb, moduleBaseDir(nb));
99
128
  const [
100
129
  worldCoarse,
101
130
  worldDetail,
@@ -106,15 +135,15 @@ export function loadMapData(): Promise<MapData> {
106
135
  naLakes,
107
136
  gazetteer,
108
137
  ] = await Promise.all([
109
- readJson<BoundaryTopology>(dir, FILES.worldCoarse),
110
- readJson<BoundaryTopology>(dir, FILES.worldDetail),
111
- readJson<BoundaryTopology>(dir, FILES.usStates),
138
+ readJson<BoundaryTopology>(nb, dir, FILES.worldCoarse),
139
+ readJson<BoundaryTopology>(nb, dir, FILES.worldDetail),
140
+ readJson<BoundaryTopology>(nb, dir, FILES.usStates),
112
141
  // Lakes/rivers/NA assets are optional — older bundles may predate them.
113
- readJson<BoundaryTopology>(dir, FILES.lakes).catch(() => undefined),
114
- readJson<BoundaryTopology>(dir, FILES.rivers).catch(() => undefined),
115
- readJson<BoundaryTopology>(dir, FILES.naLand).catch(() => undefined),
116
- readJson<BoundaryTopology>(dir, FILES.naLakes).catch(() => undefined),
117
- readJson<Gazetteer>(dir, FILES.gazetteer),
142
+ readJson<BoundaryTopology>(nb, dir, FILES.lakes).catch(() => undefined),
143
+ readJson<BoundaryTopology>(nb, dir, FILES.rivers).catch(() => undefined),
144
+ readJson<BoundaryTopology>(nb, dir, FILES.naLand).catch(() => undefined),
145
+ readJson<BoundaryTopology>(nb, dir, FILES.naLakes).catch(() => undefined),
146
+ readJson<Gazetteer>(nb, dir, FILES.gazetteer),
118
147
  ]);
119
148
  return validate({
120
149
  worldCoarse,
@@ -519,8 +519,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
519
519
  // albers-usa only covers US territory: choose it only when the map is truly
520
520
  // US-only — no non-US country region AND no POI outside the US (#13). Without
521
521
  // the POI guard a `default-country US` + Tokyo map projected to garbage.
522
+ // albers-usa is the US-only composite projection — it insets AK/HI and clips
523
+ // out all non-US land. Use it only when the map actually renders US STATES (an
524
+ // explicit `region us-states` or US-state region fills), NOT merely because the
525
+ // POIs happen to be US: a pure POI/route map across the US should stay on a
526
+ // geographic projection so neighbour land (Mexico, Central America, the
527
+ // Caribbean, Canada) still draws.
522
528
  const usDominant =
523
- (inferredCountry === 'US' || subdivisions.includes('us-states')) &&
529
+ (subdivisions.includes('us-states') ||
530
+ regions.some((r) => r.layer === 'us-state')) &&
524
531
  !regions.some((r) => r.layer === 'country' && r.iso !== 'US') &&
525
532
  !anyNonUsPoi;
526
533