@diagrammo/dgmo 0.21.1 → 0.23.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 (87) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2230 -503
  3. package/dist/advanced.d.cts +5731 -0
  4. package/dist/advanced.d.ts +5731 -0
  5. package/dist/advanced.js +2226 -503
  6. package/dist/auto.cjs +2272 -479
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +124 -124
  10. package/dist/auto.mjs +2274 -480
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +16 -16
  13. package/dist/editor.js +16 -16
  14. package/dist/highlight.cjs +18 -13
  15. package/dist/highlight.js +18 -13
  16. package/dist/index.cjs +2253 -465
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2255 -466
  20. package/dist/internal.cjs +2230 -503
  21. package/dist/internal.d.cts +5731 -0
  22. package/dist/internal.d.ts +5731 -0
  23. package/dist/internal.js +2226 -503
  24. package/dist/map-data/PROVENANCE.json +1 -1
  25. package/dist/map-data/gazetteer.json +1 -1
  26. package/dist/map-data/mountain-ranges.json +1 -1
  27. package/dist/map-data/water-bodies.json +1 -0
  28. package/dist/map-data/world-coarse.json +1 -1
  29. package/dist/map-data/world-detail.json +1 -1
  30. package/docs/language-reference.md +55 -9
  31. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  32. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  33. package/gallery/fixtures/map-categorical.dgmo +0 -1
  34. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  35. package/gallery/fixtures/map-coastline.dgmo +7 -0
  36. package/gallery/fixtures/map-colorize.dgmo +11 -0
  37. package/gallery/fixtures/map-direct-color.dgmo +0 -1
  38. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  39. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  40. package/gallery/fixtures/map-route.dgmo +0 -1
  41. package/package.json +1 -1
  42. package/src/advanced.ts +12 -1
  43. package/src/boxes-and-lines/parser.ts +39 -0
  44. package/src/boxes-and-lines/renderer.ts +205 -20
  45. package/src/boxes-and-lines/types.ts +9 -0
  46. package/src/cli.ts +1 -1
  47. package/src/completion.ts +36 -30
  48. package/src/cycle/renderer.ts +14 -1
  49. package/src/d3.ts +20 -6
  50. package/src/editor/highlight-api.ts +4 -0
  51. package/src/editor/keywords.ts +16 -16
  52. package/src/infra/renderer.ts +35 -7
  53. package/src/map/colorize.ts +54 -0
  54. package/src/map/context-labels.ts +429 -0
  55. package/src/map/data/PROVENANCE.json +1 -1
  56. package/src/map/data/README.md +6 -0
  57. package/src/map/data/gazetteer.json +1 -1
  58. package/src/map/data/mountain-ranges.json +1 -1
  59. package/src/map/data/types.ts +34 -0
  60. package/src/map/data/water-bodies.json +1 -0
  61. package/src/map/data/world-coarse.json +1 -1
  62. package/src/map/data/world-detail.json +1 -1
  63. package/src/map/dimensions.ts +117 -0
  64. package/src/map/geo-query.ts +21 -3
  65. package/src/map/geo.ts +47 -1
  66. package/src/map/layout.ts +1408 -266
  67. package/src/map/load-data.ts +10 -2
  68. package/src/map/parser.ts +42 -116
  69. package/src/map/renderer.ts +604 -14
  70. package/src/map/resolved-types.ts +16 -2
  71. package/src/map/resolver.ts +208 -59
  72. package/src/map/types.ts +30 -32
  73. package/src/mindmap/renderer.ts +10 -1
  74. package/src/palettes/atlas.ts +77 -0
  75. package/src/palettes/blueprint.ts +73 -0
  76. package/src/palettes/color-utils.ts +58 -1
  77. package/src/palettes/index.ts +12 -3
  78. package/src/palettes/slate.ts +73 -0
  79. package/src/palettes/tidewater.ts +73 -0
  80. package/src/render.ts +8 -1
  81. package/src/tech-radar/renderer.ts +3 -0
  82. package/src/tech-radar/types.ts +3 -0
  83. package/src/utils/d3-types.ts +5 -0
  84. package/src/utils/legend-layout.ts +21 -4
  85. package/src/utils/legend-types.ts +7 -0
  86. package/src/utils/reserved-key-registry.ts +8 -3
  87. package/src/palettes/bold.ts +0 -67
package/src/map/layout.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  geoPath,
10
10
  geoNaturalEarth1,
11
+ geoEqualEarth,
11
12
  geoEquirectangular,
12
13
  geoConicEqualArea,
13
14
  geoMercator,
@@ -17,7 +18,14 @@ import {
17
18
  type GeoPath,
18
19
  } from 'd3-geo';
19
20
  import { feature } from 'topojson-client';
20
- import { mix, contrastText, relativeLuminance } from '../palettes/color-utils';
21
+ import {
22
+ mix,
23
+ contrastRatio,
24
+ relativeLuminance,
25
+ politicalTints,
26
+ } from '../palettes/color-utils';
27
+ import { buildAdjacency } from './geo';
28
+ import { assignColors } from './colorize';
21
29
  import { resolveColor } from '../colors';
22
30
  import type { PaletteColors } from '../palettes/types';
