@diagrammo/dgmo 0.21.1 → 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 (73) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2003 -466
  3. package/dist/advanced.d.cts +5714 -0
  4. package/dist/advanced.d.ts +5714 -0
  5. package/dist/advanced.js +1999 -466
  6. package/dist/auto.cjs +2048 -449
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +121 -121
  10. package/dist/auto.mjs +2050 -450
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +13 -16
  13. package/dist/editor.js +13 -16
  14. package/dist/highlight.cjs +15 -13
  15. package/dist/highlight.js +15 -13
  16. package/dist/index.cjs +2032 -435
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2034 -436
  20. package/dist/internal.cjs +2003 -466
  21. package/dist/internal.d.cts +5714 -0
  22. package/dist/internal.d.ts +5714 -0
  23. package/dist/internal.js +1999 -466
  24. package/dist/map-data/water-bodies.json +1 -0
  25. package/docs/language-reference.md +20 -9
  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 +0 -1
  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 +12 -1
  37. package/src/boxes-and-lines/renderer.ts +39 -12
  38. package/src/cli.ts +1 -1
  39. package/src/completion.ts +32 -25
  40. package/src/cycle/renderer.ts +14 -1
  41. package/src/d3.ts +8 -2
  42. package/src/editor/highlight-api.ts +4 -0
  43. package/src/editor/keywords.ts +13 -16
  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/types.ts +34 -0
  48. package/src/map/data/water-bodies.json +1 -0
  49. package/src/map/dimensions.ts +117 -0
  50. package/src/map/geo-query.ts +21 -3
  51. package/src/map/geo.ts +47 -1
  52. package/src/map/layout.ts +1300 -251
  53. package/src/map/load-data.ts +10 -2
  54. package/src/map/parser.ts +42 -116
  55. package/src/map/renderer.ts +512 -13
  56. package/src/map/resolved-types.ts +16 -2
  57. package/src/map/resolver.ts +208 -59
  58. package/src/map/types.ts +30 -32
  59. package/src/mindmap/renderer.ts +10 -1
  60. package/src/palettes/atlas.ts +77 -0
  61. package/src/palettes/blueprint.ts +73 -0
  62. package/src/palettes/color-utils.ts +58 -1
  63. package/src/palettes/index.ts +12 -3
  64. package/src/palettes/slate.ts +73 -0
  65. package/src/palettes/tidewater.ts +73 -0
  66. package/src/render.ts +8 -1
  67. package/src/tech-radar/renderer.ts +3 -0
  68. package/src/tech-radar/types.ts +3 -0
  69. package/src/utils/d3-types.ts +5 -0
  70. package/src/utils/legend-layout.ts +21 -4
  71. package/src/utils/legend-types.ts +7 -0
  72. package/src/utils/reserved-key-registry.ts +3 -0
  73. 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 {
@@ -28,12 +28,17 @@ export interface MapData {
28
28
  /** North-America-clipped 10m major lakes (Great Lakes etc.), used in place of
29
29
  * the coarse `lakes` under the albers-usa US view. Optional. */
30
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;
31
35
  gazetteer: Gazetteer;
32
36
  }
33
37
 
34
38
  export type ProjectionFamily =
35
- | 'equirectangular'
39
+ | 'equal-earth'
36
40
  | 'natural-earth'
41
+ | 'equirectangular'
37
42
  | 'albers-usa'
38
43
  | 'mercator';
39
44
 
@@ -116,6 +121,9 @@ export type GeoExtent = [[number, number], [number, number]];
116
121
 
117
122
  export interface ResolvedMap {
118
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. */
119
127
  readonly subtitle?: string;
120
128
  readonly caption?: string;
121
129
  readonly tagGroups: readonly TagGroup[];
@@ -127,6 +135,12 @@ export interface ResolvedMap {
127
135
  readonly routes: readonly ResolvedRoute[];
128
136
  readonly extent: GeoExtent;
129
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[];
130
144
  readonly diagnostics: readonly DgmoError[];
131
145
  readonly error: string | null;
132
146
  }
@@ -24,6 +24,8 @@ import {
24
24
  featureBboxPrimary,
25
25
  unionExtent,
26
26
  fold,
27
+ decodeFeatures,
28
+ regionAt,
27
29
  } from './geo';
28
30
 
29
31
  /** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
@@ -38,17 +40,34 @@ type LookupResult =
38
40
  const WORLD_SPAN = 90;
39
41
  // Mercator is used for everything sub-world (tight clusters AND single-continent
40
42
  // regional views — a mid-latitude continent reads with its familiar conventional
41
- // shape, where equirectangular squashes it). Two guards push back to
42
- // equirectangular: a world/multi-continent `span` (> WORLD_SPAN), or a frame that
43
- // reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's sec(φ) area
44
- // blow-up turns gross. Europe (≈71°N) and East Asia stay comfortably on Mercator.
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.
45
47
  const MERCATOR_MAX_LAT = 80;
46
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;
47
55
  // Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
48
56
  // Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
49
57
  // populated continents fill the frame rather than waste it on ice.
50
58
  const WORLD_LAT_SOUTH = -58;
51
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;
52
71
 
53
72
  // Long-form (or common-alias) country name → the folded Natural-Earth display
54
73
  // name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
@@ -155,6 +174,30 @@ function looksUS(lat: number, lon: number): boolean {
155
174
  return (lon >= -180 && lon <= -64) || lon >= 172;
156
175
  }
157
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
+
158
201
  export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
159
202
  const diagnostics: DgmoError[] = [...parsed.diagnostics]; // seed with parse diags (R14)
160
203
  const err = (line: number, message: string, code?: string): void => {
@@ -166,9 +209,6 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
166
209
 
167
210
  const result: Writable<ResolvedMap> = {
168
211
  title: parsed.title,
169
- ...(parsed.directives.subtitle !== undefined && {
170
- subtitle: parsed.directives.subtitle,
171
- }),
172
212
  ...(parsed.directives.caption !== undefined && {
173
213
  caption: parsed.directives.caption,
174
214
  }),
@@ -178,7 +218,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
178
218
  // renderer's job (step 4) — the resolver only carries `tags` + `tagGroups`
179
219
  // through; it never resolves a tag value to a palette color (#10).
180
220
  directives: { ...parsed.directives },
181
- basemaps: { world: 'coarse', subdivisions: [] },
221
+ basemaps: { world: 'detail', subdivisions: [] },
182
222
  regions: [],
183
223
  pois: [],
184
224
  edges: [],
@@ -187,13 +227,18 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
187
227
  [-180, -85],
188
228
  [180, 85],
189
229
  ] as GeoExtent,
190
- projection: 'natural-earth',
230
+ projection: 'equirectangular',
231
+ poiFrameContainers: [],
191
232
  diagnostics,
192
233
  error: parsed.error,
193
234
  };
194
235
 
195
236
  // Per-layer indexes (never merged — R12; coarse is the authoritative name
196
- // 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.
197
242
  const countryIndex = featureIndex(data.worldCoarse);
198
243
  const usStateIndex = featureIndex(data.usStates);
199
244
  const allNames = [
@@ -201,10 +246,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
201
246
  ...[...usStateIndex.values()].map((v) => v.name),
202
247
  ];
203
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
+
204
255
  // ── US-scope signal (drives the country-vs-state collision, R2) ──
205
256
  const usScoped =
206
- parsed.directives.region === 'us-states' ||
207
- parsed.directives.defaultCountry?.toUpperCase() === 'US' ||
257
+ localeCountry === 'US' ||
208
258
  parsed.regions.some((r) => {
209
259
  const f = fold(r.name);
210
260
  return usStateIndex.has(f) && !countryIndex.has(f);
@@ -267,7 +317,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
267
317
  }
268
318
  } else if (inCountry && inState) {
269
319
  if (usScoped) {
270
- // A US scope (e.g. `region us-states`) makes the state the unambiguous
320
+ // A US scope (e.g. `locale US`) makes the state the unambiguous
271
321
  // intent — resolve silently, no disambiguation warning needed.
272
322
  chosen = { ...inState, layer: 'us-state' };
273
323
  } else {
@@ -404,7 +454,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
404
454
  if (!scope)
405
455
  warn(
406
456
  line,
407
- `"${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.`,
408
458
  'W_MAP_AMBIGUOUS_NAME'
409
459
  );
410
460
  }
@@ -422,11 +472,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
422
472
  // projection test (#13). Named POIs contribute their gazetteer ISO; bare-coord
423
473
  // POIs contribute a rough US-or-not classification (no reverse-geocode).
424
474
  const poiCountries: string[] = [];
425
- 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;
426
480
  const noteCountry = (iso: string | undefined): void => {
427
481
  if (iso) {
428
482
  poiCountries.push(iso);
429
- if (iso !== 'US') anyNonUsPoi = true;
483
+ if (iso === 'US') anyUsPoi = true;
484
+ if (iso !== 'US' && iso !== 'CA' && iso !== 'MX') anyNonNaPoi = true;
430
485
  }
431
486
  };
432
487
 
@@ -434,7 +489,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
434
489
  const deferred: (typeof parsed.pois)[number][] = [];
435
490
  for (const p of parsed.pois) {
436
491
  if (p.pos.kind === 'coords') {
437
- 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;
438
495
  addResolvedPoi(p.pos.lat, p.pos.lon, p);
439
496
  continue;
440
497
  }
@@ -458,18 +515,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
458
515
  // resolved regions AND Pass-A POIs (#3 — POIs were previously voided, so a
459
516
  // POI-only US map never inferred US).
460
517
  const inferredCountry =
461
- parsed.directives.defaultCountry?.toUpperCase() ??
462
- mostCommonCountry(regions, poiCountries) ??
463
- 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;
464
522
 
465
- // Pass B: ambiguous bare names, scoped by inferred default-country.
523
+ // Pass B: ambiguous bare names, scoped by the inferred locale.
466
524
  for (const p of deferred) {
467
525
  if (p.pos.kind !== 'name') continue;
468
526
  const got = lookupName(
469
527
  p.pos.name,
470
528
  p.pos.scope,
471
529
  p.lineNumber,
472
- inferredCountry,
530
+ inferredScope,
473
531
  true
474
532
  );
475
533
  if (got.kind === 'ok') {
@@ -566,7 +624,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
566
624
  sizeValue !== undefined ? { value: sizeValue } : {};
567
625
  if (pos.kind === 'coords') {
568
626
  const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
569
- 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;
570
629
  if (!registry.has(id)) {
571
630
  registerPoi(
572
631
  id,
@@ -590,7 +649,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
590
649
  if (registry.has(f)) return f;
591
650
  const aliased = declaredByName.get(f);
592
651
  if (aliased) return aliased;
593
- const got = lookupName(pos.name, pos.scope, line, inferredCountry, true);
652
+ const got = lookupName(pos.name, pos.scope, line, inferredScope, true);
594
653
  if (got.kind !== 'ok') return null;
595
654
  noteCountry(got.iso);
596
655
  registerPoi(
@@ -649,9 +708,37 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
649
708
  }
650
709
 
651
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
+
652
729
  const subdivisions: Array<'us-states'> = [];
653
- if (usSubdivisionReferenced || parsed.directives.region === 'us-states')
654
- 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');
655
742
 
656
743
  // ── Extent + projection (R5/R10) ──
657
744
  const regionBoxes: GeoExtent[] = [];
@@ -674,48 +761,101 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
674
761
  [-180, -85],
675
762
  [180, 85],
676
763
  ];
677
- 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
+ }
678
826
 
679
827
  const lonSpan = extent[1][0] - extent[0][0];
680
828
  const latSpan = extent[1][1] - extent[0][1];
681
829
  const span = Math.max(lonSpan, latSpan);
682
830
  const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
683
- // albers-usa only covers US territory: choose it only when the map is truly
684
- // US-only no non-US country region AND no POI outside the US (#13). Without
685
- // the POI guard a `default-country US` + Tokyo map projected to garbage.
686
- // albers-usa is the US-only composite projection it insets AK/HI and clips
687
- // out all non-US land. Use it only when the map actually renders US STATES (an
688
- // explicit `region us-states` or US-state region fills), NOT merely because the
689
- // POIs happen to be US: a pure POI/route map across the US should stay on a
690
- // geographic projection so neighbour land (Mexico, Central America, the
691
- // Caribbean, Canada) still draws.
692
- const usDominant =
693
- (subdivisions.includes('us-states') ||
694
- regions.some((r) => r.layer === 'us-state')) &&
695
- !regions.some((r) => r.layer === 'country' && r.iso !== 'US') &&
696
- !anyNonUsPoi;
697
-
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.)
698
839
  let projection: ProjectionFamily;
699
- const override = parsed.directives.projection;
700
- if (
701
- override === 'equirectangular' ||
702
- override === 'natural-earth' ||
703
- override === 'albers-usa' ||
704
- override === 'mercator'
705
- ) {
706
- projection = override;
707
- } 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) {
708
848
  projection = 'albers-usa';
709
849
  } else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
710
- // World/multi-continent scale (or a polar-reaching frame): equirectangular
711
- // fills the frame edge-to-edge, never clips the continents at the boundary
712
- // (naturalEarth's curved sides overrun a corner-based fit), and avoids
713
- // Mercator's gross sec(φ) area blow-up near the poles. `projection
714
- // natural-earth` opts back into the curved look explicitly.
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.)
715
855
  projection = 'equirectangular';
716
856
  } else {
717
857
  // Tight clusters AND single-continent regional views: Mercator gives every
718
- // mid-latitude landmass its familiar conventional shape (equirectangular
858
+ // mid-latitude landmass its familiar conventional shape (a world projection
719
859
  // squashes a continent like Europe horizontally).
720
860
  projection = 'mercator';
721
861
  }
@@ -732,7 +872,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
732
872
  // would slice off South Africa, southern Argentina, northern Russia, …). The
733
873
  // ≥180° gate leaves regional spreads tight — `region` continents (Europe
734
874
  // ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
735
- // Applies to both world projections (equirectangular default + natural-earth).
875
+ // Applies to the equirectangular world projection (data + reference alike).
736
876
  if (lonSpan >= 180) {
737
877
  extent = [
738
878
  [-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
@@ -745,11 +885,20 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
745
885
  result.edges = edges;
746
886
  result.routes = routes;
747
887
  result.basemaps = {
748
- 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',
749
897
  subdivisions,
750
898
  };
751
899
  result.extent = extent;
752
900
  result.projection = projection;
901
+ result.poiFrameContainers = containerRegionIds;
753
902
  result.error = parsed.error ?? firstError(diagnostics);
754
903
  // `Writable` widens the GeoExtent tuple to an array; the runtime value is a
755
904
  // correct GeoExtent, so cast back on return (through unknown — tuple vs array).
package/src/map/types.ts CHANGED
@@ -10,17 +10,13 @@ export type PoiPos =
10
10
  | { readonly kind: 'coords'; readonly lat: number; readonly lon: number }
11
11
  | { readonly kind: 'name'; readonly name: string; readonly scope?: string };
12
12
 
13
- /** `scale <min> <max> [center <n>]` (center reserved for the diverging seam, §24B.12). */
14
- export interface MapScale {
15
- readonly min: number;
16
- readonly max: number;
17
- readonly center?: number;
18
- }
19
-
20
- /** One-shot directives (§24B.2/.7). Values are raw strings unless typed. */
13
+ /** One-shot directives (§24B.2/.7). Values are raw strings unless typed.
14
+ *
15
+ * COSMETIC DEFAULTS ARE ON. Every basemap feature renders by default; the only
16
+ * control is a bare `no-*` opt-out flag that sets the matching `noXxx` boolean.
17
+ * Absent (undefined) = feature ON — so render gates test `!== true`, never
18
+ * `=== true`. There are NO positive opt-in cosmetic flags (§24B.2). */
21
19
  export interface MapDirectives {
22
- region?: string;
23
- projection?: string;
24
20
  /** Legend label for the region value ramp (`region-metric <label>`). */
25
21
  regionMetric?: string;
26
22
  /** Recognized color NAME for the choropleth ramp hue, peeled off the
@@ -30,30 +26,32 @@ export interface MapDirectives {
30
26
  poiMetric?: string;
31
27
  /** Legend label for the edge/leg value (thickness) channel (`flow-metric`). */
32
28
  flowMetric?: string;
33
- scale?: MapScale;
34
- regionLabels?: string; // full | abbrev | off
35
- poiLabels?: string; // off | auto | all
36
- defaultCountry?: string;
37
- defaultState?: string;
29
+ /** Default ISO scope for bare-name resolution (§24B.8): a 3166-1 country
30
+ * (`locale US`) or 3166-2 subdivision (`locale US-GA`). The country part
31
+ * biases ambiguous bare cities to that nation; the subdivision part further
32
+ * prefers that state. Inferred from content; explicit only to steer a guess. */
33
+ locale?: string;
38
34
  activeTag?: string;
39
- noLegend?: boolean;
40
- /** Suppress the Alaska & Hawaii inset boxes drawn under the `albers-usa`
41
- * projection (bare flag `no-insets`). Only meaningful for the US states
42
- * basemap; silently ignored under any other projection. */
43
- noInsets?: boolean;
44
- subtitle?: string;
45
35
  caption?: string;
46
- /** Basemap dress override (bare flags `muted` / `natural`). Forces the
47
- * land/water styling regardless of whether a colouring dimension is active —
48
- * `muted` recedes to neutral grays, `natural` keeps the green/blue reference
49
- * dress. Absent → auto (muted iff a score/tag dimension is active). Lets two
50
- * maps in one deck share a look. */
51
- basemapStyle?: 'muted' | 'natural';
52
- /** Opt-in subtle mountain-range relief shading (bare flag `relief`, §24B.2).
53
- * Draws a shared directional gradient ("degenerate hillshade") clipped to
54
- * each notable mountain-range polygon, over base land and under data fills.
55
- * Off by default; needs the optional `mountain-ranges.json` asset. */
56
- relief?: boolean;
36
+ /** `no-legend` suppress the legend (default-on). */
37
+ noLegend?: boolean;
38
+ /** `no-coastline` suppress the faint nautical-chart water-lines along
39
+ * coasts/shorelines (default-on; geometry derived from drawn region paths). */
40
+ noCoastline?: boolean;
41
+ /** `no-relief` suppress mountain-range relief hachures. Relief is default-on
42
+ * but auto-gated to dataless reference maps at continent/world zoom (§24B.2). */
43
+ noRelief?: boolean;
44
+ /** `no-context-labels` suppress the orientation backdrop (water-body names +
45
+ * unreferenced notable country names), distinct from `region-labels`. */
46
+ noContextLabels?: boolean;
47
+ /** `no-region-labels` — suppress region labels (default-on, full→abbrev→hide). */
48
+ noRegionLabels?: boolean;
49
+ /** `no-poi-labels` — suppress POI labels (default-on, collision-managed auto). */
50
+ noPoiLabels?: boolean;
51
+ /** `no-colorize` — force the plain green-land reference dress even when regions
52
+ * are referenced (regions are auto-coloured by default; §24B colorize). A
53
+ * no-op under data — the basemap is already gray there. */
54
+ noColorize?: boolean;
57
55
  }
58
56
 
59
57
  /** A region-fill: a subdivision name with an optional score and/or tag values
@@ -99,6 +99,9 @@ export function renderMindmap(
99
99
  controlsExpanded?: boolean;
100
100
  onToggleControlsExpand?: () => void;
101
101
  exportMode?: boolean;
102
+ /** When 'app', controls (Descriptions / Depth Colors) are hosted by the app
103
+ * overlay strip — inline gear suppressed, controls row + anchor reserved. */
104
+ controlsHost?: 'app' | 'inline';
102
105
  }
103
106
  ): void {
104
107
  const isExport = !!exportDims;
@@ -119,9 +122,12 @@ export function renderMindmap(
119
122
  .attr('preserveAspectRatio', 'xMidYMin meet')
120
123
  .style('font-family', FONT_FAMILY);
121
124
 
125
+ const appHosted = options?.controlsHost === 'app';
126
+ // App-hosted: Descriptions / Depth Colors move to the app overlay, so a
127
+ // controls-only legend (no tag groups) has nothing left to render.
122
128
  const hasControls =
123
129
  !!options?.onToggleColorByDepth || !!options?.onToggleDescriptions;
124
- const hasLegend = parsed.tagGroups.length > 0 || hasControls;
130
+ const hasLegend = parsed.tagGroups.length > 0 || (hasControls && !appHosted);
125
131
  const fixedLegend = !isExport && hasLegend;
126
132
  const legendReserve = fixedLegend
127
133
  ? getMaxLegendReservedHeight(
@@ -262,6 +268,9 @@ export function renderMindmap(
262
268
  position: { placement: 'top-center', titleRelation: 'below-title' },
263
269
  mode: options?.exportMode ? 'export' : 'preview',
264
270
  ...(controlsToggles !== undefined && { controlsGroup: controlsToggles }),
271
+ ...(options?.controlsHost !== undefined && {
272
+ controlsHost: options.controlsHost,
273
+ }),
265
274
  };
266
275
  const legendState: LegendState = {
267
276
  activeGroup: options?.colorByDepth