23
31
  import {
@@ -28,6 +36,7 @@ import {
28
36
  import type { LabelRect, PointCircle } from '../label-layout';
29
37
  import { measureLegendText } from '../utils/legend-constants';
30
38
  import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
39
+ import type { DgmoError } from '../diagnostics';
31
40
  import type { BoundaryTopology } from './data/types';
32
41
  import type {
33
42
  MapData,
@@ -36,6 +45,8 @@ import type {
36
45
  ResolvedEdge,
37
46
  ProjectionFamily,
38
47
  } from './resolved-types';
48
+ import { placeContextLabels } from './context-labels';
49
+ import type { CountryCandidate } from './context-labels';
39
50
 
40
51
  // Minimal GeoJSON shapes (avoid a hard @types/geojson dep; cast at d3 calls).
41
52
  interface GeoFeature {
@@ -58,7 +69,30 @@ const R_MAX = 22;
58
69
  const W_MIN = 1.25; // edge stroke width
59
70
  const W_MAX = 8;
60
71
  const FONT = 11; // on-map label font px
61
- const COLO_EPS = 1.5; // px: POIs closer than this are "co-located"
72
+
73
+ // A few countries have far-flung territory that drags the area-weighted centroid
74
+ // off the mainland (US → Alaska pulls it up into Canada). Anchor their world-layer
75
+ // label/hover point to a mainland [lon, lat] instead. Antimeridian crossers whose
76
+ // body dominates by area (Russia) are NOT listed — their area-weighted centroid
77
+ // already lands on the mainland; only the naive bounding-box centre (which the app
78
+ // previously used for hover) mistook the wrapped sliver for half the shape.
79
+ const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
80
+ US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
81
+ };
82
+ // POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
83
+ // column falls back to hover-only labels when it would sprawl or overflow:
84
+ // - MAX_CLUSTER_EXTENT_FACTOR × min(width,height) = the px diagonal beyond which
85
+ // a cluster is a sprawling chain (its leaders would fan across the map), not a
86
+ // tight blob. Resolution-relative so the decision is stable across zoom — the
87
+ // px threshold is computed per-render, NOT a constant.
88
+ // - MAX_COLUMN_ROWS = the most rows a single column can stack readably.
89
+ // Exported for tests to drive the boundaries directly.
90
+ export const MAX_CLUSTER_EXTENT_FACTOR = 0.18;
91
+ export const MAX_COLUMN_ROWS = 7;
92
+ // WCAG ratio below which a region label needs a halo to read on its own fill.
93
+ // 4.5 = AA for normal text; mid-tone choropleth fills fall below this and get
94
+ // the rescue halo, while saturated/pastel fills (Texas, light land) clear it.
95
+ const REGION_LABEL_HALO_RATIO = 4.5;
62
96
  // % palette-green of bg for unscored land — a VERY faded green so every map
63
97
  // (plain reference OR data-coloured) wears the same subtle dress and the green
64
98
  // never competes with saturated tag/score tints. Dark lifts a touch off the
@@ -70,11 +104,16 @@ const LAND_TINT_DARK = 24;
70
104
  // — the generic 25% shape tint washes out and lets the olive land dominate.
71
105
  const TAG_TINT_LIGHT = 60;
72
106
  const TAG_TINT_DARK = 68;
73
- // % palette-blue of bg for the ocean / backdrop — a VERY faded blue, matching
74
- // the land's subtlety so the whole basemap reads as a quiet dress under the data.
75
- const WATER_TINT_LIGHT = 13;
76
- const WATER_TINT_DARK = 14;
107
+ // % palette-blue of bg for the ocean / backdrop — a faded blue, kept light
108
+ // enough not to compete with saturated blue/green data hues but distinctly
109
+ // bluer than the land so the sea reads as water rather than blank canvas.
110
+ const WATER_TINT_LIGHT = 24;
111
+ const WATER_TINT_DARK = 24;
77
112
  const RIVER_WIDTH = 1.3; // px stroke width for river lines
113
+ // Compact breakpoint (decision D2): below this effective render width a wide
114
+ // extent reads as zoomed-out — prefer abbreviated region labels and suppress
115
+ // relief, regardless of geographic extent.
116
+ const COMPACT_WIDTH_PX = 480;
78
117
  // Relief (mountain-range shading). A projected range below this px² area is
79
118
  // dropped (no confetti slivers at world zoom).
80
119
  const RELIEF_MIN_AREA = 12; // px²
@@ -83,16 +122,49 @@ const RELIEF_MIN_DIM = 2; // px
83
122
  // Relief = horizontal hachure lines clipped to each range: a subtle
84
123
  // dark-on-light / light-on-dark texture that reads as "mountains here". Spacing
85
124
  // is SCREEN-space so density is constant regardless of zoom (geo-space spacing
86
- // would collapse a small range to 1–2 lines and read as a glitch). Kept FAINT:
87
- // thin sub-pixel lines drawn with a non-scaling stroke (constant device width at
88
- // any zoom/DPR) and low-contrast colour. NOT crispEdgesthat snaps the stroke
89
- // to a solid ~1px in WebKit and reads far too heavy; plain AA keeps them whisper-thin.
90
- const RELIEF_HATCH_SPACING = 3; // px between lines
91
- const RELIEF_HATCH_WIDTH = 0.25; // px stroke
125
+ // would collapse a small range to 1–2 lines and read as a glitch). Drawn with a
126
+ // non-scaling stroke (constant device width at any zoom/DPR) and a low-contrast
127
+ // colour so it reads as faint, fine terrain hachure dense thin lines that are
128
+ // almost indistinguishable as individual strokes (a whisper of texture, not
129
+ // stripes). NOT crispEdges that snaps the stroke to a solid ~1px in WebKit and
130
+ // reads too heavy; plain AA keeps the lines soft. The width is kept just ABOVE
131
+ // sub-pixel: at ~0.15px the AA fuzz spreads each line to ~1px and tight spacing
132
+ // merges them into a flat grey wash (a "blob"). 0.25px every 1.5px stays a fine,
133
+ // faint hatch on both zoomed-out world maps and zoomed-in regional views.
134
+ const RELIEF_HATCH_SPACING = 1.5; // px between lines
135
+ const RELIEF_HATCH_WIDTH = 0.2; // px stroke
92
136
  // % of the DARK reference (palette.bg on dark themes, palette.text on light)
93
137
  // blended into the land colour — so the lines read DARKER than the land in both
94
138
  // themes (palette.text alone flips to light on dark themes).
95
- const RELIEF_HATCH_STRENGTH = 32;
139
+ const RELIEF_HATCH_STRENGTH = 26;
140
+ // Coastline water-lines (opt-in `coastline`, §24B.2). N equal-width coast-parallel
141
+ // rings on the water side, evenly spaced and FADING seaward — the antique
142
+ // nautical-chart depth-contour look. Offshore distances + thickness are
143
+ // SCREEN-space FRACTIONS of min(w,h) so the rings stay a constant fraction of the
144
+ // canvas at ANY export size and ANY geographic extent (a decorative screen-space
145
+ // cue, not a geographic offset — ADR-3). Tuned faint, water-toned, low-contrast.
146
+ // minExtent = per-subpath degenerate-ring floor. Kept just above zero so EVERY
147
+ // island — down to the smallest specks — grows coast rings; it only drops
148
+ // sub-pixel/degenerate subpaths that would render nothing (R11). (Earlier it
149
+ // culled small islands to de-noise world maps, but every island should carry the
150
+ // nautical hashing, so the floor is now a bare degenerate guard.)
151
+ // INVARIANT (load-bearing): COASTLINE_STEP > COASTLINE_THICKNESS — i.e. every
152
+ // ring's d_k + thickness < d_(k+1). The renderer draws outer→inner; ring k's
153
+ // colour band reaches radius d_k+thickness and its flat-water overdraw reaches
154
+ // d_k. If a ring's band reached the next ring out, the inner overdraw would erase
155
+ // it. Keep step > thickness; a layout test pins it (map-layout.test.ts).
156
+ const COASTLINE_RING_COUNT = 5; // discrete coast-parallel rings
157
+ const COASTLINE_D0 = 0.0016; // innermost ring offshore distance (frac of min dim)
158
+ const COASTLINE_STEP = 0.0028; // spacing between rings (frac of min dim)
159
+ const COASTLINE_THICKNESS = 0.0014; // ring width — SAME for every ring (frac)
160
+ const COASTLINE_OPACITY_NEAR = 0.5; // innermost ring opacity
161
+ const COASTLINE_OPACITY_FAR = 0.1; // outermost ring opacity (gradual fade)
162
+ const COASTLINE_MIN_EXTENT = 0.0006; // degenerate-ring floor (frac of min dim)
163
+ const COASTLINE_MIN_EXTENT_GLOBAL = 0.0006; // same at world zoom — ring every island
164
+ // Water-line tone: mix regionStroke into water. LESS water than `lakeStroke`
165
+ // (mix 45) so the offshore lines carry a touch MORE contrast than the existing
166
+ // coast stroke and stay distinguishable from it (R10/F14).
167
+ const COASTLINE_STROKE_MIX = 32;
96
168
  // % palette-gray of bg for non-US neighbour land. Higher on dark so it reads as
97
169
  // a clear gray rather than sinking into the dark background.
98
170
  const FOREIGN_TINT_LIGHT = 30;
@@ -104,8 +176,16 @@ const FOREIGN_TINT_DARK = 62;
104
176
  // saturation. Plain reference maps keep neighbour land at the fuller gray tint.
105
177
  const MUTED_FOREIGN_LIGHT = 28; // neighbour land — recessive gray, not green
106
178
  const MUTED_FOREIGN_DARK = 16;
107
- const COLO_R = 9; // spiderfy radius
179
+ const COLO_R = 9; // spiderfy ring radius floor (px)
108
180
  const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
181
+ // Coincident-POI spiderfy (stacks): two dots "stack" when their centre distance is
182
+ // below (rA+rB)*STACK_OVERLAP — i.e. the markers visibly overlap. A ≥2-member stack
183
+ // collapses to a single ringed `+N` badge at rest and fans out on click; export
184
+ // renders the expanded fan directly (all labels visible). Distinct-but-dense
185
+ // clusters (centres farther than combined radii) are untouched — current behavior.
186
+ const STACK_OVERLAP = 1.0; // overlap factor for the coincidence threshold
187
+ const STACK_RING_MAX = 8; // ≤ this many → even circle; more → golden-angle spiral
188
+ const STACK_RING_GAP = 4; // px min gap between adjacent expanded dots
109
189
  const FAN_STEP = 16; // px perpendicular offset between parallel edges
110
190
  const ARC_CURVE_FRAC = 0.18; // default arc bow as a fraction of leg length
111
191
 
@@ -114,6 +194,9 @@ export interface MapLayoutRegion {
114
194
  readonly d: string; // SVG path data
115
195
  readonly fill: string;
116
196
  readonly stroke: string;
197
+ /** Human-readable display name (e.g. "France", "California"). Set for EVERY
198
+ * region — authored and base/context alike — and emitted as
199
+ * `data-region-name` so the app can show it on hover. */
117
200
  readonly label?: string;
118
201
  readonly lineNumber: number;
119
202
  readonly layer: 'base' | 'country' | 'us-state';
@@ -123,6 +206,14 @@ export interface MapLayoutRegion {
123
206
  /** The region's tag values keyed by group (lowercased) — emitted as
124
207
  * `data-tag-<group>` so the app can highlight on legend-entry hover. */
125
208
  readonly tags?: Readonly<Record<string, string>>;
209
+ /** Area-weighted screen centroid (px) of the DRAWN geometry — emitted as
210
+ * `data-label-x`/`data-label-y` so the app can anchor the hover label here
211
+ * instead of the path's bounding-box centre. The bbox centre breaks for
212
+ * antimeridian crossers (Russia's wrapped Chukotka sliver pins the box's left
213
+ * edge to the far side of the map, dropping the centre into the Atlantic); the
214
+ * area-weighted centroid stays on the body. Honours WORLD_LABEL_ANCHORS. */
215
+ readonly labelX?: number;
216
+ readonly labelY?: number;
126
217
  }
127
218
 
128
219
  /** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
@@ -142,6 +233,12 @@ export interface MapLayoutInset {
142
233
  * un-fitted `alaskaProjection()`/`hawaiiProjection()` factories would invert
143
234
  * to garbage, so the geo-query inverts against THIS instance. */
144
235
  readonly projection: GeoProjection;
236
+ /** Neighbour land (e.g. Canada beside Alaska) projected with this inset's
237
+ * fitted projection and clipped to the box — drawn BEHIND the state so a land
238
+ * border reads as land, not coast. Without it the state's outer ring buffers
239
+ * outward over open box-ocean and the land border sprouts coastline rings.
240
+ * `undefined` when no neighbour land falls inside the box. */
241
+ readonly contextLand?: { readonly d: string; readonly fill: string };
145
242
  }
146
243
 
147
244
  /** Post-projection non-uniform stretch applied to GLOBAL fits (fill-the-canvas).
@@ -171,6 +268,38 @@ export interface MapLayoutPoi {
171
268
  /** Tag values keyed by lowercased group name — emitted as `data-tag-<group>`
172
269
  * so the app can spotlight markers on legend-entry hover (mirrors regions). */
173
270
  readonly tags?: Readonly<Record<string, string>>;
271
+ /** Set when this marker is a member of a coincident stack (spiderfy). Its
272
+ * `cx/cy` is the EXPANDED ring position (the source-of-truth used by export +
273
+ * the no-JS default); the app collapses the stack to a single badge at rest
274
+ * via `data-cluster-member`. */
275
+ readonly clusterId?: string;
276
+ }
277
+
278
+ /** A coincident POI stack (≥2 markers whose dots overlap). Laid out EXPANDED
279
+ * (members fanned onto a ring/spiral with legs to the centroid) — that geometry
280
+ * is the source of truth: a static export shows every member + label with no
281
+ * special-casing. The renderer ALSO emits a collapsed `+N`-style badge (a neutral
282
+ * dot ringed with the bare count) at the centroid, hidden by default; the app
283
+ * collapses each stack at rest (hide members, show badge) and expands on click. */
284
+ export interface MapLayoutCluster {
285
+ /** Stable id (the first member's POI id). Mirrored on member dots/labels/legs as
286
+ * `data-cluster-member` and on the badge as `data-cluster`. */
287
+ readonly id: string;
288
+ /** Centroid (collapsed badge position + spider-leg hub). */
289
+ readonly cx: number;
290
+ readonly cy: number;
291
+ /** Member count = badge text (bare `N`, RQ1). */
292
+ readonly count: number;
293
+ /** Radius of the transparent pointer hit-area centred on the centroid — covers
294
+ * the collapsed badge AND the expanded dot ring so a hover/click anywhere over
295
+ * the stack drives the spiderfy controller. */
296
+ readonly hitR: number;
297
+ /** Spider legs: centroid → each expanded member dot (member's own colour). */
298
+ readonly legs: ReadonlyArray<{
299
+ readonly x2: number;
300
+ readonly y2: number;
301
+ readonly color: string;
302
+ }>;
174
303
  }
175
304
 
176
305
  /** A drawn connector -- an edge or a route leg (same geometry contract). */
@@ -182,6 +311,17 @@ export interface MapLayoutLeg {
182
311
  readonly label?: string;
183
312
  readonly labelX?: number;
184
313
  readonly labelY?: number;
314
+ /** Text colour for the label — contrast-picked against the background fill the
315
+ * label sits on (the choropleth/tag region under it, or land/water), so a
316
+ * freight tag over a dark scored country reads light, over pale land reads
317
+ * dark. Absent ⇒ renderer falls back to the muted default. */
318
+ readonly labelColor?: string;
319
+ /** Whether the label needs a halo. Only set when the chosen text colour's
320
+ * contrast against the underlying fill is marginal (mid-tone fills); clear
321
+ * fills get no ghost. */
322
+ readonly labelHalo?: boolean;
323
+ /** Halo colour (opposite lightness of `labelColor`) when {@link labelHalo}. */
324
+ readonly labelHaloColor?: string;
185
325
  readonly lineNumber: number;
186
326
  }
187
327
 
@@ -202,6 +342,24 @@ export interface PlacedLabel {
202
342
  /** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
203
343
  * the label + leader so the app can spotlight the dot on label hover. */
204
344
  readonly poiId?: string;
345
+ /** Cartographic italic (context-label water names, §24B). Default upright. */
346
+ readonly italic?: boolean;
347
+ /** Cartographic letter-spacing in px (context-label water names). Default 0. */
348
+ readonly letterSpacing?: number;
349
+ /** Pre-wrapped display lines (context-label water names — §24B). When present
350
+ * the renderer stacks these as centred tspans instead of `text`; `text` keeps
351
+ * the single-string form for hit-testing/measurement. Absent ⇒ single line. */
352
+ readonly lines?: readonly string[];
353
+ /** Hover-only label: emitted invisible (opacity 0 + `data-poi-hidden`) in the
354
+ * preview and revealed on POI/label hover; OMITTED entirely from static
355
+ * export. Set when a POI cluster can't place its labels cleanly (see the
356
+ * extent/count/clean gate in the POI-label block). Default-undefined =
357
+ * visible. Hidden labels are NOT pushed into `obstacles`. */
358
+ readonly hidden?: boolean;
359
+ /** Set when this label belongs to a coincident-stack member (spiderfy). Emitted
360
+ * visible (export + expanded view) but tagged `data-cluster-member` so the app
361
+ * hides it when the stack is collapsed to its badge. */
362
+ readonly clusterMember?: string;
205
363
  readonly lineNumber: number;
206
364
  }
207
365
 
@@ -245,6 +403,27 @@ export interface MapLayoutReliefHatch {
245
403
  readonly width: number;
246
404
  }
247
405
 
406
+ /** Style object for the opt-in coastline water-lines (`coastline`, §24B.2).
407
+ * `null` when the flag is off. Carries only STYLE — no geometry; the renderer
408
+ * buffers the existing region paths (`layout.regions[].d`) and masks them to the
409
+ * water side. `d`/`thickness` are absolute SCREEN px (already resolved from a
410
+ * fraction of the fitted canvas, so they stay proportional across export sizes —
411
+ * ADR-3). */
412
+ export interface MapLayoutCoastlineStyle {
413
+ /** Water-toned line colour (a touch more contrast than `lakeStroke`). */
414
+ readonly color: string;
415
+ /** The 2 coast-parallel lines, inner→outer. `d` = offshore distance,
416
+ * `thickness` = ring width (both screen px), `opacity` fades seaward. */
417
+ readonly lines: ReadonlyArray<{
418
+ readonly d: number;
419
+ readonly thickness: number;
420
+ readonly opacity: number;
421
+ }>;
422
+ /** Per-subpath bbox-extent floor (screen px): rings smaller than this are
423
+ * dropped (de-noise tiny islands, bound the stroke cost — R5/R11). */
424
+ readonly minExtent: number;
425
+ }
426
+
248
427
  export interface MapLayout {
249
428
  readonly width: number;
250
429
  readonly height: number;
@@ -261,8 +440,15 @@ export interface MapLayout {
261
440
  readonly relief: readonly MapLayoutRelief[];
262
441
  /** Hachure style for the relief lines (null = relief off / none survived). */
263
442
  readonly reliefHatch: MapLayoutReliefHatch | null;
443
+ /** Style for the opt-in coastline water-lines (null = `coastline` off). The
444
+ * renderer buffers `regions[]`/`insetRegions[]` paths against this style and
445
+ * masks them to the water side. */
446
+ readonly coastlineStyle: MapLayoutCoastlineStyle | null;
264
447
  readonly legs: readonly MapLayoutLeg[];
265
448
  readonly pois: readonly MapLayoutPoi[];
449
+ /** Coincident POI stacks (spiderfy). Empty when no ≥2-member overlap exists.
450
+ * The renderer draws a collapsed badge per stack; the app collapses/expands. */
451
+ readonly clusters: readonly MapLayoutCluster[];
266
452
  readonly labels: readonly PlacedLabel[];
267
453
  readonly legend: MapLayoutLegend | null;
268
454
  /** Framed AK/HI inset cutouts (albers-usa only; empty otherwise). */
@@ -276,6 +462,10 @@ export interface MapLayout {
276
462
  readonly projection: GeoProjection;
277
463
  /** Non-uniform stretch applied for GLOBAL fits (null for regional fits). */
278
464
  readonly stretch: MapLayoutStretch | null;
465
+ /** Generic layout-time diagnostics channel — currently has no producers, so it
466
+ * is always empty. Kept wired up because callers merge it with the resolver's
467
+ * diagnostics for the editor lint channel. */
468
+ readonly diagnostics: readonly DgmoError[];
279
469
  }
280
470
 
281
471
  export interface LayoutOptions {
@@ -287,6 +477,11 @@ export interface LayoutOptions {
287
477
  * selects the choropleth ramp, a tag-group name selects that group, `'none'`
288
478
  * / `null` clears it. `undefined` = not provided (use the directive/default). */
289
479
  readonly activeGroup?: string | null;
480
+ /** Export-only: when true, suppress the global stretch-fill and contain-fit
481
+ * (letterbox) instead. Set by `mapExportDimensions` when it clamps/floors the
482
+ * canvas away from the content aspect, so the off-aspect canvas doesn't
483
+ * re-distort. The in-app preview pane leaves this unset (keeps stretch-fill). */
484
+ readonly preferContain?: boolean;
290
485
  }
291
486
 
292
487
  interface Size {
@@ -302,13 +497,57 @@ function geomObject(topo: BoundaryTopology): {
302
497
  return topo.objects[key]!;
303
498
  }
304
499
 
305
- /** Decode every feature of a topology into GeoJSON, keyed by ISO id. */
500
+ // Cache the (expensive) topojson→GeoJSON decode by topology object identity. The
501
+ // MapData topology objects are stable within a session, so the same layer is
502
+ // decoded once even though the export path now builds the projection twice (once
503
+ // for dimension sizing, once for layout). Keyed by object identity (WeakMap), so
504
+ // it never holds stale data across a data reload. CALLERS MUST TREAT THE RESULT AS
505
+ // IMMUTABLE — `buildMapProjection` copies the world layer before its crisp-upgrade
506
+ // `.set()` so the cached map is never mutated.
507
+ const decodeCache = new WeakMap<BoundaryTopology, Map<string, GeoFeature>>();
508
+
509
+ /** Combine two decoded features that share an ISO id into one MultiPolygon — so a
510
+ * country split across multiple topology geometries (e.g. na-land's `FR`) draws
511
+ * all its parts rather than only the last. Polygon/MultiPolygon coordinates are
512
+ * flattened into a single MultiPolygon ring list; a feature whose geometry is
513
+ * neither is returned unchanged (nothing sensible to merge). */
514
+ function mergeFeatures(a: GeoFeature, b: GeoFeature): GeoFeature {
515
+ const polysOf = (f: GeoFeature): number[][][][] | null => {
516
+ const g = f.geometry as { type?: string; coordinates?: unknown } | null;
517
+ if (!g) return null;
518
+ if (g.type === 'Polygon') return [g.coordinates as number[][][]];
519
+ if (g.type === 'MultiPolygon') return g.coordinates as number[][][][];
520
+ return null;
521
+ };
522
+ const pa = polysOf(a);
523
+ const pb = polysOf(b);
524
+ if (!pa || !pb) return a; // can't merge non-polygonal geometry — keep the first
525
+ return {
526
+ ...a,
527
+ geometry: { type: 'MultiPolygon', coordinates: [...pa, ...pb] },
528
+ };
529
+ }
530
+
531
+ /** Decode every feature of a topology into GeoJSON, keyed by ISO id. Memoized by
532
+ * topology identity — the returned map is shared, so do NOT mutate it (copy first
533
+ * if you need to). Natural-Earth source carries hazards this guards against: a
534
+ * null-geometry sovereignty stub tagged with a real ISO code (e.g. "Ashmore and
535
+ * Cartier Is." shares `AU` with Australia) would otherwise CLOBBER the real
536
+ * country's geometry — `set` keeps the last write. So null geometries are
537
+ * skipped, and a genuine duplicate id (two real geometries, e.g. na-land `FR`)
538
+ * is MERGED into one MultiPolygon instead of one part overwriting the other. */
306
539
  function decodeLayer(topo: BoundaryTopology): Map<string, GeoFeature> {
540
+ const cached = decodeCache.get(topo);
541
+ if (cached) return cached;
307
542
  const out = new Map<string, GeoFeature>();
308
543
  for (const g of geomObject(topo).geometries) {
309
544
  const f = feature(topo as never, g as never) as unknown as GeoFeature;
310
- out.set(g.id, { ...f, id: g.id });
545
+ if (!f.geometry) continue; // null-geometry stub — never renders, must not clobber
546
+ const tagged = { ...f, id: g.id };
547
+ const existing = out.get(g.id);
548
+ out.set(g.id, existing ? mergeFeatures(existing, tagged) : tagged);
311
549
  }
550
+ decodeCache.set(topo, out);
312
551
  return out;
313
552
  }
314
553
 
@@ -329,21 +568,55 @@ function projectionFor(family: ProjectionFamily): GeoProjection {
329
568
  return usConusProjection();
330
569
  case 'mercator':
331
570
  return geoMercator();
571
+ case 'equal-earth':
572
+ // Equal-area pseudocylindrical: areas stay honest so a choropleth's shading
573
+ // isn't distorted by projection (the default for *data* world maps).
574
+ return geoEqualEarth();
575
+ case 'equirectangular':
576
+ // Plate carrée: straight lat/lon grid, fully rectangular frame. The default
577
+ // for dataless *reference* world maps — a clean conventional wall-map look.
578
+ return geoEquirectangular();
332
579
  case 'natural-earth':
580
+ // Curved pseudocylindrical compromise. Retained for completeness; areas are
581
+ // only approximately preserved.
333
582
  return geoNaturalEarth1();
334
- case 'equirectangular':
335
583
  default:
336
- // Plate carrée: x = λ, y = -φ. Cylindrical, so the extent's four CORNERS
337
- // are its projected extremes — fitExtent frames it edge-to-edge with no
338
- // bulge overflow (unlike naturalEarth, whose curved sides overrun a
339
- // corner fit and clip the continents). Fills the rectangle: no rounded
340
- // gray corners, no split landmass at the frame edge.
341
584
  return geoEquirectangular();
342
585
  }
343
586
  }
344
587
 
345
588
  /** US state ISO codes that render as insets (drawn off the conus). */
346
589
  const INSET_STATES = new Set(['US-AK', 'US-HI']);
590
+ // Rough bboxes deciding whether a point sits in Alaska / Hawaii — the AK/HI
591
+ // insets render only when the map references that state (§24B.2). Alaska's
592
+ // Aleutians cross the antimeridian, so its longitude test is two-sided.
593
+ const inAlaska = (lon: number, lat: number): boolean =>
594
+ lat >= 51 && (lon <= -129 || lon >= 172);
595
+ const inHawaii = (lon: number, lat: number): boolean =>
596
+ lat >= 18 && lat <= 23 && lon >= -161 && lon <= -154;
597
+ /** US states that visually abut a foreign country (Canada `CA` / Mexico `MX`) in
598
+ * the drawn map — a fixed, extent-independent geographic fact. Used ONLY by the
599
+ * colorize pass to bridge the US-states and world topologies (which share no
600
+ * TopoJSON arcs) so a border state never shares a hue with the country it touches
601
+ * (§24B colorize). Great-Lakes water-gap states (OH/PA) are excluded — they don't
602
+ * visually touch Canada's drawn polygon. */
603
+ const FOREIGN_BORDER: Readonly<Record<string, readonly string[]>> = {
604
+ CA: [
605
+ 'US-AK',
606
+ 'US-WA',
607
+ 'US-ID',
608
+ 'US-MT',
609
+ 'US-ND',
610
+ 'US-MN',
611
+ 'US-MI',
612
+ 'US-NY',
613
+ 'US-VT',
614
+ 'US-NH',
615
+ 'US-ME',
616
+ ],
617
+ MX: ['US-CA', 'US-AZ', 'US-NM', 'US-TX'],
618
+ };
619
+
347
620
  /** US territories excluded from the contiguous-US fit frame. */
348
621
  const US_NON_CONUS = new Set([
349
622
  'US-AK',
@@ -392,54 +665,277 @@ export function mapNeutralLandColor(
392
665
  );
393
666
  }
394
667
 
395
- export function layoutMap(
396
- resolved: ResolvedMap,
397
- data: MapData,
398
- size: Size,
399
- opts: LayoutOptions
400
- ): MapLayout {
401
- const { palette, isDark } = opts;
402
- const { width, height } = size;
668
+ /** Result of {@link buildMapProjection}: the (fresh, un-fitted) projection, fit
669
+ * target, global/regional classification, and decoded basemap layers — all
670
+ * derived from `(resolved, data)` alone (NOT canvas-size dependent). `layoutMap`
671
+ * consumes these then does the size-dependent `fitExtent` + stretch/clip;
672
+ * `mapContentAspect` consumes `projection`/`fitTarget` (+ the layers, for the
673
+ * contain-fit ink bounds). MUST be rebuilt per call — d3 projections are mutated
674
+ * in place by `fitExtent`/`clipExtent`, so the instance is never shared. */
675
+ export interface MapProjectionBuild {
676
+ readonly projection: GeoProjection;
677
+ readonly fitTarget: GeoFC;
678
+ /** ≥270° lon or ≥130° lat span ⇒ global (stretch-fill) vs regional (contain). */
679
+ readonly fitIsGlobal: boolean;
680
+ readonly worldLayer: Map<string, GeoFeature>;
681
+ readonly usLayer: Map<string, GeoFeature> | null;
682
+ readonly usCrisp: boolean;
683
+ readonly wantsUsStates: boolean;
684
+ /** The RAW world topology `worldLayer` derives from (coarse vs detail). Carried
685
+ * out so the colorize pass can build arc-adjacency on the same source the
686
+ * drawn countries came from — memoized on this stable asset object. */
687
+ readonly worldTopo: BoundaryTopology;
688
+ }
403
689
 
690
+ /** Build the projection, fit target, and decoded basemap layers for a resolved
691
+ * map. Extracted from `layoutMap` so the export-dimension helper
692
+ * (`mapContentAspect`) frames the canvas with the IDENTICAL projection + fit
693
+ * target the renderer draws with — divergence here would mismatch the canvas
694
+ * aspect against the geometry. The returned projection has `.rotate` applied but
695
+ * NOT `.fitExtent` (that is canvas-size dependent and stays in `layoutMap`). */
696
+ export function buildMapProjection(
697
+ resolved: ResolvedMap,
698
+ data: MapData
699
+ ): MapProjectionBuild {
404
700
  // -- Basemap decode --
405
701
  const wantsUsStates = resolved.basemaps.subdivisions.includes('us-states');
406
702
  // In a US (albers-usa + us-states) view the surrounding land was world-atlas
407
703
  // 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
408
704
  // assets are present, swap them in so neighbours (Canada/Mexico) and the Great
409
705
  // Lakes match the states' resolution. Falls back to the world tiers otherwise.
706
+ // Crisp NA assets apply to BOTH the national albers-usa view AND a regional
707
+ // US mercator view (POI-only region framing — e.g. a single state). A
708
+ // US-oriented mercator frame is sub-world and entirely within North America by
709
+ // construction, so the NA-clipped 10m land/lakes fit it; the bbox guard below
710
+ // still keeps non-NA countries on world geometry. Excludes equirectangular
711
+ // (a world US-states choropleth) where the NA clip would crop the globe.
410
712
  const usCrisp =
411
- resolved.projection === 'albers-usa' && wantsUsStates && !!data.naLand;
713
+ (resolved.projection === 'albers-usa' ||
714
+ resolved.projection === 'mercator') &&
715
+ wantsUsStates &&
716
+ !!data.naLand;
412
717
  // Base world layer. In a US view use the DETAIL tier (full global coverage) so
413
718
  // distant context — South America, northern Canada, etc. — is present and can
414
- // draw when it falls inside the frame. (`naLand` alone is bbox-clipped to lon
415
- // -140..-52 / lat 10..66, so it has no S. America and a truncated Canada; using
416
- // it as the base would leave ocean where that land belongs.)
719
+ // draw when it falls inside the frame.
417
720
  const worldTopo = usCrisp
418
721
  ? data.worldDetail
419
722
  : resolved.basemaps.world === 'detail'
420
723
  ? data.worldDetail
421
724
  : data.worldCoarse;
422
- const worldLayer = decodeLayer(worldTopo);
423
- // Crisp upgrade: `naLand` is 10m country land (vs the base's 50m) but clipped to
424
- // a North-America bbox. Swap a country's geometry to the crisp version ONLY when
425
- // its full (base) bounds lie inside that clip box so contained neighbours
426
- // (Mexico, Central America, the Caribbean) sharpen to match the 10m states,
427
- // while countries the clip would truncate (Canada, Greenland) keep their full
428
- // base shape. Coast off-frame still bleeds; nothing is lost.
725
+ // Copy the cached decode — the crisp-upgrade below mutates `worldLayer` via
726
+ // `.set()`, which must not poison the shared `decodeLayer` cache.
727
+ const worldLayer = new Map(decodeLayer(worldTopo));
728
+ // Crisp upgrade: swap a country's geometry to the 10m `naLand` version ONLY
729
+ // when its full (base) bounds lie inside the NA clip box.
429
730
  if (usCrisp && data.naLand) {
430
- // NA clip bbox from the data build (scripts/build-map-data.mjs NA_BBOX).
431
731
  const [nbW, nbS, nbE, nbN] = [-140, 10, -52, 66];
432
732
  const crisp = decodeLayer(data.naLand);
433
733
  for (const [iso, cf] of crisp) {
434
734
  const base = worldLayer.get(iso);
435
735
  if (!base) continue; // crisp-only id with no base → skip (avoid orphans)
436
736
  const [[bw, bs], [be, bn]] = geoBounds(base as never);
737
+ // Keep the base feature's `properties` (the country name) — the crisp
738
+ // `naLand` geometry carries none, and the context-label layer reads the
739
+ // name from here. Without this the label falls back to the bare ISO code.
437
740
  if (bw >= nbW && be <= nbE && bs >= nbS && bn <= nbN)
438
- worldLayer.set(iso, cf);
741
+ worldLayer.set(iso, { ...cf, properties: base.properties });
439
742
  }
440
743
  }
441
744
  const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
442
745
 
746
+ // -- Projection + fit (AR2) --
747
+ // The extent outline sampled as a MultiPoint (NOT a Polygon — a hand-built
748
+ // lat/lon rectangle's spherical winding is ambiguous to d3-geo). Sampled ALONG
749
+ // the four edges so a curved projection (natural-earth) is framed at its bulge.
750
+ const extentOutline = (): GeoFeature => {
751
+ const [[w, s], [e, n]] = resolved.extent;
752
+ const N = 16;
753
+ const coords: Array<[number, number]> = [];
754
+ for (let i = 0; i <= N; i++) {
755
+ const t = i / N;
756
+ const lon = w + (e - w) * t;
757
+ const lat = s + (n - s) * t;
758
+ coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
759
+ }
760
+ return {
761
+ type: 'Feature',
762
+ properties: {},
763
+ geometry: { type: 'MultiPoint', coordinates: coords },
764
+ };
765
+ };
766
+
767
+ let fitFeatures: GeoFeature[];
768
+ if (resolved.projection === 'albers-usa' && usLayer) {
769
+ // Frame the contiguous 48 + DC (insets/territories excluded). The conic
770
+ // projects everything else around it.
771
+ fitFeatures = [...usLayer.entries()]
772
+ .filter(([iso]) => !US_NON_CONUS.has(iso))
773
+ .map(([, f]) => f);
774
+ // Expand the frame to include referenced Canada/Mexico content so a
775
+ // near-border neighbour (e.g. Toronto) is visible rather than bleeding off
776
+ // the canvas edge. Only CA/MX content can reach this branch (the resolver's
777
+ // NA rule), so the frame can only grow toward those neighbours. AK/HI POIs
778
+ // stay insets — excluded here. Content-driven: a neighbour POI adds only its
779
+ // point (US barely shrinks); a neighbour country fill adds its full geometry.
780
+ const neighborPoints: Array<[number, number]> = resolved.pois
781
+ .filter((p) => !inAlaska(p.lon, p.lat) && !inHawaii(p.lon, p.lat))
782
+ .map((p) => [p.lon, p.lat]);
783
+ if (neighborPoints.length > 0) {
784
+ fitFeatures.push({
785
+ type: 'Feature',
786
+ properties: {},
787
+ geometry: { type: 'MultiPoint', coordinates: neighborPoints },
788
+ });
789
+ }
790
+ for (const r of resolved.regions) {
791
+ if (r.layer === 'country' && (r.iso === 'CA' || r.iso === 'MX')) {
792
+ const cf = worldLayer.get(r.iso);
793
+ if (cf) fitFeatures.push(cf);
794
+ }
795
+ }
796
+ } else {
797
+ fitFeatures = [extentOutline()];
798
+ }
799
+ const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
800
+
801
+ const projection = projectionFor(resolved.projection);
802
+ // mercator / natural-earth: rotate to the extent's center longitude BEFORE
803
+ // fitting (rotate changes the bounds fitExtent measures). albers-usa is a
804
+ // US-only composite with NO .rotate -- never call it (AR2).
805
+ if (resolved.projection !== 'albers-usa') {
806
+ let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
807
+ if (centerLon > 180) centerLon -= 360;
808
+ projection.rotate([-centerLon, 0]);
809
+ }
810
+
811
+ // Global vs regional classification (drives stretch-fill vs contain-fit).
812
+ const fitGB = geoBounds(fitTarget as never) as [
813
+ [number, number],
814
+ [number, number],
815
+ ];
816
+ const fitIsGlobal =
817
+ fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
818
+
819
+ return {
820
+ projection,
821
+ fitTarget,
822
+ fitIsGlobal,
823
+ worldLayer,
824
+ usLayer,
825
+ usCrisp,
826
+ wantsUsStates,
827
+ worldTopo,
828
+ };
829
+ }
830
+
831
+ /** Split a projected geoPath `d` into its subpath rings (point arrays). geoPath
832
+ * emits polygons as straight `M`/`L`/`Z` segments (no curves), so a flat parse
833
+ * is exact. Each ring is one subpath (an outer boundary OR a hole); classify
834
+ * outer-vs-hole downstream (e.g. via containment depth or signed area). Used by
835
+ * fill hit-testing here and by the renderer's coastline water-lines. */
836
+ export function parsePathRings(d: string): Array<Array<[number, number]>> {
837
+ const rings: Array<Array<[number, number]>> = [];
838
+ let cur: Array<[number, number]> = [];
839
+ const re = /([MLZ])([^MLZ]*)/g;
840
+ let m: RegExpExecArray | null;
841
+ while ((m = re.exec(d))) {
842
+ if (m[1] === 'Z') {
843
+ if (cur.length) rings.push(cur);
844
+ cur = [];
845
+ continue;
846
+ }
847
+ if (m[1] === 'M' && cur.length) {
848
+ rings.push(cur);
849
+ cur = [];
850
+ }
851
+ const nums = m[2]!.split(/[ ,]+/).map(Number);
852
+ for (let i = 0; i + 1 < nums.length; i += 2) {
853
+ const x = nums[i]!;
854
+ const y = nums[i + 1]!;
855
+ if (Number.isFinite(x) && Number.isFinite(y)) cur.push([x, y]);
856
+ }
857
+ }
858
+ if (cur.length) rings.push(cur);
859
+ return rings;
860
+ }
861
+
862
+ /** Drop antimeridian wrap-slivers from a GLOBAL-view region path. A landmass that
863
+ * crosses ±180° (Russia's Chukotka, the western Aleutians, Fiji…) is clipped into
864
+ * fragments; the far one is a small sliver pinned to the OPPOSITE vertical frame
865
+ * edge — it reads as a stray island floating beside its true continent (e.g. the
866
+ * "island left of Alaska"). We drop any ring that (a) has an edge collinear with
867
+ * the LEFT or RIGHT canvas edge AND (b) is small AND (c) isn't the region's
868
+ * largest ring. The mainland (large, on its own edge) and interior islands (not
869
+ * frame-cut) are kept. Vertical edges only — a ring cut by the top/bottom lat
870
+ * crop is real content, not a wrap. Global-only: regional clipExtent cuts ARE
871
+ * real land at the viewport edge and must survive. */
872
+ function dropAntimeridianWrapSlivers(
873
+ d: string,
874
+ width: number,
875
+ height: number
876
+ ): string {
877
+ const rings = parsePathRings(d);
878
+ if (rings.length <= 1) return d;
879
+ const eps = 0.75;
880
+ const minArea = 0.003 * width * height; // 0.3% of canvas
881
+ const ringArea = (r: ReadonlyArray<[number, number]>): number => {
882
+ let s = 0;
883
+ for (let i = 0; i < r.length; i++) {
884
+ const a = r[i]!;
885
+ const b = r[(i + 1) % r.length]!;
886
+ s += a[0] * b[1] - b[0] * a[1];
887
+ }
888
+ return Math.abs(s) / 2;
889
+ };
890
+ const areas = rings.map(ringArea);
891
+ const maxArea = Math.max(...areas);
892
+ const onVEdge = (
893
+ a: readonly [number, number],
894
+ b: readonly [number, number]
895
+ ): boolean =>
896
+ (Math.abs(a[0]) <= eps && Math.abs(b[0]) <= eps) ||
897
+ (Math.abs(a[0] - width) <= eps && Math.abs(b[0] - width) <= eps);
898
+ let dropped = false;
899
+ const kept = rings.filter((r, idx) => {
900
+ if (areas[idx]! >= maxArea || areas[idx]! >= minArea) return true;
901
+ const touches = r.some((p, i) => onVEdge(p, r[(i + 1) % r.length]!));
902
+ if (touches) {
903
+ dropped = true;
904
+ return false;
905
+ }
906
+ return true;
907
+ });
908
+ if (!dropped) return d;
909
+ return kept
910
+ .map(
911
+ (r) => r.map((p, i) => (i ? 'L' : 'M') + p[0] + ',' + p[1]).join('') + 'Z'
912
+ )
913
+ .join('');
914
+ }
915
+
916
+ export function layoutMap(
917
+ resolved: ResolvedMap,
918
+ data: MapData,
919
+ size: Size,
920
+ opts: LayoutOptions
921
+ ): MapLayout {
922
+ const { palette, isDark } = opts;
923
+ const { width, height } = size;
924
+
925
+ // -- Projection, fit target & basemap decode (shared with mapContentAspect so
926
+ // the export canvas aspect matches the drawn geometry — see buildMapProjection).
927
+ // The projection here has .rotate applied but NOT .fitExtent (done below, as it
928
+ // depends on canvas width/height). --
929
+ const {
930
+ projection,
931
+ fitTarget,
932
+ fitIsGlobal,
933
+ worldLayer,
934
+ usLayer,
935
+ usCrisp,
936
+ worldTopo,
937
+ } = buildMapProjection(resolved, data);
938
+
443
939
  const usContext = usLayer !== null;
444
940
  // Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
445
941
  // colouring dimension is active — defined below, once `activeGroup` is known.
@@ -462,9 +958,14 @@ export function layoutMap(
462
958
  const values = resolved.regions
463
959
  .filter((r) => r.value !== undefined)
464
960
  .map((r) => r.value!);
465
- const scaleOverride = resolved.directives.scale;
466
- const rampMin = scaleOverride ? scaleOverride.min : Math.min(...values);
467
- const rampMax = scaleOverride ? scaleOverride.max : Math.max(...values);
961
+ // Ramp auto-fits (the `scale` directive is gone). For all-non-negative data the
962
+ // low end anchors at 0 so every such choropleth shares a 0 baseline (decision
963
+ // C); mixed-sign data fits data-min→data-max. Only the LOW end is shared —
964
+ // different maxes still differ at the high end (cross-map comparability is not
965
+ // recovered, by design).
966
+ const allNonNegative = values.length > 0 && values.every((v) => v >= 0);
967
+ const rampMin = allNonNegative ? 0 : Math.min(...values);
968
+ const rampMax = Math.max(...values);
468
969
  // Value ramp defaults to red so valued regions stand out against the blue
469
970
  // water (palette.primary is a blue in most palettes and would blend in). A
470
971
  // trailing color on `region-metric` (§24B.3) overrides the hue idiomatically.
@@ -504,20 +1005,14 @@ export function layoutMap(
504
1005
  }
505
1006
  const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
506
1007
 
507
- // Basemap dress. Subject water + land always wear the SAME faded blue/green
508
- // dress (subtle enough that saturated tag/score tints never blend into it), so
509
- // every map looks consistent. `mutedBasemap` now governs only the NEIGHBOUR
510
- // land: when a colouring dimension is active (or `muted` is forced) the
511
- // surrounding world recedes to a paler gray so the subject + its data fills
512
- // dominate; a plain reference map keeps neighbour land at the fuller gray. The
513
- // bare `muted` / `natural` flags force either neighbour treatment regardless
514
- // (so two maps in a deck can match); absent → this auto rule.
515
- const mutedBasemap =
516
- resolved.directives.basemapStyle === 'muted'
517
- ? true
518
- : resolved.directives.basemapStyle === 'natural'
519
- ? false
520
- : activeGroup !== null;
1008
+ // Basemap dress (fixed automatic aesthetic no directive). Subject water +
1009
+ // land always wear the SAME faded blue/green dress (subtle enough that
1010
+ // saturated tag/score tints never blend into it), so every map looks
1011
+ // consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a colouring
1012
+ // dimension is active the surrounding world recedes to a paler gray so the
1013
+ // subject + its data fills dominate; a plain reference map keeps neighbour
1014
+ // land at the fuller gray.
1015
+ const mutedBasemap = activeGroup !== null;
521
1016
  const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
522
1017
  const water = mapBackgroundColor(palette, isDark, mutedBasemap);
523
1018
  const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
@@ -533,6 +1028,70 @@ export function layoutMap(
533
1028
  : FOREIGN_TINT_LIGHT
534
1029
  );
535
1030
 
1031
+ // -- Colorize: content-inferred distinct political fills (§24B) --
1032
+ // Colorize is the DEFAULT dress for any map that is NOT colouring regions by
1033
+ // data. The ONLY two things that turn it off: (1) a data dimension exists on a
1034
+ // region (any `value:` or tag group) — data owns the saturation, so the basemap
1035
+ // recedes to the gray choropleth/categorical dress; or (2) the `no-colorize`
1036
+ // opt-out. Everything else — bare `map`, POI/route-only maps, named regions
1037
+ // without data — gets distinct political pastels (markers/routes draw on top).
1038
+ // Data EXISTENCE (not which dimension is *active*) is the discriminator, so a
1039
+ // tag map viewed with `active-tag none` still keeps its neutral data dress; and
1040
+ // the live-preview `California` → `California value: 92` edit transitions
1041
+ // colorized → choropleth cleanly.
1042
+ const colorizeActive =
1043
+ resolved.directives.noColorize !== true &&
1044
+ !hasRamp &&
1045
+ resolved.tagGroups.length === 0;
1046
+ // Hue per ISO over ONE UNIFIED graph spanning every drawn topology, so no two
1047
+ // bordering regions share a hue — INCLUDING across the international seam. The
1048
+ // world and us-states topologies share no TopoJSON arcs, so neighbors() is blind
1049
+ // to the US↔Canada/Mexico border; those edges are fixed geographic facts (FOREIGN
1050
+ // _BORDER) added explicitly. Coloring is global (whole topologies, not the drawn
1051
+ // subset) and country codes sort before `US-XX`, so a country's colour is decided
1052
+ // before any state is visited → extent-independent (France identical at any width
1053
+ // and in an inset; AC10) and the same whether or not states are drawn. Every drawn
1054
+ // ISO is in the graph, so the lookup never misses → no green leak (F14).
1055
+ const colorByIso = new Map<string, string>();
1056
+ if (colorizeActive) {
1057
+ const adjacency = new Map<string, string[]>();
1058
+ const addEdges = (src: ReadonlyMap<string, readonly string[]>): void => {
1059
+ for (const [iso, ns] of src) {
1060
+ const cur = adjacency.get(iso);
1061
+ if (cur) cur.push(...ns);
1062
+ else adjacency.set(iso, [...ns]);
1063
+ }
1064
+ };
1065
+ addEdges(buildAdjacency(worldTopo)); // countries
1066
+ if (usLayer) {
1067
+ addEdges(buildAdjacency(data.usStates)); // US states
1068
+ // International border seam (US states ↔ Canada/Mexico), both directions —
1069
+ // the two topologies don't share arcs, so this is the only place the seam
1070
+ // is expressible. Skip any endpoint not in the graph (defensive).
1071
+ for (const [country, states] of Object.entries(FOREIGN_BORDER)) {
1072
+ const cn = adjacency.get(country);
1073
+ if (!cn) continue;
1074
+ for (const st of states) {
1075
+ const sn = adjacency.get(st);
1076
+ if (!sn) continue;
1077
+ cn.push(st);
1078
+ sn.push(country);
1079
+ }
1080
+ }
1081
+ }
1082
+ const { byIso, huesNeeded } = assignColors(
1083
+ [...adjacency.keys()],
1084
+ adjacency
1085
+ );
1086
+ const tints = politicalTints(palette, huesNeeded, isDark);
1087
+ for (const [iso, idx] of byIso) colorByIso.set(iso, tints[idx]!);
1088
+ }
1089
+ /** Per-region boundary stroke under colorize. Distinct FILLS aren't enough —
1090
+ * the boundary sells the separation (F10). Darken per-region toward the
1091
+ * palette text so the outline tracks each pastel; width stays the renderer
1092
+ * constant (the darker tone, not weight, does the work — AC12). */
1093
+ const colorizeStroke = (fill: string): string => mix(fill, palette.text, 35);
1094
+
536
1095
  // Score ramp base: a NEUTRAL tint of the page, NOT the (green) land colour —
537
1096
  // blending red toward green produced muddy brown mid-tones that blurred into
538
1097
  // the unscored land. Anchored to a neutral, the ramp is a clean single-hue red
@@ -590,76 +1149,27 @@ export function layoutMap(
590
1149
  * regions, neutral otherwise; a tag group active → that group's tag colour,
591
1150
  * neutral otherwise (value ignored). */
592
1151
  const regionFill = (r: {
1152
+ iso?: string;
593
1153
  value?: number;
594
1154
  color?: string;
595
1155
  tags: Readonly<Record<string, string>>;
596
1156
  }): string => {
597
1157
  const direct = directFill(r.color);
598
- if (direct) return direct;
1158
+ if (direct) return direct; // §24B.4 direct color wins over colorize (F4)
599
1159
  if (activeIsScore) {
600
1160
  return r.value !== undefined ? fillForValue(r.value) : neutralFill;
601
1161
  }
1162
+ // Under colorize (activeGroup === null ⇒ not score) the terminal neutralFill
1163
+ // is replaced by the region's political pastel; the value-path above is dead
1164
+ // here (activeIsScore is false). Data/tag maps are untouched.
1165
+ if (colorizeActive) return (r.iso && colorByIso.get(r.iso)) ?? neutralFill;
602
1166
  return tagFill(r.tags, activeGroup) ?? neutralFill;
603
1167
  };
604
1168
 
605
1169
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
606
1170
 
607
- // -- Projection + fit (AR2, refined) --
608
- // For world projections we fit to the resolver's (padded, never-degenerate)
609
- // extent box — fitting to raw drawn points would collapse to a zero-size
610
- // target (single/coincident POIs → Infinity scale → NaN). albers-usa fits to
611
- // its own conus features (below).
612
- //
613
- // The extent outline sampled as a MultiPoint — NOT a Polygon. A hand-built
614
- // lat/lon rectangle's spherical winding is ambiguous to d3-geo, which can
615
- // read it as the whole-globe complement (→ tiny content framed on a world
616
- // map). Points have no interior/winding ambiguity, so fitExtent frames the
617
- // box exactly. We sample ALONG the four edges (not just the corners) because
618
- // a curved projection (natural-earth) bulges between corners — its widest x
619
- // is at the equator and its lowest/highest y at the central meridian, neither
620
- // of which is a corner. Fitting only corners under-frames the curve, so the
621
- // continents at the frame's top/bottom/sides spill off and clip (S. Africa,
622
- // Argentina, N. Russia). Equirectangular/mercator are linear, so the extra
623
- // samples are redundant-but-harmless there.
624
- const extentOutline = (): GeoFeature => {
625
- const [[w, s], [e, n]] = resolved.extent;
626
- const N = 16;
627
- const coords: Array<[number, number]> = [];
628
- for (let i = 0; i <= N; i++) {
629
- const t = i / N;
630
- const lon = w + (e - w) * t;
631
- const lat = s + (n - s) * t;
632
- coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
633
- }
634
- return {
635
- type: 'Feature',
636
- properties: {},
637
- geometry: { type: 'MultiPoint', coordinates: coords },
638
- };
639
- };
640
-
641
- let fitFeatures: GeoFeature[];
642
- if (resolved.projection === 'albers-usa' && usLayer) {
643
- // Frame the contiguous 48 + DC (insets/territories excluded). The conic
644
- // projects everything else — Canada, Mexico — around it, bleeding off the
645
- // canvas edges so there's no empty water band and no hard clip line.
646
- fitFeatures = [...usLayer.entries()]
647
- .filter(([iso]) => !US_NON_CONUS.has(iso))
648
- .map(([, f]) => f);
649
- } else {
650
- fitFeatures = [extentOutline()];
651
- }
652
- const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
653
-
654
- const projection = projectionFor(resolved.projection);
655
- // mercator / natural-earth: rotate to the extent's center longitude BEFORE
656
- // fitting (rotate changes the bounds fitExtent measures). albers-usa is a
657
- // US-only composite with NO .rotate -- never call it (AR2).
658
- if (resolved.projection !== 'albers-usa') {
659
- let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
660
- if (centerLon > 180) centerLon -= 360;
661
- projection.rotate([-centerLon, 0]);
662
- }
1171
+ // -- Fit the projection to the canvas (size-dependent; the projection + fit
1172
+ // target themselves came from buildMapProjection above). --
663
1173
  // Reserve top padding for the title/subtitle banner ONLY when there are POIs,
664
1174
  // so their markers/labels don't project up under the title (which renders in
665
1175
  // the foreground). A POI-less choropleth needs no reserve — the land fills to
@@ -689,26 +1199,35 @@ export function layoutMap(
689
1199
  // a full canvas), but POI radii + label font sizes are applied in the renderer
690
1200
  // (NOT here), so markers stay round and text stays un-squashed. Regional views
691
1201
  // keep contain-fit: no distortion, neighbour land not cropped.
692
- const fitGB = geoBounds(fitTarget as never) as [
693
- [number, number],
694
- [number, number],
695
- ];
696
- const fitIsGlobal =
697
- fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
1202
+ //
1203
+ // `preferContain` (set by the export-dimension helper when it clamps/floors the
1204
+ // canvas away from the content aspect) suppresses the stretch even for a global
1205
+ // extent: the canvas was intentionally sized off-aspect, so stretching would
1206
+ // re-introduce the very distortion the content-aware sizing removes. We then
1207
+ // contain-fit (letterbox over water) instead. The in-app preview pane never
1208
+ // sets preferContain, so it keeps stretch-filling the pane. (`fitIsGlobal` comes
1209
+ // from buildMapProjection.)
698
1210
  let path: GeoPath;
699
1211
  let project: (lon: number, lat: number) => [number, number] | null;
700
1212
  // Captured for the geo-query (null unless this is a global stretch fit).
701
1213
  let stretchParams: MapLayoutStretch | null = null;
702
- if (fitIsGlobal) {
1214
+ if (fitIsGlobal && !opts.preferContain) {
703
1215
  const cb = geoPath(projection).bounds(fitTarget as never);
704
1216
  const bx0 = cb[0][0];
705
1217
  const by0 = cb[0][1];
706
1218
  const cw = cb[1][0] - bx0;
707
1219
  const ch = cb[1][1] - by0;
708
- const ox = fitBox[0][0];
709
- const oy = fitBox[0][1];
710
- const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
711
- const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
1220
+ // A global stretch-fill runs the world to EVERY edge of the canvas — no
1221
+ // FIT_PAD inset. The equirectangular rectangle is the map, so its edges ARE
1222
+ // the render-area edges (the antimeridian sits exactly on the left/right
1223
+ // edge, not 24px short of it with a coastline ringing the gap). The title
1224
+ // overlays the top; we reserve a top band only when POIs are present (so
1225
+ // their markers don't project up under the foreground title banner).
1226
+ const topReserve = resolved.title && resolved.pois.length > 0 ? topPad : 0;
1227
+ const ox = 0;
1228
+ const oy = topReserve;
1229
+ const sx = cw > 0 ? width / cw : 1;
1230
+ const sy = ch > 0 ? (height - topReserve) / ch : 1;
712
1231
  stretchParams = { sx, sy, ox, oy, bx0, by0 };
713
1232
  const stretch = (x: number, y: number): [number, number] => [
714
1233
  ox + (x - bx0) * sx,
@@ -770,11 +1289,16 @@ export function layoutMap(
770
1289
  name: string;
771
1290
  lineNumber: number;
772
1291
  }[] = [];
773
- if (
774
- resolved.projection === 'albers-usa' &&
775
- usLayer &&
776
- !resolved.directives.noInsets
777
- ) {
1292
+ // AK/HI insets are inferred (no directive): draw a state's inset only when the
1293
+ // map references it (a valued/tagged state or a POI inside it). An all-US map
1294
+ // that names neither frames the contiguous states alone (§24B.2).
1295
+ const akRef =
1296
+ resolved.regions.some((r) => r.iso === 'US-AK') ||
1297
+ resolved.pois.some((p) => inAlaska(p.lon, p.lat));
1298
+ const hiRef =
1299
+ resolved.regions.some((r) => r.iso === 'US-HI') ||
1300
+ resolved.pois.some((p) => inHawaii(p.lon, p.lat));
1301
+ if (resolved.projection === 'albers-usa' && usLayer && (akRef || hiRef)) {
778
1302
  const PAD = 8;
779
1303
  const GAP = 12; // px the top edge rides below the coast
780
1304
  const yB = height - FIT_PAD; // lowest a box may reach (canvas bottom pad)
@@ -861,8 +1385,28 @@ export function layoutMap(
861
1385
  );
862
1386
  const d = geoPath(proj)(f as never) ?? '';
863
1387
  if (!d) return xr;
1388
+ // Neighbour land projected with this same fitted projection, clipped to the
1389
+ // box. Alaska's only land neighbour is Canada; drawing it behind AK turns
1390
+ // the eastern AK/Canada border into a land boundary so it grows no coastline
1391
+ // rings (and fills the box's upper-right corner with recessive context).
1392
+ let contextLand: { d: string; fill: string } | undefined;
1393
+ if (iso === 'US-AK') {
1394
+ const can = worldLayer.get('CA');
1395
+ const cd = can ? (geoPath(proj)(can as never) ?? '') : '';
1396
+ if (cd)
1397
+ contextLand = {
1398
+ d: cd,
1399
+ fill: colorizeActive
1400
+ ? (colorByIso.get('CA') ?? foreignFill)
1401
+ : foreignFill,
1402
+ };
1403
+ }
864
1404
  const r = regionById.get(iso);
865
- let fill = neutralFill;
1405
+ // Inset land reads the SAME colorByIso as the main frame → AK/HI identical
1406
+ // to their main-frame colour (extent-independent; AC10/AC11).
1407
+ let fill = colorizeActive
1408
+ ? (colorByIso.get(iso) ?? neutralFill)
1409
+ : neutralFill;
866
1410
  let lineNumber = -1;
867
1411
  if (r?.layer === 'us-state') {
868
1412
  fill = regionFill(r);
@@ -882,12 +1426,13 @@ export function layoutMap(
882
1426
  // The FITTED inset projection (just fit to this box) — captured so the
883
1427
  // geo-query can invert pixels inside the frame back to AK/HI coords.
884
1428
  projection: proj,
1429
+ ...(contextLand && { contextLand }),
885
1430
  });
886
1431
  insetRegions.push({
887
1432
  id: iso,
888
1433
  d,
889
1434
  fill,
890
- stroke: regionStroke,
1435
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
891
1436
  lineNumber,
892
1437
  layer: 'us-state',
893
1438
  ...(r?.value !== undefined && { value: r.value }),
@@ -901,13 +1446,17 @@ export function layoutMap(
901
1446
  return xr;
902
1447
  };
903
1448
  // AK is the larger state; HI a small island group tucked to its right.
904
- const akRight = placeInset(
905
- 'US-AK',
906
- alaskaProjection(),
907
- FIT_PAD,
908
- width * 0.15
909
- );
910
- placeInset('US-HI', hawaiiProjection(), akRight + 24, width * 0.1);
1449
+ // Each draws only when referenced; HI slides left to FIT_PAD if AK is absent.
1450
+ let akRight = FIT_PAD;
1451
+ if (akRef)
1452
+ akRight = placeInset('US-AK', alaskaProjection(), FIT_PAD, width * 0.15);
1453
+ if (hiRef)
1454
+ placeInset(
1455
+ 'US-HI',
1456
+ hawaiiProjection(),
1457
+ akRef ? akRight + 24 : FIT_PAD,
1458
+ width * 0.1
1459
+ );
911
1460
  }
912
1461
 
913
1462
  // -- Basemap culling --
@@ -963,15 +1512,31 @@ export function layoutMap(
963
1512
  loMax = -Infinity,
964
1513
  rawMin = Infinity,
965
1514
  rawMax = -Infinity;
1515
+ const lons: number[] = [];
966
1516
  for (const [rawLon] of ring) {
967
1517
  const lon = normLon(rawLon);
1518
+ lons.push(lon);
968
1519
  if (lon < loMin) loMin = lon;
969
1520
  if (lon > loMax) loMax = lon;
970
1521
  if (rawLon < rawMin) rawMin = rawLon;
971
1522
  if (rawLon > rawMax) rawMax = rawLon;
972
1523
  }
973
- if (loMax - loMin > 270) return false; // circumpolar/polar-wrap garbage
974
- if (rawMax - rawMin > 180 && loMax - loMin < 90) return false; // seam sliver
1524
+ // OCCUPIED longitude arc (complement of the largest empty gap), NOT the raw
1525
+ // min→max span: a landmass crossing the antimeridian (Russia: points near
1526
+ // −180° AND +180° via Chukotka) has a ~360° min→max span but only a ~171°
1527
+ // occupied arc. The naive `loMax−loMin > 270` test mistook Russia for
1528
+ // circumpolar garbage and dropped all of mainland Russia from regional views.
1529
+ // A truly pole-wrapping ring occupies ~360° (no large gap) and is still
1530
+ // dropped. (#russia-cull)
1531
+ lons.sort((a, b) => a - b);
1532
+ let maxGap = 0;
1533
+ for (let i = 1; i < lons.length; i++)
1534
+ maxGap = Math.max(maxGap, lons[i]! - lons[i - 1]!);
1535
+ if (lons.length > 1)
1536
+ maxGap = Math.max(maxGap, lons[0]! + 360 - lons[lons.length - 1]!);
1537
+ const occupiedArc = 360 - maxGap;
1538
+ if (occupiedArc > 270) return false; // circumpolar/polar-wrap garbage
1539
+ if (rawMax - rawMin > 180 && occupiedArc < 90) return false; // seam sliver
975
1540
  // Projected-bbox ∩ canvas. project() honours the active projection (and
976
1541
  // ignores clipExtent, so positions are true), so this is exactly "does any
977
1542
  // of this ring fall on the canvas".
@@ -1020,7 +1585,7 @@ export function layoutMap(
1020
1585
 
1021
1586
  // View-INDEPENDENT frame-fill guard. An antimeridian-crossing ring whose true
1022
1587
  // occupied longitude arc is small (e.g. Fiji: islands at 177°E and 178°W, a
1023
- // ~5° arc straddling the seam) projects under equirectangular to two slivers
1588
+ // ~5° arc straddling the seam) projects under a world projection to two slivers
1024
1589
  // at opposite frame edges; the fill between them inverts to paint the WHOLE
1025
1590
  // ocean as land. `cullFeatureToView` drops these in a regional view, but a
1026
1591
  // global/world view skips culling — so they must be dropped here regardless.
@@ -1082,7 +1647,14 @@ export function layoutMap(
1082
1647
  for (const [iso, f] of layerFeatures) {
1083
1648
  // Alaska/Hawaii are drawn as insets under albers-usa — skip them in the
1084
1649
  // main conus layer (the conic would otherwise place them far off-frame).
1085
- if (layerKind === 'us-state' && usContext && INSET_STATES.has(iso))
1650
+ // Only albers-usa relocates them to insets; on a world/regional projection
1651
+ // they have no inset and must draw in place from the us-states layer.
1652
+ if (
1653
+ layerKind === 'us-state' &&
1654
+ usContext &&
1655
+ resolved.projection === 'albers-usa' &&
1656
+ INSET_STATES.has(iso)
1657
+ )
1086
1658
  continue;
1087
1659
  // In a US view the us-states layer paints the whole country — drop the
1088
1660
  // redundant US country polygon underneath it (it only adds a coarser base
@@ -1100,12 +1672,22 @@ export function layoutMap(
1100
1672
  // but still drop antimeridian frame-fillers (Fiji et al.).
1101
1673
  const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
1102
1674
  if (!viewF) continue;
1103
- const d = path(viewF as never) ?? '';
1675
+ const raw = path(viewF as never) ?? '';
1676
+ // Global views: strip the wrap-sliver a crossing landmass leaves pinned to
1677
+ // the far edge (Russia's Chukotka beside Alaska). Regional cuts are real.
1678
+ const d = fitIsGlobal
1679
+ ? dropAntimeridianWrapSlivers(raw, width, height)
1680
+ : raw;
1104
1681
  if (!d) continue;
1105
1682
  const isThisLayer = r?.layer === layerKind;
1106
1683
  // Non-US neighbour land in a US view is gray context, not yellow land.
1107
1684
  const isForeign = layerKind === 'country' && usContext && iso !== 'US';
1108
- let fill = isForeign ? foreignFill : neutralFill;
1685
+ // Under colorize EVERY drawn political region — referenced, context, or
1686
+ // neighbour — gets its pastel, so the whole visible set reads as one map
1687
+ // (foreignFill/neutralFill bypassed; F9). The referenced branch below routes
1688
+ // through regionFill (direct color still wins).
1689
+ const baseFill = isForeign ? foreignFill : neutralFill;
1690
+ let fill = colorizeActive ? (colorByIso.get(iso) ?? baseFill) : baseFill;
1109
1691
  let label: string | undefined;
1110
1692
  let lineNumber = -1;
1111
1693
  let layer: MapLayoutRegion['layer'] = 'base';
@@ -1115,15 +1697,30 @@ export function layoutMap(
1115
1697
  lineNumber = r.lineNumber;
1116
1698
  layer = layerKind;
1117
1699
  label = r.name;
1700
+ } else {
1701
+ // Base/context land (not authored): still carry the display name so the
1702
+ // app can show it on hover. Names live on the geo feature's properties
1703
+ // (the same source the resolver/inset/context-label layers read).
1704
+ label = (f.properties as { name?: string } | null)?.name;
1118
1705
  }
1706
+ // Label/hover anchor: a hardcoded mainland anchor when far-flung territory
1707
+ // would skew it, else the area-weighted screen centroid of the drawn shape.
1708
+ // The latter (unlike a bounding-box centre) survives antimeridian crossers.
1709
+ const labelAnchor = WORLD_LABEL_ANCHORS[iso];
1710
+ const c = labelAnchor
1711
+ ? project(labelAnchor[0], labelAnchor[1])
1712
+ : path.centroid(viewF as never);
1713
+ const hasCentroid =
1714
+ c != null && Number.isFinite(c[0]) && Number.isFinite(c[1]);
1119
1715
  regions.push({
1120
1716
  id: iso,
1121
1717
  d,
1122
1718
  fill,
1123
- stroke: regionStroke,
1719
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
1124
1720
  lineNumber,
1125
1721
  layer,
1126
1722
  ...(label !== undefined && { label }),
1723
+ ...(hasCentroid && { labelX: c[0], labelY: c[1] }),
1127
1724
  ...(isThisLayer && r.value !== undefined && { value: r.value }),
1128
1725
  ...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
1129
1726
  });
@@ -1164,6 +1761,69 @@ export function layoutMap(
1164
1761
  }
1165
1762
  }
1166
1763
 
1764
+ // -- Background-fill hit-testing (for connector-label contrast) --
1765
+ // A freight/edge label floats over whatever region the route crosses — a dark
1766
+ // scored country, pale land, or open water. To pick a legible text shade (and
1767
+ // skip the ghost halo when not needed) we need the fill UNDER the label point.
1768
+ // Test in SCREEN space against the already-drawn region paths: that sidesteps
1769
+ // every projection wrinkle (global stretch, antimeridian, AK/HI insets) because
1770
+ // the geometry is already projected (see module-level `parsePathRings`).
1771
+ // Even-odd ray cast across ALL of a feature's rings at once, so polygons with
1772
+ // holes (a ring inside a ring) toggle correctly.
1773
+ const pointInRings = (
1774
+ px: number,
1775
+ py: number,
1776
+ rings: Array<Array<[number, number]>>
1777
+ ): boolean => {
1778
+ let inside = false;
1779
+ for (const ring of rings) {
1780
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1781
+ const [xi, yi] = ring[i]!;
1782
+ const [xj, yj] = ring[j]!;
1783
+ if (
1784
+ yi > py !== yj > py &&
1785
+ px < ((xj - xi) * (py - yi)) / (yj - yi) + xi
1786
+ )
1787
+ inside = !inside;
1788
+ }
1789
+ }
1790
+ return inside;
1791
+ };
1792
+ // Precompute hit targets once (regions are drawn in array order, so the LAST
1793
+ // containing one is topmost). Insets paint over neighbour land in their own box.
1794
+ const fillHitTargets = [...regions, ...insetRegions].map((r) => ({
1795
+ fill: r.fill,
1796
+ rings: parsePathRings(r.d),
1797
+ }));
1798
+ const fillAt = (x: number, y: number): string => {
1799
+ let hit = water; // open ocean / canvas backdrop when over no land
1800
+ for (const t of fillHitTargets)
1801
+ if (pointInRings(x, y, t.rings)) hit = t.fill;
1802
+ return hit;
1803
+ };
1804
+ // Contrast-pick text colour for a label sitting ON `fill` (shared by region
1805
+ // labels and connector labels): the genuinely higher-contrast of the palette's
1806
+ // light/dark on-fill text, with a halo only when that contrast is marginal
1807
+ // (mid-tone fills), so clear fills carry no ghost.
1808
+ const labelOnFill = (
1809
+ fill: string
1810
+ ): { color: string; halo: boolean; haloColor: string } => {
1811
+ const color =
1812
+ contrastRatio(fill, palette.textOnFillDark) >=
1813
+ contrastRatio(fill, palette.textOnFillLight)
1814
+ ? palette.textOnFillDark
1815
+ : palette.textOnFillLight;
1816
+ const haloColor =
1817
+ color === palette.textOnFillLight
1818
+ ? palette.textOnFillDark
1819
+ : palette.textOnFillLight;
1820
+ return {
1821
+ color,
1822
+ halo: contrastRatio(fill, color) < REGION_LABEL_HALO_RATIO,
1823
+ haloColor,
1824
+ };
1825
+ };
1826
+
1167
1827
  // Relief (notable mountain ranges) — horizontal hachure lines clipped to each
1168
1828
  // range, drawn over the base land and under rivers/POIs/data fills. Opt-in via
1169
1829
  // the `relief` flag; needs the optional `mountainRanges` asset. Each surviving
@@ -1174,9 +1834,16 @@ export function layoutMap(
1174
1834
  // (ADR-2) is handled at the RENDER clip — relief is clipped to land MINUS the
1175
1835
  // data-coloured regions, so a range that crosses a valued state still shows on
1176
1836
  // the un-valued land around it (a bbox drop here would nuke the whole range).
1837
+ // Relief is ALWAYS on; only the `no-relief` directive turns it off. It renders
1838
+ // on data maps too (the renderer lays the hachure ATOP the choropleth/tag fills
1839
+ // and the hatch tone flips to stay visible over muted land), at every zoom, and
1840
+ // at every width. The only remaining filters are per-range quality guards below
1841
+ // (sub-min-area / sub-min-dimension slivers are skipped so a range never draws
1842
+ // as a sub-pixel smudge) — those drop individual ranges, never the feature.
1843
+ const reliefAllowed = resolved.directives.noRelief !== true;
1177
1844
  const relief: MapLayoutRelief[] = [];
1178
1845
  let reliefHatch: MapLayoutReliefHatch | null = null;
1179
- if (resolved.directives.relief === true && data.mountainRanges) {
1846
+ if (reliefAllowed && data.mountainRanges) {
1180
1847
  for (const [, f] of decodeLayer(data.mountainRanges)) {
1181
1848
  const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1182
1849
  if (!viewF) continue;
@@ -1202,25 +1869,64 @@ export function layoutMap(
1202
1869
  // differs from the land, flip to the light tone so the lines stay visible.
1203
1870
  const darkTone = isDark ? palette.bg : palette.text;
1204
1871
  const lightTone = isDark ? palette.text : palette.bg;
1205
- const landLum = relativeLuminance(neutralFill);
1872
+ // Relief is ONE global clipped layer with a single colour (renderer.ts)
1873
+ // a per-region hatch tone over varied pastels would need a renderer
1874
+ // rearchitecture (out of scope; v2). Under colorize the political tints are
1875
+ // pale washes sitting near the surface/bg, so referencing that base picks a
1876
+ // fixed mid-contrast hatch tone that reads over all of them (AC15/G2).
1877
+ const reliefLandRef = colorizeActive
1878
+ ? isDark
1879
+ ? palette.surface
1880
+ : palette.bg
1881
+ : neutralFill;
1882
+ const landLum = relativeLuminance(reliefLandRef);
1206
1883
  const tone =
1207
1884
  Math.abs(landLum - relativeLuminance(darkTone)) > 0.04
1208
1885
  ? darkTone
1209
1886
  : lightTone;
1210
1887
  reliefHatch = {
1211
- color: mix(tone, neutralFill, RELIEF_HATCH_STRENGTH),
1888
+ color: mix(tone, reliefLandRef, RELIEF_HATCH_STRENGTH),
1212
1889
  spacing: RELIEF_HATCH_SPACING,
1213
1890
  width: RELIEF_HATCH_WIDTH,
1214
1891
  };
1215
1892
  }
1216
1893
  }
1217
1894
 
1895
+ // Coastline water-lines style (opt-in `coastline`, §24B.2). No geometry/asset:
1896
+ // the renderer derives the lines from the already-drawn region paths and masks
1897
+ // them to the water side. We only resolve the proportional screen-space style
1898
+ // here (fractions of min(w,h) → absolute px, so the offshore distance stays a
1899
+ // constant fraction of the canvas at any export size — ADR-3). Differs from
1900
+ // relief: a touch more contrast than `lakeStroke` so the offshore lines read as
1901
+ // distinct from the coast stroke (R10/F14).
1902
+ let coastlineStyle: MapLayoutCoastlineStyle | null = null;
1903
+ if (resolved.directives.noCoastline !== true) {
1904
+ const minDim = Math.min(width, height);
1905
+ coastlineStyle = {
1906
+ color: mix(regionStroke, water, COASTLINE_STROKE_MIX),
1907
+ // N equal-width rings: distance steps outward by COASTLINE_STEP; opacity
1908
+ // fades linearly from NEAR (innermost) to FAR (outermost).
1909
+ lines: Array.from({ length: COASTLINE_RING_COUNT }, (_, k) => ({
1910
+ d: (COASTLINE_D0 + k * COASTLINE_STEP) * minDim,
1911
+ thickness: COASTLINE_THICKNESS * minDim,
1912
+ opacity:
1913
+ COASTLINE_OPACITY_NEAR +
1914
+ ((COASTLINE_OPACITY_FAR - COASTLINE_OPACITY_NEAR) * k) /
1915
+ (COASTLINE_RING_COUNT - 1),
1916
+ })),
1917
+ minExtent:
1918
+ (isGlobalView ? COASTLINE_MIN_EXTENT_GLOBAL : COASTLINE_MIN_EXTENT) *
1919
+ minDim,
1920
+ };
1921
+ }
1922
+
1218
1923
  // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land.
1219
- // Nudged slightly toward the border tone (off flat `water`) so the line reads
1220
- // as a deliberate water course rather than a gap where it crosses a border
1221
- // in muted/data mode flat water is a pale gray that just looks like a broken
1222
- // boundary. Open paths: stroked, no fill; under POIs/edges/labels.
1223
- const riverColor = mix(water, regionStroke, 16);
1924
+ // A deliberate water-blue a more saturated cousin of the body-of-water
1925
+ // `water` tone (which is a very faded blue, §mapBackgroundColor) so the line
1926
+ // reads clearly as a water course, not a dark gap where it crosses a border.
1927
+ // Mixing toward the border tone instead reads as a broken boundary in
1928
+ // muted/data mode. Open paths: stroked, no fill; under POIs/edges/labels.
1929
+ const riverColor = mix(palette.colors.blue, water, 32);
1224
1930
  const rivers: MapLayoutRiver[] = [];
1225
1931
  if (data.rivers) {
1226
1932
  for (const [, f] of decodeLayer(data.rivers)) {
@@ -1299,38 +2005,136 @@ export function layoutMap(
1299
2005
  const xy = project(p.lon, p.lat);
1300
2006
  if (xy) projected.push({ p, xy });
1301
2007
  }
1302
- const coloGroups = new Map<string, Proj[]>();
2008
+ const placePoi = (
2009
+ e: Proj,
2010
+ cx: number,
2011
+ cy: number,
2012
+ clusterId?: string
2013
+ ): void => {
2014
+ const { fill, stroke } = poiFill(e.p);
2015
+ poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
2016
+ const num = routeNumberById.get(e.p.id);
2017
+ pois.push({
2018
+ id: e.p.id,
2019
+ cx,
2020
+ cy,
2021
+ r: radiusFor(e.p),
2022
+ fill,
2023
+ stroke,
2024
+ lineNumber: e.p.lineNumber,
2025
+ implicit: !!e.p.implicit,
2026
+ isOrigin: originIds.has(e.p.id),
2027
+ ...(num !== undefined && { routeNumber: num }),
2028
+ ...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
2029
+ ...(clusterId !== undefined && { clusterId }),
2030
+ });
2031
+ };
2032
+
2033
+ // -- Coincident-POI spiderfy (stacks). Two dots "stack" when they visibly
2034
+ // overlap (centre distance < combined radii × STACK_OVERLAP). A ≥2-member stack
2035
+ // is laid out EXPANDED — members fanned onto a ring (golden-angle spiral past
2036
+ // STACK_RING_MAX), legs back to the centroid — which is the source of truth for
2037
+ // export + the no-JS default; the app collapses it to one ringed `+N` badge at
2038
+ // rest and expands on click. POIs that anchor an edge or route leg are EXCLUDED
2039
+ // (kept at true position; collapsing a connector endpoint is out of v1 scope).
2040
+ // Distinct-but-dense clusters never overlap at the combined-radii threshold, so
2041
+ // they keep today's true-position + leader/column behavior.
2042
+ const clusters: MapLayoutCluster[] = [];
2043
+ const connected = new Set<string>();
2044
+ for (const e of resolved.edges) {
2045
+ connected.add(e.fromId);
2046
+ connected.add(e.toId);
2047
+ }
2048
+ for (const rt of resolved.routes) {
2049
+ rt.stopIds.forEach((id) => connected.add(id));
2050
+ }
2051
+ const radiusOf = (e: Proj): number => radiusFor(e.p);
2052
+ // Connected endpoints: always true position.
1303
2053
  for (const e of projected) {
1304
- const key = `${Math.round(e.xy[0] / COLO_EPS)},${Math.round(e.xy[1] / COLO_EPS)}`;
1305
- const arr = coloGroups.get(key);
1306
- if (arr) arr.push(e);
1307
- else coloGroups.set(key, [e]);
2054
+ if (connected.has(e.p.id)) placePoi(e, e.xy[0], e.xy[1]);
1308
2055
  }
1309
- for (const group of coloGroups.values()) {
1310
- group.forEach((e, i) => {
1311
- let cx = e.xy[0];
1312
- let cy = e.xy[1];
1313
- if (group.length > 1) {
1314
- const ang = i * GOLDEN_ANGLE;
1315
- cx += Math.cos(ang) * COLO_R;
1316
- cy += Math.sin(ang) * COLO_R;
2056
+ // Distance-based transitive grouping among stackable POIs (first-matching-group
2057
+ // heuristic, matching the GROUP_R label-column grouping below).
2058
+ const groups: Proj[][] = [];
2059
+ for (const e of projected) {
2060
+ if (connected.has(e.p.id)) continue;
2061
+ const r = radiusOf(e);
2062
+ const near = groups.find((g) =>
2063
+ g.some(
2064
+ (q) =>
2065
+ Math.hypot(q.xy[0] - e.xy[0], q.xy[1] - e.xy[1]) <
2066
+ (r + radiusOf(q)) * STACK_OVERLAP
2067
+ )
2068
+ );
2069
+ if (near) near.push(e);
2070
+ else groups.push([e]);
2071
+ }
2072
+ for (const g of groups) {
2073
+ if (g.length === 1) {
2074
+ placePoi(g[0]!, g[0]!.xy[0], g[0]!.xy[1]);
2075
+ continue;
2076
+ }
2077
+ const clusterId = g[0]!.p.id; // line-number-ordered first member → stable
2078
+ const cx0 = g.reduce((s, e) => s + e.xy[0], 0) / g.length;
2079
+ const cy0 = g.reduce((s, e) => s + e.xy[1], 0) / g.length;
2080
+ const maxR = Math.max(...g.map(radiusOf));
2081
+ // Ring radius so adjacent expanded dots clear each other by STACK_RING_GAP.
2082
+ const sep = 2 * maxR + STACK_RING_GAP;
2083
+ const ringR = Math.max(
2084
+ COLO_R,
2085
+ sep / (2 * Math.sin(Math.PI / Math.max(g.length, 2)))
2086
+ );
2087
+ const positions = g.map((e, i) => {
2088
+ if (g.length <= STACK_RING_MAX) {
2089
+ const ang = -Math.PI / 2 + (i * 2 * Math.PI) / g.length;
2090
+ return {
2091
+ e,
2092
+ mx: cx0 + Math.cos(ang) * ringR,
2093
+ my: cy0 + Math.sin(ang) * ringR,
2094
+ };
1317
2095
  }
1318
- const { fill, stroke } = poiFill(e.p);
1319
- poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
1320
- const num = routeNumberById.get(e.p.id);
1321
- pois.push({
1322
- id: e.p.id,
1323
- cx,
1324
- cy,
1325
- r: radiusFor(e.p),
1326
- fill,
1327
- stroke,
1328
- lineNumber: e.p.lineNumber,
1329
- implicit: !!e.p.implicit,
1330
- isOrigin: originIds.has(e.p.id),
1331
- ...(num !== undefined && { routeNumber: num }),
1332
- ...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
1333
- });
2096
+ const ang = i * GOLDEN_ANGLE;
2097
+ const rr = ringR * Math.sqrt((i + 1) / g.length);
2098
+ return { e, mx: cx0 + Math.cos(ang) * rr, my: cy0 + Math.sin(ang) * rr };
2099
+ });
2100
+ // Off-canvas guard: translate the whole fan (centroid + members together) so
2101
+ // every DOT stays on-canvas. A pure shift preserves the spider geometry AND
2102
+ // keeps the collapsed badge honest — the ring is small, so the badge barely
2103
+ // moves off the true centroid. (Labels are NOT folded into this box: a label
2104
+ // is wide enough that shifting to fit it would drag the badge far from the
2105
+ // real location — a geographic lie. Instead the label block below flips each
2106
+ // member's radial label to the side that fits and clamps it to the frame.)
2107
+ let minX = cx0 - maxR;
2108
+ let maxX = cx0 + maxR;
2109
+ let minY = cy0 - maxR;
2110
+ let maxY = cy0 + maxR;
2111
+ for (const { mx, my, e } of positions) {
2112
+ const r = radiusOf(e);
2113
+ minX = Math.min(minX, mx - r);
2114
+ maxX = Math.max(maxX, mx + r);
2115
+ minY = Math.min(minY, my - r);
2116
+ maxY = Math.max(maxY, my + r);
2117
+ }
2118
+ let dx = 0;
2119
+ let dy = 0;
2120
+ if (minX + dx < 2) dx = 2 - minX;
2121
+ if (maxX + dx > width - 2) dx = width - 2 - maxX;
2122
+ if (minY + dy < 2) dy = 2 - minY;
2123
+ if (maxY + dy > height - 2) dy = height - 2 - maxY;
2124
+ const legsOut: Array<{ x2: number; y2: number; color: string }> = [];
2125
+ for (const { e, mx, my } of positions) {
2126
+ const fx = mx + dx;
2127
+ const fy = my + dy;
2128
+ placePoi(e, fx, fy, clusterId);
2129
+ legsOut.push({ x2: fx, y2: fy, color: poiFill(e.p).fill });
2130
+ }
2131
+ clusters.push({
2132
+ id: clusterId,
2133
+ cx: cx0 + dx,
2134
+ cy: cy0 + dy,
2135
+ count: g.length,
2136
+ hitR: ringR + maxR + 6,
2137
+ legs: legsOut,
1334
2138
  });
1335
2139
  }
1336
2140
 
@@ -1399,16 +2203,29 @@ export function layoutMap(
1399
2203
  if (!a || !b) continue;
1400
2204
  const mx = (a.cx + b.cx) / 2;
1401
2205
  const my = (a.cy + b.cy) / 2;
2206
+ const bow = {
2207
+ curved: leg.style === 'arc',
2208
+ offset: 0,
2209
+ labelX: mx,
2210
+ labelY: my - 4,
2211
+ };
2212
+ const routeLabelStyle =
2213
+ leg.label !== undefined
2214
+ ? labelOnFill(fillAt(bow.labelX, bow.labelY))
2215
+ : undefined;
1402
2216
  legs.push({
1403
- d: legPath(a, b, leg.style === 'arc', 0),
2217
+ d: legPath(a, b, bow.curved, bow.offset),
1404
2218
  width: routeWidthFor(Number(leg.value)),
1405
2219
  color: mix(palette.text, palette.bg, 72),
1406
2220
  arrow: true,
1407
2221
  lineNumber: leg.lineNumber,
1408
2222
  ...(leg.label !== undefined && {
1409
2223
  label: leg.label,
1410
- labelX: mx,
1411
- labelY: my - 4,
2224
+ labelX: bow.labelX,
2225
+ labelY: bow.labelY,
2226
+ labelColor: routeLabelStyle!.color,
2227
+ labelHalo: routeLabelStyle!.halo,
2228
+ labelHaloColor: routeLabelStyle!.haloColor,
1412
2229
  }),
1413
2230
  });
1414
2231
  }
@@ -1440,20 +2257,32 @@ export function layoutMap(
1440
2257
  const a = poiScreen.get(e.fromId);
1441
2258
  const b = poiScreen.get(e.toId);
1442
2259
  if (!a || !b) return;
1443
- const curved = e.style === 'arc' || n > 1;
1444
- const offset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
2260
+ const fanOffset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
1445
2261
  const mx = (a.cx + b.cx) / 2;
1446
2262
  const my = (a.cy + b.cy) / 2;
2263
+ const bow = {
2264
+ curved: e.style === 'arc' || n > 1,
2265
+ offset: fanOffset,
2266
+ labelX: mx,
2267
+ labelY: my - 4,
2268
+ };
2269
+ const edgeLabelStyle =
2270
+ e.label !== undefined
2271
+ ? labelOnFill(fillAt(bow.labelX, bow.labelY))
2272
+ : undefined;
1447
2273
  legs.push({
1448
- d: legPath(a, b, curved, offset),
2274
+ d: legPath(a, b, bow.curved, bow.offset),
1449
2275
  width: widthFor(e),
1450
2276
  color: mix(palette.text, palette.bg, 66),
1451
2277
  arrow: e.directed,
1452
2278
  lineNumber: e.lineNumber,
1453
2279
  ...(e.label !== undefined && {
1454
2280
  label: e.label,
1455
- labelX: mx,
1456
- labelY: my - 4,
2281
+ labelX: bow.labelX,
2282
+ labelY: bow.labelY,
2283
+ labelColor: edgeLabelStyle!.color,
2284
+ labelHalo: edgeLabelStyle!.halo,
2285
+ labelHaloColor: edgeLabelStyle!.haloColor,
1457
2286
  }),
1458
2287
  });
1459
2288
  });
@@ -1505,14 +2334,17 @@ export function layoutMap(
1505
2334
  obstacles.some((o) => rectsOverlap(rect, o)) ||
1506
2335
  legSegments.some((s) => segmentRectOverlap(s[0], s[1], s[2], s[3], rect));
1507
2336
 
1508
- // Region labels (default off). Rendered as haloed text — NO pill — so the
1509
- // choropleth fill (which encodes the data) stays fully visible. The text
1510
- // colour is contrast-picked against each region's OWN fill (dark on
1511
- // pastel/unscored land, light on saturated fills) with an opposite-lightness
1512
- // paint-order halo, the same convention POI labels use. A label is shown only
1513
- // when its (padded) footprint fits inside the region, so small states like the
1514
- // NE cluster auto-hide rather than overlap / spill onto the ocean.
1515
- const regionLabelMode = resolved.directives.regionLabels ?? 'off';
2337
+ // Region labels (default ON; `no-region-labels` suppresses). Rendered as plain
2338
+ // text — NO pill, NO halo — so the choropleth fill (which encodes the data)
2339
+ // stays fully visible. The text colour is contrast-picked against each region's
2340
+ // OWN fill. Auto-fit cascade full → abbrev → hide (decision A): the full name
2341
+ // shows when it fits its footprint; otherwise a US-state 2-letter abbreviation
2342
+ // is tried (countries have no abbrev source, so they degrade full → hide); if
2343
+ // nothing fits the label is hidden rather than overlapping / spilling onto the
2344
+ // ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
2345
+ // FIRST for US states.
2346
+ const showRegionLabels = resolved.directives.noRegionLabels !== true;
2347
+ const isCompact = width < COMPACT_WIDTH_PX;
1516
2348
  const LABEL_PADX = 6;
1517
2349
  const LABEL_PADY = 3;
1518
2350
  const labelW = (text: string): number =>
@@ -1525,55 +2357,118 @@ export function layoutMap(
1525
2357
  fill: string,
1526
2358
  lineNumber: number
1527
2359
  ): void => {
1528
- const color = contrastText(
1529
- fill,
1530
- palette.textOnFillLight,
1531
- palette.textOnFillDark
2360
+ // Colour is contrast-picked against the region's own fill (see labelOnFill).
2361
+ // The halo, though, is gated by CONTAINMENT — not fill tone. A label that
2362
+ // sits wholly within its own fill reads against a single known colour, so
2363
+ // the picked shade suffices and a halo is just noise (big states: TX, CA).
2364
+ // But when the glyphs spill past the region — a narrow shape (FL peninsula),
2365
+ // a tiny state (MD), or a small inset island (HI) — the text crosses onto
2366
+ // ocean / neighbour land whose tone we can't predict, so it needs the halo
2367
+ // to stay legible. Sample the label's screen footprint against the drawn
2368
+ // fills: if any extreme lands on a fill other than the region's own, the
2369
+ // label overflows and earns a halo.
2370
+ const { color, haloColor } = labelOnFill(fill);
2371
+ const halfW = measureLegendText(text, FONT) / 2;
2372
+ const overflows = [y - FONT * 0.55, y - FONT * 0.1].some(
2373
+ (sy) => fillAt(x - halfW, sy) !== fill || fillAt(x + halfW, sy) !== fill
1532
2374
  );
1533
- const haloColor =
1534
- color === palette.textOnFillLight
1535
- ? palette.textOnFillDark
1536
- : palette.textOnFillLight;
1537
2375
  labels.push({
1538
2376
  x,
1539
2377
  y,
1540
2378
  text,
1541
2379
  anchor: 'middle',
1542
2380
  color,
1543
- halo: true,
2381
+ halo: overflows,
1544
2382
  haloColor,
1545
2383
  lineNumber,
1546
2384
  });
1547
2385
  };
1548
- // A few countries have far-flung territory that drags the area-weighted
1549
- // centroid off the mainland (US Alaska pulls it up into Canada). Anchor
1550
- // their world-layer label to a mainland [lon, lat] instead.
1551
- const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
1552
- US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
2386
+ // A region label's screen footprint, middle-anchored on its centroid, used to
2387
+ // keep two region labels from overlapping (a small gap adds breathing room).
2388
+ const REGION_LABEL_GAP = 2;
2389
+ const regionLabelRect = (cx: number, cy: number, text: string): LabelRect => {
2390
+ const w = measureLegendText(text, FONT) + 2 * REGION_LABEL_GAP;
2391
+ return { x: cx - w / 2, y: cy - FONT / 2, w, h: FONT };
1553
2392
  };
1554
- if (regionLabelMode === 'full' || regionLabelMode === 'abbrev') {
1555
- for (const r of regions) {
1556
- if (r.layer === 'base' || r.label === undefined) continue;
1557
- const f =
1558
- r.layer === 'us-state' ? usLayer?.get(r.id) : worldLayer.get(r.id);
1559
- if (!f) continue;
1560
- const [[x0, y0], [x1, y1]] = path.bounds(f as never);
1561
- const text =
1562
- regionLabelMode === 'abbrev' ? r.id.replace(/^US-/, '') : r.label;
1563
- // Hide if the label wouldn't fit inside the region's footprint.
1564
- if (labelW(text) > x1 - x0 || labelH > y1 - y0) continue;
1565
- const anchor =
1566
- r.layer !== 'us-state' ? WORLD_LABEL_ANCHORS[r.id] : undefined;
1567
- const c = anchor
1568
- ? project(anchor[0], anchor[1])
1569
- : path.centroid(f as never);
1570
- if (!c || !Number.isFinite(c[0])) continue;
2393
+ if (showRegionLabels) {
2394
+ // Gather the placeable region labels, then commit them largest-footprint
2395
+ // first. Two adjacent regions can sit too close to both carry a label at the
2396
+ // current scale (Spain + Portugal on a whole-world view collapse to ~32px
2397
+ // apart). Rather than overlap, the bigger region keeps its label and the
2398
+ // smaller one yields; zoom in and the footprints separate, no collision
2399
+ // fires, and both labels show. Order is by projected box AREA (visual claim)
2400
+ // so the result is scale-driven, not source-order-driven.
2401
+ // POI-only region framing: the region(s) CONTAINING the POIs are labelled
2402
+ // prominently even though they carry no data (layer 'base'). Neighbour land
2403
+ // gets the muted context-label treatment further down.
2404
+ const frameContainers = new Set(resolved.poiFrameContainers);
2405
+ const entries = regions
2406
+ .map((r) => {
2407
+ const isContainer = frameContainers.has(r.id);
2408
+ if ((r.layer === 'base' && !isContainer) || r.label === undefined)
2409
+ return null;
2410
+ // A container state carries layer 'base', so key off the id shape too.
2411
+ const isUsState = r.layer === 'us-state' || r.id.startsWith('US-');
2412
+ const f = isUsState ? usLayer?.get(r.id) : worldLayer.get(r.id);
2413
+ if (!f) return null;
2414
+ const [[x0, y0], [x1, y1]] = path.bounds(f as never);
2415
+ const boxW = x1 - x0;
2416
+ const boxH = y1 - y0;
2417
+ // full → abbrev → hide. Abbrev exists only for US states; at the compact
2418
+ // breakpoint abbrev is tried first.
2419
+ const abbrev = isUsState ? r.id.replace(/^US-/, '') : undefined;
2420
+ const candidates =
2421
+ abbrev !== undefined
2422
+ ? isCompact
2423
+ ? [abbrev, r.label]
2424
+ : [r.label, abbrev]
2425
+ : [r.label];
2426
+ const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : undefined;
2427
+ const c = anchor
2428
+ ? project(anchor[0], anchor[1])
2429
+ : path.centroid(f as never);
2430
+ if (!c || !Number.isFinite(c[0])) return null;
2431
+ return { r, c, boxW, boxH, area: boxW * boxH, candidates };
2432
+ })
2433
+ .filter((e): e is NonNullable<typeof e> => e !== null)
2434
+ .sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
2435
+ const placedRegionRects: LabelRect[] = [];
2436
+ // POI markers are obstacles for region labels: a region whose centroid sits on
2437
+ // a POI (e.g. Colorado's centroid under the "Core POP" dot in Denver) must NOT
2438
+ // stamp its name there — the POI's own label owns that spot, and two names by
2439
+ // one dot is ambiguous. The dot rect is padded to also keep the region name
2440
+ // clear of the POI's adjacent label. Region labels with no nearby POI (a
2441
+ // container whose POIs cluster in one corner, or an empty neighbour state) are
2442
+ // unaffected. POI markers are positioned above; their labels place further
2443
+ // down, so dot-proximity is the signal available here.
2444
+ const POI_LABEL_PAD = 14; // px — rough room for the POI's own hugging label
2445
+ const poiObstacles: LabelRect[] = pois.map((p) => ({
2446
+ x: p.cx - p.r - POI_LABEL_PAD,
2447
+ y: p.cy - p.r - POI_LABEL_PAD,
2448
+ w: 2 * (p.r + POI_LABEL_PAD),
2449
+ h: 2 * (p.r + POI_LABEL_PAD),
2450
+ }));
2451
+ for (const { r, c, boxW, boxH, candidates } of entries) {
2452
+ // The first candidate that BOTH fits its own footprint AND clears every
2453
+ // already-placed region label AND every POI marker wins; none qualifies →
2454
+ // the label is hidden (a country has no abbrev, so it degrades full → hide;
2455
+ // a US state may fall back to its 2-letter code before hiding).
2456
+ const text = candidates.find((t) => {
2457
+ if (labelW(t) > boxW || labelH > boxH) return false;
2458
+ const rect = regionLabelRect(c[0], c[1], t);
2459
+ return (
2460
+ !placedRegionRects.some((p) => rectsOverlap(rect, p)) &&
2461
+ !poiObstacles.some((o) => rectsOverlap(rect, o))
2462
+ );
2463
+ });
2464
+ if (text === undefined) continue;
2465
+ placedRegionRects.push(regionLabelRect(c[0], c[1], text));
1571
2466
  pushRegionLabel(c[0], c[1], text, r.fill, r.lineNumber);
1572
2467
  }
1573
- // AK/HI labels live in their insets (own projection centroids).
2468
+ // AK/HI labels live in their insets (own projection centroids). Insets are
2469
+ // tiny, so prefer the abbreviation when the canvas is compact.
1574
2470
  for (const seed of insetLabelSeeds) {
1575
- const text =
1576
- regionLabelMode === 'abbrev' ? seed.iso.replace(/^US-/, '') : seed.name;
2471
+ const text = isCompact ? seed.iso.replace(/^US-/, '') : seed.name;
1577
2472
  const src = regionById.get(seed.iso);
1578
2473
  pushRegionLabel(
1579
2474
  seed.x,
@@ -1585,12 +2480,13 @@ export function layoutMap(
1585
2480
  }
1586
2481
  }
1587
2482
 
1588
- // POI labels (default auto; off -> none; all -> every POI).
1589
- const poiLabelMode = resolved.directives.poiLabels ?? 'auto';
1590
- if (poiLabelMode !== 'off') {
1591
- const ordered = [...pois].sort(
1592
- (a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1)
1593
- );
2483
+ // POI labels: default-on, collision-managed auto. `no-poi-labels` suppresses.
2484
+ if (resolved.directives.noPoiLabels !== true) {
2485
+ // Cluster (stack) members are laid out + labelled by the spiderfy block; keep
2486
+ // them out of the singleton/proximity-column placement here.
2487
+ const ordered = [...pois]
2488
+ .filter((p) => p.clusterId === undefined)
2489
+ .sort((a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1));
1594
2490
  const poiById = new Map(resolved.pois.map((q) => [q.id, q]));
1595
2491
  const labelText = (p: MapLayoutPoi): string => {
1596
2492
  const src = poiById.get(p.id);
@@ -1607,6 +2503,18 @@ export function layoutMap(
1607
2503
  // from the east AND west — Boulder in the route-cluster gauntlet).
1608
2504
  type Side = 'right' | 'left' | 'above' | 'below';
1609
2505
  const GAP = 3;
2506
+ // Coincident-stack members (spiderfy) are labelled via a tidy leader-lined
2507
+ // COLUMN beside the cluster (see the cluster-column pass after the column
2508
+ // helpers below) — NOT radial inline labels, which pile up unreadably when
2509
+ // the ring is tight. Group the members here; the pass commits them once the
2510
+ // column machinery is defined.
2511
+ const clusterMembersById = new Map<string, MapLayoutPoi[]>();
2512
+ for (const p of pois) {
2513
+ if (p.clusterId === undefined) continue;
2514
+ const arr = clusterMembersById.get(p.clusterId);
2515
+ if (arr) arr.push(p);
2516
+ else clusterMembersById.set(p.clusterId, [p]);
2517
+ }
1610
2518
  const inlineRect = (p: MapLayoutPoi, w: number, side: Side): LabelRect => {
1611
2519
  switch (side) {
1612
2520
  case 'right':
@@ -1646,7 +2554,7 @@ export function layoutMap(
1646
2554
  text,
1647
2555
  anchor,
1648
2556
  color: palette.text,
1649
- halo: true,
2557
+ halo: false,
1650
2558
  haloColor: palette.bg,
1651
2559
  poiId: p.id,
1652
2560
  lineNumber: p.lineNumber,
@@ -1683,39 +2591,89 @@ export function layoutMap(
1683
2591
  const ROW_GAP = 3;
1684
2592
  const step = poiLabH + ROW_GAP;
1685
2593
  const COL_GAP = 16;
1686
- const placeColumn = (group: MapLayoutPoi[]): void => {
1687
- const items = group
2594
+ type ColItem = { p: MapLayoutPoi; text: string; w: number };
2595
+ const makeItems = (group: MapLayoutPoi[]): ColItem[] =>
2596
+ group
1688
2597
  .map((p) => ({ p, ...labelInfo(p) }))
1689
2598
  .sort((a, b) => a.p.cy - b.p.cy || (a.text < b.text ? -1 : 1));
2599
+ // The column's per-row layout (side, colX, clamped startY, each row's rect).
2600
+ // Shared by the clean-check gate and the commit path so they never diverge.
2601
+ const columnRows = (
2602
+ items: ColItem[],
2603
+ side: 'right' | 'left'
2604
+ ): Array<{ o: ColItem; colX: number; rowCy: number; rect: LabelRect }> => {
1690
2605
  const left = Math.min(...items.map((o) => o.p.cx - o.p.r));
1691
2606
  const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2607
+ const maxW = Math.max(...items.map((o) => o.w));
1692
2608
  const cyMid =
1693
2609
  (Math.min(...items.map((o) => o.p.cy)) +
1694
2610
  Math.max(...items.map((o) => o.p.cy))) /
1695
2611
  2;
1696
- const maxW = Math.max(...items.map((o) => o.w));
1697
- // Prefer the right of the cluster; fall to the left if it runs off-canvas.
1698
- const side: 'right' | 'left' =
1699
- right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
1700
- const colX = side === 'right' ? right + COL_GAP : left - COL_GAP;
2612
+ // Column anchor x, clamped so the widest row's text box stays on-canvas.
2613
+ // (No-op for the clean callers; matters when a fallback column e.g. a
2614
+ // second spider cluster boxed out of its preferred side would otherwise
2615
+ // run a label off the frame.) A right column anchors its text start at
2616
+ // colX; a left column anchors its end at colX (text spans colX-maxW..colX).
2617
+ const colX =
2618
+ side === 'right'
2619
+ ? Math.min(right + COL_GAP, width - 2 - maxW)
2620
+ : Math.max(left - COL_GAP, 2 + maxW);
1701
2621
  const totalH = items.length * step;
1702
2622
  let startY = cyMid - totalH / 2;
1703
2623
  startY = Math.max(2, Math.min(startY, height - totalH - 2));
1704
- items.forEach((o, i) => {
2624
+ return items.map((o, i) => {
1705
2625
  const rowCy = startY + i * step + step / 2;
1706
- obstacles.push({
1707
- x: side === 'right' ? colX : colX - o.w,
1708
- y: rowCy - poiLabH / 2,
1709
- w: o.w,
1710
- h: poiLabH,
1711
- });
2626
+ return {
2627
+ o,
2628
+ colX,
2629
+ rowCy,
2630
+ rect: {
2631
+ x: side === 'right' ? colX : colX - o.w,
2632
+ y: rowCy - poiLabH / 2,
2633
+ w: o.w,
2634
+ h: poiLabH,
2635
+ },
2636
+ };
2637
+ });
2638
+ };
2639
+ // Pure gate (NO mutation): every row on-canvas AND collision-free, at the
2640
+ // post-startY-clamp positions the commit path will use.
2641
+ const wouldColumnBeClean = (
2642
+ items: ColItem[],
2643
+ side: 'right' | 'left'
2644
+ ): boolean =>
2645
+ columnRows(items, side).every(
2646
+ ({ rect }) =>
2647
+ rect.x >= 0 &&
2648
+ rect.x + rect.w <= width &&
2649
+ rect.y >= 0 &&
2650
+ rect.y + rect.h <= height &&
2651
+ !collides(rect)
2652
+ );
2653
+ // Today's side heuristic — used only for ungated singleton callouts.
2654
+ const defaultColumnSide = (items: ColItem[]): 'right' | 'left' => {
2655
+ const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2656
+ const maxW = Math.max(...items.map((o) => o.w));
2657
+ return right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
2658
+ };
2659
+ // Commit a visible callout column on the GIVEN side (no re-deriving the
2660
+ // side — the caller has already validated it). When `clusterId` is set the
2661
+ // rows are tagged `clusterMember` so the app shows/hides them (text AND
2662
+ // leader) with the collapsed-stack badge.
2663
+ const commitColumn = (
2664
+ items: ColItem[],
2665
+ side: 'right' | 'left',
2666
+ clusterId?: string
2667
+ ): void => {
2668
+ for (const { o, colX, rowCy, rect } of columnRows(items, side)) {
2669
+ obstacles.push(rect);
1712
2670
  labels.push({
1713
2671
  x: colX,
1714
2672
  y: rowCy + FONT / 3,
1715
2673
  text: o.text,
1716
2674
  anchor: side === 'right' ? 'start' : 'end',
1717
2675
  color: palette.text,
1718
- halo: true,
2676
+ halo: false,
1719
2677
  haloColor: palette.bg,
1720
2678
  leader: {
1721
2679
  x1: o.p.cx,
@@ -1726,26 +2684,207 @@ export function layoutMap(
1726
2684
  leaderColor: o.p.fill,
1727
2685
  poiId: o.p.id,
1728
2686
  lineNumber: o.p.lineNumber,
2687
+ ...(clusterId !== undefined && { clusterMember: clusterId }),
1729
2688
  });
2689
+ }
2690
+ };
2691
+ // Hover-only fallback: a single inline label beside the dot (no leader),
2692
+ // emitted invisible and revealed on hover. NOT added to obstacles (it's
2693
+ // invisible and must not displace visible labels). y is clamped on-canvas
2694
+ // because we skip the inlineFits four-edge check (F8).
2695
+ const pushHidden = (p: MapLayoutPoi): void => {
2696
+ const { text, w } = labelInfo(p);
2697
+ let x = p.cx + p.r + GAP;
2698
+ let anchor: 'start' | 'end' = 'start';
2699
+ if (x + w > width) {
2700
+ x = p.cx - p.r - GAP - w;
2701
+ anchor = 'end';
2702
+ }
2703
+ const y = Math.max(0, Math.min(p.cy - poiLabH / 2, height - poiLabH));
2704
+ labels.push({
2705
+ x: anchor === 'start' ? x : x + w,
2706
+ y: y + poiLabH / 2 + FONT / 3,
2707
+ text,
2708
+ anchor,
2709
+ color: palette.text,
2710
+ halo: false,
2711
+ haloColor: palette.bg,
2712
+ poiId: p.id,
2713
+ hidden: true,
2714
+ lineNumber: p.lineNumber,
1730
2715
  });
1731
2716
  };
1732
2717
 
2718
+ // Spiderfy clusters: label every member in a tidy leader-lined column beside
2719
+ // the ring (collision-free by row spacing), tagged `clusterMember` so the app
2720
+ // toggles them with the badge. Committed FIRST so the singleton/group passes
2721
+ // route around the column. The dots/legs/badge keep their true location — only
2722
+ // the labels move out to the column, which the startY-clamp keeps on-canvas.
2723
+ for (const [clusterId, members] of clusterMembersById) {
2724
+ if (members.length === 0) continue;
2725
+ const items = makeItems(members);
2726
+ // Prefer a clean (on-canvas, collision-free) side; fall back to the side
2727
+ // with more horizontal room. Cluster labels are always placed (never
2728
+ // hover-only) — readability beats the odd overlap with a faint basemap.
2729
+ const side = wouldColumnBeClean(items, 'right')
2730
+ ? 'right'
2731
+ : wouldColumnBeClean(items, 'left')
2732
+ ? 'left'
2733
+ : defaultColumnSide(items);
2734
+ commitColumn(items, side, clusterId);
2735
+ }
2736
+
2737
+ // Per-render extent threshold (resolution-relative; Decision #1, F9).
2738
+ const maxExtent = MAX_CLUSTER_EXTENT_FACTOR * Math.min(width, height);
2739
+ // Pass 1: place singletons (unchanged); for ≥2 clusters resolve gate
2740
+ // (a)/(a2) — sprawl/overflow → hover-only. These hides push NOTHING to
2741
+ // obstacles, so doing them first decouples the gate-(b) clean-checks below
2742
+ // from commit order (F4). Surviving clusters defer to pass 2.
2743
+ const clusterPending: ColItem[][] = [];
1733
2744
  for (const g of groups) {
1734
- // Singleton that fits inline → inline; everything else → callout column
1735
- // (the whole cluster, or a lone POI boxed in by legs/edges).
2745
+ const items = makeItems(g);
1736
2746
  if (g.length === 1) {
1737
- const p = g[0]!;
1738
- const { text, w } = labelInfo(p);
2747
+ // Singleton: inline if it fits, else today's single-row callout —
2748
+ // always placed, never hover-only (Decision #2 / AC9).
2749
+ const { p, text, w } = items[0]!;
1739
2750
  const side = (['right', 'left', 'above', 'below'] as const).find((s) =>
1740
2751
  inlineFits(p, w, s)
1741
2752
  );
1742
- if (side) {
1743
- pushInline(p, text, w, side);
1744
- continue;
2753
+ if (side) pushInline(p, text, w, side);
2754
+ else commitColumn(items, defaultColumnSide(items));
2755
+ continue;
2756
+ }
2757
+ // Gate (a): bounding-box diagonal over marker extents — a sprawling chain
2758
+ // whose column leaders would fan across the map. Gate (a2): too many rows
2759
+ // to stack readably. Either → whole cluster hover-only.
2760
+ const left = Math.min(...items.map((o) => o.p.cx - o.p.r));
2761
+ const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2762
+ const minCy = Math.min(...items.map((o) => o.p.cy));
2763
+ const maxCy = Math.max(...items.map((o) => o.p.cy));
2764
+ const diag = Math.hypot(right - left, maxCy - minCy);
2765
+ if (diag > maxExtent || items.length > MAX_COLUMN_ROWS) {
2766
+ items.forEach((o) => pushHidden(o.p));
2767
+ } else {
2768
+ clusterPending.push(items);
2769
+ }
2770
+ }
2771
+ // Pass 2: gate (b) — a surviving cluster shows its column only if a right-
2772
+ // or left-side column places fully clean; commit on that exact side, else
2773
+ // the whole cluster goes hover-only.
2774
+ for (const items of clusterPending) {
2775
+ const side = (['right', 'left'] as const).find((s) =>
2776
+ wouldColumnBeClean(items, s)
2777
+ );
2778
+ if (side) commitColumn(items, side);
2779
+ else items.forEach((o) => pushHidden(o.p));
2780
+ }
2781
+ }
2782
+
2783
+ // -- Context labels (orientation backdrop, §24B). Placed DEAD LAST so they
2784
+ // only fill leftover space and never displace a data/region/POI label
2785
+ // (Decision 7). Off by default; gated on the directive so it costs nothing. --
2786
+ if (resolved.directives.noContextLabels !== true) {
2787
+ // F1: context labels must dodge EVERY committed label (region/inset/POI/
2788
+ // route), not just the POI-label rects already in `obstacles`. Region
2789
+ // labels go into `labels` but never into `obstacles`, so add a footprint
2790
+ // rect for each committed label here (POI rects are already present —
2791
+ // duplicates are harmless). This upholds Decision 7's "never displace a
2792
+ // data/region/POI label" against the live `collides` closure.
2793
+ for (const l of labels) {
2794
+ // Hidden (hover-only) labels are invisible — context labels must not
2795
+ // reserve space around them (Decision #7).
2796
+ if (l.hidden) continue;
2797
+ const w = labelW(l.text);
2798
+ const x =
2799
+ l.anchor === 'start' ? l.x : l.anchor === 'end' ? l.x - w : l.x - w / 2;
2800
+ obstacles.push({ x, y: l.y - labelH / 2, w, h: labelH });
2801
+ }
2802
+ // Under albers-usa the AK/HI inset frames occupy the lower-left; a context
2803
+ // label must never sit on one (the original Decision 8 hazard). Feed each
2804
+ // inset box into the collision set so the placement dodges them.
2805
+ for (const box of insets)
2806
+ obstacles.push({ x: box.x, y: box.y, w: box.w, h: box.h });
2807
+ // Unreferenced notable countries: the FULL decoded country set (worldLayer
2808
+ // holds every country in the chosen tier — crisp `.set()` upgrades never
2809
+ // delete), minus any already labelled by region-labels (Decision 1). Geo
2810
+ // work (bbox/anchor) stays here; area-rank + fit + collision live in the
2811
+ // pure module so the strict density invariants (AC7) are unit-testable.
2812
+ const countryCandidates: CountryCandidate[] = [];
2813
+ for (const f of worldLayer.values()) {
2814
+ const iso = typeof f.id === 'string' ? f.id : String(f.id ?? '');
2815
+ if (!iso || regionById.has(iso)) continue;
2816
+ // F3: skip a country whose SUBDIVISIONS are the referenced data (e.g. a
2817
+ // US-states choropleth on a world projection) — the states ARE the data,
2818
+ // so don't slap a redundant "United States" context label over them.
2819
+ let hasReferencedSub = false;
2820
+ for (const k of regionById.keys())
2821
+ if (k.startsWith(iso + '-')) {
2822
+ hasReferencedSub = true;
2823
+ break;
1745
2824
  }
2825
+ if (hasReferencedSub) continue;
2826
+ const b = path.bounds(f as never) as [[number, number], [number, number]];
2827
+ const [x0, y0] = b[0];
2828
+ const [x1, y1] = b[1];
2829
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue;
2830
+ const anchorLngLat = WORLD_LABEL_ANCHORS[iso];
2831
+ const a = anchorLngLat
2832
+ ? project(anchorLngLat[0], anchorLngLat[1])
2833
+ : (path.centroid(f as never) as [number, number]);
2834
+ countryCandidates.push({
2835
+ name: (f.properties as { name?: string } | undefined)?.name ?? iso,
2836
+ bbox: [x0, y0, x1, y1],
2837
+ anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
2838
+ });
2839
+ }
2840
+ // Neighbour US states (POI-only region framing): when the frame is snapped to
2841
+ // a US-state container (e.g. California), label the surrounding in-frame states
2842
+ // (Nevada, Oregon, Arizona…) in the muted context style for orientation. They
2843
+ // are NOT containers and NOT data, so the region-label pass skipped them.
2844
+ // Anchor each to the centroid of its VISIBLE (culled) geometry so a state only
2845
+ // partly in frame (a sliver of Oregon at the top) still anchors on-screen
2846
+ // rather than at an off-frame centroid that `insideViewport` would reject.
2847
+ const framedStateContainers = (resolved.poiFrameContainers ?? []).some(
2848
+ (id) => id.startsWith('US-')
2849
+ );
2850
+ if (usLayer && framedStateContainers) {
2851
+ const containerSet = new Set(resolved.poiFrameContainers);
2852
+ for (const [iso, f] of usLayer) {
2853
+ if (containerSet.has(iso) || regionById.has(iso)) continue;
2854
+ const viewF = cullFeatureToView(f);
2855
+ if (!viewF) continue; // not in frame
2856
+ const b = path.bounds(viewF as never) as [
2857
+ [number, number],
2858
+ [number, number],
2859
+ ];
2860
+ const [x0, y0] = b[0];
2861
+ const [x1, y1] = b[1];
2862
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue;
2863
+ const a = path.centroid(viewF as never) as [number, number];
2864
+ countryCandidates.push({
2865
+ name: (f.properties as { name?: string } | undefined)?.name ?? iso,
2866
+ bbox: [x0, y0, x1, y1],
2867
+ anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
2868
+ });
1746
2869
  }
1747
- placeColumn(g);
1748
2870
  }
2871
+ const contextLabels = placeContextLabels({
2872
+ projection: resolved.projection,
2873
+ dLonSpan,
2874
+ dLatSpan,
2875
+ width,
2876
+ height,
2877
+ waterBodies: data.waterBodies,
2878
+ countries: countryCandidates,
2879
+ palette,
2880
+ project,
2881
+ collides,
2882
+ // Water labels must stay over open water — `fillAt` returns the ocean
2883
+ // backdrop colour off-land and a region fill on-land (lakes/states count
2884
+ // as land here, which is the safe side for an ocean name).
2885
+ overLand: (x, y) => fillAt(x, y) !== water,
2886
+ });
2887
+ labels.push(...contextLabels);
1749
2888
  }
1750
2889
 
1751
2890
  // -- Legend model (AR1: categorical via renderer's renderLegendD3) --
@@ -1789,13 +2928,16 @@ export function layoutMap(
1789
2928
  rivers,
1790
2929
  relief,
1791
2930
  reliefHatch,
2931
+ coastlineStyle,
1792
2932
  legs,
1793
2933
  pois,
2934
+ clusters,
1794
2935
  labels,
1795
2936
  legend,
1796
2937
  insets,
1797
2938
  insetRegions,
1798
2939
  projection,
1799
2940
  stretch: stretchParams,
2941
+ diagnostics: [],
1800
2942
  };
1801
2943
  }