@diagrammo/dgmo 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2521 -623
  3. package/dist/advanced.d.cts +917 -534
  4. package/dist/advanced.d.ts +917 -534
  5. package/dist/advanced.js +2516 -623
  6. package/dist/auto.cjs +2333 -608
  7. package/dist/auto.js +119 -119
  8. package/dist/auto.mjs +2335 -609
  9. package/dist/cli.cjs +168 -168
  10. package/dist/editor.cjs +13 -15
  11. package/dist/editor.js +13 -15
  12. package/dist/highlight.cjs +15 -12
  13. package/dist/highlight.js +15 -12
  14. package/dist/index.cjs +2317 -595
  15. package/dist/index.d.cts +4 -1
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +2319 -596
  18. package/dist/internal.cjs +2521 -623
  19. package/dist/internal.d.cts +917 -534
  20. package/dist/internal.d.ts +917 -534
  21. package/dist/internal.js +2516 -623
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -0
  24. package/dist/map-data/water-bodies.json +1 -0
  25. package/docs/language-reference.md +44 -31
  26. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  27. package/gallery/fixtures/map-categorical.dgmo +0 -1
  28. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  29. package/gallery/fixtures/map-coastline.dgmo +7 -0
  30. package/gallery/fixtures/map-colorize.dgmo +11 -0
  31. package/gallery/fixtures/map-direct-color.dgmo +9 -0
  32. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  33. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  34. package/gallery/fixtures/map-route.dgmo +0 -1
  35. package/package.json +1 -1
  36. package/src/advanced.ts +26 -1
  37. package/src/boxes-and-lines/renderer.ts +39 -12
  38. package/src/cli.ts +1 -1
  39. package/src/completion.ts +32 -24
  40. package/src/cycle/renderer.ts +14 -1
  41. package/src/d3.ts +23 -11
  42. package/src/editor/highlight-api.ts +4 -0
  43. package/src/editor/keywords.ts +13 -15
  44. package/src/infra/renderer.ts +35 -7
  45. package/src/map/colorize.ts +54 -0
  46. package/src/map/context-labels.ts +429 -0
  47. package/src/map/data/PROVENANCE.json +1 -1
  48. package/src/map/data/mountain-ranges.json +1 -0
  49. package/src/map/data/types.ts +34 -0
  50. package/src/map/data/water-bodies.json +1 -0
  51. package/src/map/dimensions.ts +117 -0
  52. package/src/map/geo-query.ts +295 -0
  53. package/src/map/geo.ts +305 -2
  54. package/src/map/invert.ts +111 -0
  55. package/src/map/layout.ts +1504 -335
  56. package/src/map/load-data.ts +16 -2
  57. package/src/map/parser.ts +57 -111
  58. package/src/map/renderer.ts +556 -13
  59. package/src/map/resolved-types.ts +24 -2
  60. package/src/map/resolver.ts +237 -67
  61. package/src/map/types.ts +39 -23
  62. package/src/mindmap/renderer.ts +10 -1
  63. package/src/palettes/atlas.ts +77 -0
  64. package/src/palettes/blueprint.ts +73 -0
  65. package/src/palettes/color-utils.ts +58 -1
  66. package/src/palettes/index.ts +12 -3
  67. package/src/palettes/slate.ts +73 -0
  68. package/src/palettes/tidewater.ts +73 -0
  69. package/src/render.ts +8 -1
  70. package/src/tech-radar/renderer.ts +3 -0
  71. package/src/tech-radar/types.ts +3 -0
  72. package/src/utils/d3-types.ts +5 -0
  73. package/src/utils/legend-layout.ts +21 -4
  74. package/src/utils/legend-types.ts +7 -0
  75. package/src/utils/reserved-key-registry.ts +3 -0
  76. package/src/palettes/bold.ts +0 -67
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,15 @@ import {
17
18
  type GeoPath,
18
19
  } from 'd3-geo';
19
20
  import { feature } from 'topojson-client';
20
- import { mix, contrastText } 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';
29
+ import { resolveColor } from '../colors';
21
30
  import type { PaletteColors } from '../palettes/types';
22
31
  import {
23
32
  rectsOverlap,
@@ -27,6 +36,7 @@ import {
27
36
  import type { LabelRect, PointCircle } from '../label-layout';
28
37
  import { measureLegendText } from '../utils/legend-constants';
29
38
  import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
39
+ import type { DgmoError } from '../diagnostics';
30
40
  import type { BoundaryTopology } from './data/types';
31
41
  import type {
32
42
  MapData,
@@ -35,6 +45,8 @@ import type {
35
45
  ResolvedEdge,
36
46
  ProjectionFamily,
37
47
  } from './resolved-types';
48
+ import { placeContextLabels } from './context-labels';
49
+ import type { CountryCandidate } from './context-labels';
38
50
 
39
51
  // Minimal GeoJSON shapes (avoid a hard @types/geojson dep; cast at d3 calls).
40
52
  interface GeoFeature {
@@ -57,39 +69,108 @@ const R_MAX = 22;
57
69
  const W_MIN = 1.25; // edge stroke width
58
70
  const W_MAX = 8;
59
71
  const FONT = 11; // on-map label font px
60
- const COLO_EPS = 1.5; // px: POIs closer than this are "co-located"
61
- // % palette-yellow of bg for unscored land. Higher on dark so the soft palette
62
- // yellow reads as yellow rather than muddying toward tan against the dark bg.
63
- const LAND_TINT_LIGHT = 58;
64
- const LAND_TINT_DARK = 75;
72
+ // POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
73
+ // column falls back to hover-only labels when it would sprawl or overflow:
74
+ // - MAX_CLUSTER_EXTENT_FACTOR × min(width,height) = the px diagonal beyond which
75
+ // a cluster is a sprawling chain (its leaders would fan across the map), not a
76
+ // tight blob. Resolution-relative so the decision is stable across zoom — the
77
+ // px threshold is computed per-render, NOT a constant.
78
+ // - MAX_COLUMN_ROWS = the most rows a single column can stack readably.
79
+ // Exported for tests to drive the boundaries directly.
80
+ export const MAX_CLUSTER_EXTENT_FACTOR = 0.18;
81
+ export const MAX_COLUMN_ROWS = 7;
82
+ // WCAG ratio below which a region label needs a halo to read on its own fill.
83
+ // 4.5 = AA for normal text; mid-tone choropleth fills fall below this and get
84
+ // the rescue halo, while saturated/pastel fills (Texas, light land) clear it.
85
+ const REGION_LABEL_HALO_RATIO = 4.5;
86
+ // % palette-green of bg for unscored land — a VERY faded green so every map
87
+ // (plain reference OR data-coloured) wears the same subtle dress and the green
88
+ // never competes with saturated tag/score tints. Dark lifts a touch off the
89
+ // near-black surface so the faint green stays legible.
90
+ const LAND_TINT_LIGHT = 12;
91
+ const LAND_TINT_DARK = 24;
65
92
  // Categorical (tag) region fill: a flat, fairly saturated tint of the tag
66
93
  // colour so a tagged region reads as its CATEGORY against the tinted land base
67
94
  // — the generic 25% shape tint washes out and lets the olive land dominate.
68
95
  const TAG_TINT_LIGHT = 60;
69
96
  const TAG_TINT_DARK = 68;
70
- const WATER_TINT = 55; // % palette-blue of bg for the ocean / backdrop
97
+ // % palette-blue of bg for the ocean / backdrop — a faded blue, kept light
98
+ // enough not to compete with saturated blue/green data hues but distinctly
99
+ // bluer than the land so the sea reads as water rather than blank canvas.
100
+ const WATER_TINT_LIGHT = 24;
101
+ const WATER_TINT_DARK = 24;
71
102
  const RIVER_WIDTH = 1.3; // px stroke width for river lines
103
+ // Compact breakpoint (decision D2): below this effective render width a wide
104
+ // extent reads as zoomed-out — prefer abbreviated region labels and suppress
105
+ // relief, regardless of geographic extent.
106
+ const COMPACT_WIDTH_PX = 480;
107
+ // Relief (mountain-range shading). A projected range below this px² area is
108
+ // dropped (no confetti slivers at world zoom).
109
+ const RELIEF_MIN_AREA = 12; // px²
110
+ // Each projected bbox side must clear this — drop near-degenerate slivers.
111
+ const RELIEF_MIN_DIM = 2; // px
112
+ // Relief = horizontal hachure lines clipped to each range: a subtle
113
+ // dark-on-light / light-on-dark texture that reads as "mountains here". Spacing
114
+ // is SCREEN-space so density is constant regardless of zoom (geo-space spacing
115
+ // would collapse a small range to 1–2 lines and read as a glitch). Kept FAINT:
116
+ // thin sub-pixel lines drawn with a non-scaling stroke (constant device width at
117
+ // any zoom/DPR) and low-contrast colour. NOT crispEdges — that snaps the stroke
118
+ // to a solid ~1px in WebKit and reads far too heavy; plain AA keeps them whisper-thin.
119
+ const RELIEF_HATCH_SPACING = 2; // px between lines
120
+ const RELIEF_HATCH_WIDTH = 0.15; // px stroke
121
+ // % of the DARK reference (palette.bg on dark themes, palette.text on light)
122
+ // blended into the land colour — so the lines read DARKER than the land in both
123
+ // themes (palette.text alone flips to light on dark themes).
124
+ const RELIEF_HATCH_STRENGTH = 32;
125
+ // Coastline water-lines (opt-in `coastline`, §24B.2). N equal-width coast-parallel
126
+ // rings on the water side, evenly spaced and FADING seaward — the antique
127
+ // nautical-chart depth-contour look. Offshore distances + thickness are
128
+ // SCREEN-space FRACTIONS of min(w,h) so the rings stay a constant fraction of the
129
+ // canvas at ANY export size and ANY geographic extent (a decorative screen-space
130
+ // cue, not a geographic offset — ADR-3). Tuned faint, water-toned, low-contrast.
131
+ // minExtent = per-subpath degenerate-ring floor. Kept just above zero so EVERY
132
+ // island — down to the smallest specks — grows coast rings; it only drops
133
+ // sub-pixel/degenerate subpaths that would render nothing (R11). (Earlier it
134
+ // culled small islands to de-noise world maps, but every island should carry the
135
+ // nautical hashing, so the floor is now a bare degenerate guard.)
136
+ // INVARIANT (load-bearing): COASTLINE_STEP > COASTLINE_THICKNESS — i.e. every
137
+ // ring's d_k + thickness < d_(k+1). The renderer draws outer→inner; ring k's
138
+ // colour band reaches radius d_k+thickness and its flat-water overdraw reaches
139
+ // d_k. If a ring's band reached the next ring out, the inner overdraw would erase
140
+ // it. Keep step > thickness; a layout test pins it (map-layout.test.ts).
141
+ const COASTLINE_RING_COUNT = 5; // discrete coast-parallel rings
142
+ const COASTLINE_D0 = 0.0016; // innermost ring offshore distance (frac of min dim)
143
+ const COASTLINE_STEP = 0.0028; // spacing between rings (frac of min dim)
144
+ const COASTLINE_THICKNESS = 0.0014; // ring width — SAME for every ring (frac)
145
+ const COASTLINE_OPACITY_NEAR = 0.5; // innermost ring opacity
146
+ const COASTLINE_OPACITY_FAR = 0.1; // outermost ring opacity (gradual fade)
147
+ const COASTLINE_MIN_EXTENT = 0.0006; // degenerate-ring floor (frac of min dim)
148
+ const COASTLINE_MIN_EXTENT_GLOBAL = 0.0006; // same at world zoom — ring every island
149
+ // Water-line tone: mix regionStroke into water. LESS water than `lakeStroke`
150
+ // (mix 45) so the offshore lines carry a touch MORE contrast than the existing
151
+ // coast stroke and stay distinguishable from it (R10/F14).
152
+ const COASTLINE_STROKE_MIX = 32;
72
153
  // % palette-gray of bg for non-US neighbour land. Higher on dark so it reads as
73
154
  // a clear gray rather than sinking into the dark background.
74
155
  const FOREIGN_TINT_LIGHT = 30;
75
156
  const FOREIGN_TINT_DARK = 62;
76
157
  // MUTED basemap — used when a colouring dimension (score ramp or a tag group) is
77
- // active. The data hues may themselves be blue or green (e.g. `Core blue`,
78
- // `Growth teal`), which collide with the decorative blue-water / green-land
79
- // dress: a blue region vanishes into the ocean, a green one into the land. So
80
- // when regions carry the data signal the basemap RECEDES to neutral grays —
81
- // water and unscored/neighbour land become low-saturation gray, leaving the data
82
- // fills as the only saturated thing on the map (the cartographic norm for a
83
- // choropleth). Plain reference maps with no data keep the blue/green dress.
84
- // Light land is left at the page bg (cleanest white ground for the data hues);
85
- // dark land lifts off the near-black surface so dark-mixed tints stay legible.
86
- const MUTED_WATER_LIGHT = 14; // % gray of bg — pale sea
87
- const MUTED_WATER_DARK = 10;
88
- const MUTED_FOREIGN_LIGHT = 28; // neighbour land — grayer than the sea
158
+ // active. The subject water + land are ALWAYS the same faded blue/green dress
159
+ // (WATER_TINT_* / LAND_TINT_*); muted only pushes NEIGHBOUR land to a recessive
160
+ // gray so the subject country reads as the subject and the data fills own the
161
+ // saturation. Plain reference maps keep neighbour land at the fuller gray tint.
162
+ const MUTED_FOREIGN_LIGHT = 28; // neighbour land recessive gray, not green
89
163
  const MUTED_FOREIGN_DARK = 16;
90
- const MUTED_LAND_DARK = 24; // subject land on dark (light land = palette.bg)
91
- const COLO_R = 9; // spiderfy radius
164
+ const COLO_R = 9; // spiderfy ring radius floor (px)
92
165
  const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
166
+ // Coincident-POI spiderfy (stacks): two dots "stack" when their centre distance is
167
+ // below (rA+rB)*STACK_OVERLAP — i.e. the markers visibly overlap. A ≥2-member stack
168
+ // collapses to a single ringed `+N` badge at rest and fans out on click; export
169
+ // renders the expanded fan directly (all labels visible). Distinct-but-dense
170
+ // clusters (centres farther than combined radii) are untouched — current behavior.
171
+ const STACK_OVERLAP = 1.0; // overlap factor for the coincidence threshold
172
+ const STACK_RING_MAX = 8; // ≤ this many → even circle; more → golden-angle spiral
173
+ const STACK_RING_GAP = 4; // px min gap between adjacent expanded dots
93
174
  const FAN_STEP = 16; // px perpendicular offset between parallel edges
94
175
  const ARC_CURVE_FRAC = 0.18; // default arc bow as a fraction of leg length
95
176
 
@@ -98,6 +179,9 @@ export interface MapLayoutRegion {
98
179
  readonly d: string; // SVG path data
99
180
  readonly fill: string;
100
181
  readonly stroke: string;
182
+ /** Human-readable display name (e.g. "France", "California"). Set for EVERY
183
+ * region — authored and base/context alike — and emitted as
184
+ * `data-region-name` so the app can show it on hover. */
101
185
  readonly label?: string;
102
186
  readonly lineNumber: number;
103
187
  readonly layer: 'base' | 'country' | 'us-state';
@@ -121,6 +205,30 @@ export interface MapLayoutInset {
121
205
  readonly w: number;
122
206
  readonly h: number;
123
207
  readonly points: ReadonlyArray<readonly [number, number]>;
208
+ /** The FITTED inset projection (fit to this frame's screen box inside
209
+ * `placeInset`). Load-bearing for pixel↔lonLat over the AK/HI insets: the
210
+ * un-fitted `alaskaProjection()`/`hawaiiProjection()` factories would invert
211
+ * to garbage, so the geo-query inverts against THIS instance. */
212
+ readonly projection: GeoProjection;
213
+ /** Neighbour land (e.g. Canada beside Alaska) projected with this inset's
214
+ * fitted projection and clipped to the box — drawn BEHIND the state so a land
215
+ * border reads as land, not coast. Without it the state's outer ring buffers
216
+ * outward over open box-ocean and the land border sprouts coastline rings.
217
+ * `undefined` when no neighbour land falls inside the box. */
218
+ readonly contextLand?: { readonly d: string; readonly fill: string };
219
+ }
220
+
221
+ /** Post-projection non-uniform stretch applied to GLOBAL fits (fill-the-canvas).
222
+ * `null` for regional fits. The geo-query applies the forward form when
223
+ * projecting and the inverse before `projection.invert`. Mirrors the `stretch`
224
+ * closure used for the path stream: px = ox + (x - bx0) * sx. */
225
+ export interface MapLayoutStretch {
226
+ readonly sx: number;
227
+ readonly sy: number;
228
+ readonly ox: number;
229
+ readonly oy: number;
230
+ readonly bx0: number;
231
+ readonly by0: number;
124
232
  }
125
233
 
126
234
  export interface MapLayoutPoi {
@@ -137,6 +245,38 @@ export interface MapLayoutPoi {
137
245
  /** Tag values keyed by lowercased group name — emitted as `data-tag-<group>`
138
246
  * so the app can spotlight markers on legend-entry hover (mirrors regions). */
139
247
  readonly tags?: Readonly<Record<string, string>>;
248
+ /** Set when this marker is a member of a coincident stack (spiderfy). Its
249
+ * `cx/cy` is the EXPANDED ring position (the source-of-truth used by export +
250
+ * the no-JS default); the app collapses the stack to a single badge at rest
251
+ * via `data-cluster-member`. */
252
+ readonly clusterId?: string;
253
+ }
254
+
255
+ /** A coincident POI stack (≥2 markers whose dots overlap). Laid out EXPANDED
256
+ * (members fanned onto a ring/spiral with legs to the centroid) — that geometry
257
+ * is the source of truth: a static export shows every member + label with no
258
+ * special-casing. The renderer ALSO emits a collapsed `+N`-style badge (a neutral
259
+ * dot ringed with the bare count) at the centroid, hidden by default; the app
260
+ * collapses each stack at rest (hide members, show badge) and expands on click. */
261
+ export interface MapLayoutCluster {
262
+ /** Stable id (the first member's POI id). Mirrored on member dots/labels/legs as
263
+ * `data-cluster-member` and on the badge as `data-cluster`. */
264
+ readonly id: string;
265
+ /** Centroid (collapsed badge position + spider-leg hub). */
266
+ readonly cx: number;
267
+ readonly cy: number;
268
+ /** Member count = badge text (bare `N`, RQ1). */
269
+ readonly count: number;
270
+ /** Radius of the transparent pointer hit-area centred on the centroid — covers
271
+ * the collapsed badge AND the expanded dot ring so a hover/click anywhere over
272
+ * the stack drives the spiderfy controller. */
273
+ readonly hitR: number;
274
+ /** Spider legs: centroid → each expanded member dot (member's own colour). */
275
+ readonly legs: ReadonlyArray<{
276
+ readonly x2: number;
277
+ readonly y2: number;
278
+ readonly color: string;
279
+ }>;
140
280
  }
141
281
 
142
282
  /** A drawn connector -- an edge or a route leg (same geometry contract). */
@@ -148,6 +288,17 @@ export interface MapLayoutLeg {
148
288
  readonly label?: string;
149
289
  readonly labelX?: number;
150
290
  readonly labelY?: number;
291
+ /** Text colour for the label — contrast-picked against the background fill the
292
+ * label sits on (the choropleth/tag region under it, or land/water), so a
293
+ * freight tag over a dark scored country reads light, over pale land reads
294
+ * dark. Absent ⇒ renderer falls back to the muted default. */
295
+ readonly labelColor?: string;
296
+ /** Whether the label needs a halo. Only set when the chosen text colour's
297
+ * contrast against the underlying fill is marginal (mid-tone fills); clear
298
+ * fills get no ghost. */
299
+ readonly labelHalo?: boolean;
300
+ /** Halo colour (opposite lightness of `labelColor`) when {@link labelHalo}. */
301
+ readonly labelHaloColor?: string;
151
302
  readonly lineNumber: number;
152
303
  }
153
304
 
@@ -168,6 +319,24 @@ export interface PlacedLabel {
168
319
  /** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
169
320
  * the label + leader so the app can spotlight the dot on label hover. */
170
321
  readonly poiId?: string;
322
+ /** Cartographic italic (context-label water names, §24B). Default upright. */
323
+ readonly italic?: boolean;
324
+ /** Cartographic letter-spacing in px (context-label water names). Default 0. */
325
+ readonly letterSpacing?: number;
326
+ /** Pre-wrapped display lines (context-label water names — §24B). When present
327
+ * the renderer stacks these as centred tspans instead of `text`; `text` keeps
328
+ * the single-string form for hit-testing/measurement. Absent ⇒ single line. */
329
+ readonly lines?: readonly string[];
330
+ /** Hover-only label: emitted invisible (opacity 0 + `data-poi-hidden`) in the
331
+ * preview and revealed on POI/label hover; OMITTED entirely from static
332
+ * export. Set when a POI cluster can't place its labels cleanly (see the
333
+ * extent/count/clean gate in the POI-label block). Default-undefined =
334
+ * visible. Hidden labels are NOT pushed into `obstacles`. */
335
+ readonly hidden?: boolean;
336
+ /** Set when this label belongs to a coincident-stack member (spiderfy). Emitted
337
+ * visible (export + expanded view) but tagged `data-cluster-member` so the app
338
+ * hides it when the stack is collapsed to its badge. */
339
+ readonly clusterMember?: string;
171
340
  readonly lineNumber: number;
172
341
  }
173
342
 
@@ -194,6 +363,44 @@ export interface MapLayoutRiver {
194
363
  readonly width: number;
195
364
  }
196
365
 
366
+ /** A drawn mountain-range relief shape — a projected polygon path. The renderer
367
+ * unions these into one clip and rules horizontal hachure lines through them. */
368
+ export interface MapLayoutRelief {
369
+ readonly d: string;
370
+ }
371
+
372
+ /** The shared hachure style for the relief lines. `null` when relief is off or
373
+ * no range survives the gates. */
374
+ export interface MapLayoutReliefHatch {
375
+ /** Line stroke — palette.text mixed into the land colour (so it's dark-on-
376
+ * light and light-on-dark automatically as palette.text flips with theme). */
377
+ readonly color: string;
378
+ /** Vertical gap between lines in SCREEN px (constant density, zoom-stable). */
379
+ readonly spacing: number;
380
+ readonly width: number;
381
+ }
382
+
383
+ /** Style object for the opt-in coastline water-lines (`coastline`, §24B.2).
384
+ * `null` when the flag is off. Carries only STYLE — no geometry; the renderer
385
+ * buffers the existing region paths (`layout.regions[].d`) and masks them to the
386
+ * water side. `d`/`thickness` are absolute SCREEN px (already resolved from a
387
+ * fraction of the fitted canvas, so they stay proportional across export sizes —
388
+ * ADR-3). */
389
+ export interface MapLayoutCoastlineStyle {
390
+ /** Water-toned line colour (a touch more contrast than `lakeStroke`). */
391
+ readonly color: string;
392
+ /** The 2 coast-parallel lines, inner→outer. `d` = offshore distance,
393
+ * `thickness` = ring width (both screen px), `opacity` fades seaward. */
394
+ readonly lines: ReadonlyArray<{
395
+ readonly d: number;
396
+ readonly thickness: number;
397
+ readonly opacity: number;
398
+ }>;
399
+ /** Per-subpath bbox-extent floor (screen px): rings smaller than this are
400
+ * dropped (de-noise tiny islands, bound the stroke cost — R5/R11). */
401
+ readonly minExtent: number;
402
+ }
403
+
197
404
  export interface MapLayout {
198
405
  readonly width: number;
199
406
  readonly height: number;
@@ -204,8 +411,21 @@ export interface MapLayout {
204
411
  readonly regions: readonly MapLayoutRegion[];
205
412
  /** Major river centerlines, drawn over land/lakes and under POIs/edges. */
206
413
  readonly rivers: readonly MapLayoutRiver[];
414
+ /** Mountain-range relief shapes (empty unless `relief` is on + the asset is
415
+ * present); the renderer clips horizontal hachure lines to their union,
416
+ * drawn over base land, under rivers/POIs/data fills. */
417
+ readonly relief: readonly MapLayoutRelief[];
418
+ /** Hachure style for the relief lines (null = relief off / none survived). */
419
+ readonly reliefHatch: MapLayoutReliefHatch | null;
420
+ /** Style for the opt-in coastline water-lines (null = `coastline` off). The
421
+ * renderer buffers `regions[]`/`insetRegions[]` paths against this style and
422
+ * masks them to the water side. */
423
+ readonly coastlineStyle: MapLayoutCoastlineStyle | null;
207
424
  readonly legs: readonly MapLayoutLeg[];
208
425
  readonly pois: readonly MapLayoutPoi[];
426
+ /** Coincident POI stacks (spiderfy). Empty when no ≥2-member overlap exists.
427
+ * The renderer draws a collapsed badge per stack; the app collapses/expands. */
428
+ readonly clusters: readonly MapLayoutCluster[];
209
429
  readonly labels: readonly PlacedLabel[];
210
430
  readonly legend: MapLayoutLegend | null;
211
431
  /** Framed AK/HI inset cutouts (albers-usa only; empty otherwise). */
@@ -213,6 +433,16 @@ export interface MapLayout {
213
433
  /** AK/HI region paths drawn inside the inset boxes (foreground, over an
214
434
  * opaque ocean fill). Paired positionally with `insets`. */
215
435
  readonly insetRegions: readonly MapLayoutRegion[];
436
+ /** The fitted MAIN projection (the conus conic for albers-usa). Exposed for
437
+ * the geo-query's pixel↔lonLat inversion — the app NEVER reconstructs it from
438
+ * metadata; it binds to this exact instance. */
439
+ readonly projection: GeoProjection;
440
+ /** Non-uniform stretch applied for GLOBAL fits (null for regional fits). */
441
+ readonly stretch: MapLayoutStretch | null;
442
+ /** Generic layout-time diagnostics channel — currently has no producers, so it
443
+ * is always empty. Kept wired up because callers merge it with the resolver's
444
+ * diagnostics for the editor lint channel. */
445
+ readonly diagnostics: readonly DgmoError[];
216
446
  }
217
447
 
218
448
  export interface LayoutOptions {
@@ -224,6 +454,11 @@ export interface LayoutOptions {
224
454
  * selects the choropleth ramp, a tag-group name selects that group, `'none'`
225
455
  * / `null` clears it. `undefined` = not provided (use the directive/default). */
226
456
  readonly activeGroup?: string | null;
457
+ /** Export-only: when true, suppress the global stretch-fill and contain-fit
458
+ * (letterbox) instead. Set by `mapExportDimensions` when it clamps/floors the
459
+ * canvas away from the content aspect, so the off-aspect canvas doesn't
460
+ * re-distort. The in-app preview pane leaves this unset (keeps stretch-fill). */
461
+ readonly preferContain?: boolean;
227
462
  }
228
463
 
229
464
  interface Size {
@@ -239,13 +474,57 @@ function geomObject(topo: BoundaryTopology): {
239
474
  return topo.objects[key]!;
240
475
  }
241
476
 
242
- /** Decode every feature of a topology into GeoJSON, keyed by ISO id. */
477
+ // Cache the (expensive) topojson→GeoJSON decode by topology object identity. The
478
+ // MapData topology objects are stable within a session, so the same layer is
479
+ // decoded once even though the export path now builds the projection twice (once
480
+ // for dimension sizing, once for layout). Keyed by object identity (WeakMap), so
481
+ // it never holds stale data across a data reload. CALLERS MUST TREAT THE RESULT AS
482
+ // IMMUTABLE — `buildMapProjection` copies the world layer before its crisp-upgrade
483
+ // `.set()` so the cached map is never mutated.
484
+ const decodeCache = new WeakMap<BoundaryTopology, Map<string, GeoFeature>>();
485
+
486
+ /** Combine two decoded features that share an ISO id into one MultiPolygon — so a
487
+ * country split across multiple topology geometries (e.g. na-land's `FR`) draws
488
+ * all its parts rather than only the last. Polygon/MultiPolygon coordinates are
489
+ * flattened into a single MultiPolygon ring list; a feature whose geometry is
490
+ * neither is returned unchanged (nothing sensible to merge). */
491
+ function mergeFeatures(a: GeoFeature, b: GeoFeature): GeoFeature {
492
+ const polysOf = (f: GeoFeature): number[][][][] | null => {
493
+ const g = f.geometry as { type?: string; coordinates?: unknown } | null;
494
+ if (!g) return null;
495
+ if (g.type === 'Polygon') return [g.coordinates as number[][][]];
496
+ if (g.type === 'MultiPolygon') return g.coordinates as number[][][][];
497
+ return null;
498
+ };
499
+ const pa = polysOf(a);
500
+ const pb = polysOf(b);
501
+ if (!pa || !pb) return a; // can't merge non-polygonal geometry — keep the first
502
+ return {
503
+ ...a,
504
+ geometry: { type: 'MultiPolygon', coordinates: [...pa, ...pb] },
505
+ };
506
+ }
507
+
508
+ /** Decode every feature of a topology into GeoJSON, keyed by ISO id. Memoized by
509
+ * topology identity — the returned map is shared, so do NOT mutate it (copy first
510
+ * if you need to). Natural-Earth source carries hazards this guards against: a
511
+ * null-geometry sovereignty stub tagged with a real ISO code (e.g. "Ashmore and
512
+ * Cartier Is." shares `AU` with Australia) would otherwise CLOBBER the real
513
+ * country's geometry — `set` keeps the last write. So null geometries are
514
+ * skipped, and a genuine duplicate id (two real geometries, e.g. na-land `FR`)
515
+ * is MERGED into one MultiPolygon instead of one part overwriting the other. */
243
516
  function decodeLayer(topo: BoundaryTopology): Map<string, GeoFeature> {
517
+ const cached = decodeCache.get(topo);
518
+ if (cached) return cached;
244
519
  const out = new Map<string, GeoFeature>();
245
520
  for (const g of geomObject(topo).geometries) {
246
521
  const f = feature(topo as never, g as never) as unknown as GeoFeature;
247
- out.set(g.id, { ...f, id: g.id });
522
+ if (!f.geometry) continue; // null-geometry stub — never renders, must not clobber
523
+ const tagged = { ...f, id: g.id };
524
+ const existing = out.get(g.id);
525
+ out.set(g.id, existing ? mergeFeatures(existing, tagged) : tagged);
248
526
  }
527
+ decodeCache.set(topo, out);
249
528
  return out;
250
529
  }
251
530
 
@@ -266,21 +545,55 @@ function projectionFor(family: ProjectionFamily): GeoProjection {
266
545
  return usConusProjection();
267
546
  case 'mercator':
268
547
  return geoMercator();
548
+ case 'equal-earth':
549
+ // Equal-area pseudocylindrical: areas stay honest so a choropleth's shading
550
+ // isn't distorted by projection (the default for *data* world maps).
551
+ return geoEqualEarth();
552
+ case 'equirectangular':
553
+ // Plate carrée: straight lat/lon grid, fully rectangular frame. The default
554
+ // for dataless *reference* world maps — a clean conventional wall-map look.
555
+ return geoEquirectangular();
269
556
  case 'natural-earth':
557
+ // Curved pseudocylindrical compromise. Retained for completeness; areas are
558
+ // only approximately preserved.
270
559
  return geoNaturalEarth1();
271
- case 'equirectangular':
272
560
  default:
273
- // Plate carrée: x = λ, y = -φ. Cylindrical, so the extent's four CORNERS
274
- // are its projected extremes — fitExtent frames it edge-to-edge with no
275
- // bulge overflow (unlike naturalEarth, whose curved sides overrun a
276
- // corner fit and clip the continents). Fills the rectangle: no rounded
277
- // gray corners, no split landmass at the frame edge.
278
561
  return geoEquirectangular();
279
562
  }
280
563
  }
281
564
 
282
565
  /** US state ISO codes that render as insets (drawn off the conus). */
283
566
  const INSET_STATES = new Set(['US-AK', 'US-HI']);
567
+ // Rough bboxes deciding whether a point sits in Alaska / Hawaii — the AK/HI
568
+ // insets render only when the map references that state (§24B.2). Alaska's
569
+ // Aleutians cross the antimeridian, so its longitude test is two-sided.
570
+ const inAlaska = (lon: number, lat: number): boolean =>
571
+ lat >= 51 && (lon <= -129 || lon >= 172);
572
+ const inHawaii = (lon: number, lat: number): boolean =>
573
+ lat >= 18 && lat <= 23 && lon >= -161 && lon <= -154;
574
+ /** US states that visually abut a foreign country (Canada `CA` / Mexico `MX`) in
575
+ * the drawn map — a fixed, extent-independent geographic fact. Used ONLY by the
576
+ * colorize pass to bridge the US-states and world topologies (which share no
577
+ * TopoJSON arcs) so a border state never shares a hue with the country it touches
578
+ * (§24B colorize). Great-Lakes water-gap states (OH/PA) are excluded — they don't
579
+ * visually touch Canada's drawn polygon. */
580
+ const FOREIGN_BORDER: Readonly<Record<string, readonly string[]>> = {
581
+ CA: [
582
+ 'US-AK',
583
+ 'US-WA',
584
+ 'US-ID',
585
+ 'US-MT',
586
+ 'US-ND',
587
+ 'US-MN',
588
+ 'US-MI',
589
+ 'US-NY',
590
+ 'US-VT',
591
+ 'US-NH',
592
+ 'US-ME',
593
+ ],
594
+ MX: ['US-CA', 'US-AZ', 'US-NM', 'US-TX'],
595
+ };
596
+
284
597
  /** US territories excluded from the contiguous-US fit frame. */
285
598
  const US_NON_CONUS = new Set([
286
599
  'US-AK',
@@ -294,37 +607,34 @@ const US_NON_CONUS = new Set([
294
607
 
295
608
  /** The map's water / backdrop colour for a palette — the single source of truth
296
609
  * shared by the renderer's `<rect>` fill and any host wrapper that needs to
297
- * match it (so letterbox gaps around the SVG don't show a stray band). When
298
- * `dataActive` (a score ramp or tag group is colouring regions) the sea recedes
299
- * to a pale neutral so blue/green data hues don't blend into it. */
610
+ * match it (so letterbox gaps around the SVG don't show a stray band). Always a
611
+ * VERY faded blue uniform whether or not a colouring dimension is active — so
612
+ * it reads as water without competing with saturated blue/green data hues.
613
+ * `_dataActive` is retained for signature stability (the sea no longer changes
614
+ * with data; only neighbour land recedes — see layout's `foreignFill`). */
300
615
  export function mapBackgroundColor(
301
616
  palette: PaletteColors,
302
617
  isDark = false,
303
- dataActive = false
618
+ _dataActive = false
304
619
  ): string {
305
- if (dataActive)
306
- return mix(
307
- palette.colors.gray,
308
- palette.bg,
309
- isDark ? MUTED_WATER_DARK : MUTED_WATER_LIGHT
310
- );
311
- return mix(palette.colors.blue, palette.bg, WATER_TINT);
620
+ return mix(
621
+ palette.colors.blue,
622
+ palette.bg,
623
+ isDark ? WATER_TINT_DARK : WATER_TINT_LIGHT
624
+ );
312
625
  }
313
626
 
314
627
  /** The map's neutral (unscored/untagged) LAND colour — the base every region
315
628
  * blends from. Exported so a host can DIM a region to plain land (rather than
316
629
  * lowering opacity, which would let the water show through and make the shape
317
- * read as ocean). Matches the layout's `neutralFill`. Green reference dress by
318
- * default; neutral (page bg on light, lifted gray on dark) when `dataActive`. */
630
+ * read as ocean). Matches the layout's `neutralFill`. Always a VERY faded green
631
+ * uniform whether or not data is active so saturated tag/score tints read
632
+ * clearly against it. `_dataActive` is retained for signature stability. */
319
633
  export function mapNeutralLandColor(
320
634
  palette: PaletteColors,
321
635
  isDark: boolean,
322
- dataActive = false
636
+ _dataActive = false
323
637
  ): string {
324
- if (dataActive)
325
- return isDark
326
- ? mix(palette.colors.gray, palette.bg, MUTED_LAND_DARK)
327
- : palette.bg;
328
638
  return mix(
329
639
  palette.colors.green,
330
640
  palette.bg,
@@ -332,54 +642,223 @@ export function mapNeutralLandColor(
332
642
  );
333
643
  }
334
644
 
335
- export function layoutMap(
336
- resolved: ResolvedMap,
337
- data: MapData,
338
- size: Size,
339
- opts: LayoutOptions
340
- ): MapLayout {
341
- const { palette, isDark } = opts;
342
- const { width, height } = size;
645
+ /** Result of {@link buildMapProjection}: the (fresh, un-fitted) projection, fit
646
+ * target, global/regional classification, and decoded basemap layers — all
647
+ * derived from `(resolved, data)` alone (NOT canvas-size dependent). `layoutMap`
648
+ * consumes these then does the size-dependent `fitExtent` + stretch/clip;
649
+ * `mapContentAspect` consumes `projection`/`fitTarget` (+ the layers, for the
650
+ * contain-fit ink bounds). MUST be rebuilt per call — d3 projections are mutated
651
+ * in place by `fitExtent`/`clipExtent`, so the instance is never shared. */
652
+ export interface MapProjectionBuild {
653
+ readonly projection: GeoProjection;
654
+ readonly fitTarget: GeoFC;
655
+ /** ≥270° lon or ≥130° lat span ⇒ global (stretch-fill) vs regional (contain). */
656
+ readonly fitIsGlobal: boolean;
657
+ readonly worldLayer: Map<string, GeoFeature>;
658
+ readonly usLayer: Map<string, GeoFeature> | null;
659
+ readonly usCrisp: boolean;
660
+ readonly wantsUsStates: boolean;
661
+ /** The RAW world topology `worldLayer` derives from (coarse vs detail). Carried
662
+ * out so the colorize pass can build arc-adjacency on the same source the
663
+ * drawn countries came from — memoized on this stable asset object. */
664
+ readonly worldTopo: BoundaryTopology;
665
+ }
343
666
 
667
+ /** Build the projection, fit target, and decoded basemap layers for a resolved
668
+ * map. Extracted from `layoutMap` so the export-dimension helper
669
+ * (`mapContentAspect`) frames the canvas with the IDENTICAL projection + fit
670
+ * target the renderer draws with — divergence here would mismatch the canvas
671
+ * aspect against the geometry. The returned projection has `.rotate` applied but
672
+ * NOT `.fitExtent` (that is canvas-size dependent and stays in `layoutMap`). */
673
+ export function buildMapProjection(
674
+ resolved: ResolvedMap,
675
+ data: MapData
676
+ ): MapProjectionBuild {
344
677
  // -- Basemap decode --
345
678
  const wantsUsStates = resolved.basemaps.subdivisions.includes('us-states');
346
679
  // In a US (albers-usa + us-states) view the surrounding land was world-atlas
347
680
  // 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
348
681
  // assets are present, swap them in so neighbours (Canada/Mexico) and the Great
349
682
  // Lakes match the states' resolution. Falls back to the world tiers otherwise.
683
+ // Crisp NA assets apply to BOTH the national albers-usa view AND a regional
684
+ // US mercator view (POI-only region framing — e.g. a single state). A
685
+ // US-oriented mercator frame is sub-world and entirely within North America by
686
+ // construction, so the NA-clipped 10m land/lakes fit it; the bbox guard below
687
+ // still keeps non-NA countries on world geometry. Excludes equirectangular
688
+ // (a world US-states choropleth) where the NA clip would crop the globe.
350
689
  const usCrisp =
351
- resolved.projection === 'albers-usa' && wantsUsStates && !!data.naLand;
690
+ (resolved.projection === 'albers-usa' ||
691
+ resolved.projection === 'mercator') &&
692
+ wantsUsStates &&
693
+ !!data.naLand;
352
694
  // Base world layer. In a US view use the DETAIL tier (full global coverage) so
353
695
  // distant context — South America, northern Canada, etc. — is present and can
354
- // draw when it falls inside the frame. (`naLand` alone is bbox-clipped to lon
355
- // -140..-52 / lat 10..66, so it has no S. America and a truncated Canada; using
356
- // it as the base would leave ocean where that land belongs.)
696
+ // draw when it falls inside the frame.
357
697
  const worldTopo = usCrisp
358
698
  ? data.worldDetail
359
699
  : resolved.basemaps.world === 'detail'
360
700
  ? data.worldDetail
361
701
  : data.worldCoarse;
362
- const worldLayer = decodeLayer(worldTopo);
363
- // Crisp upgrade: `naLand` is 10m country land (vs the base's 50m) but clipped to
364
- // a North-America bbox. Swap a country's geometry to the crisp version ONLY when
365
- // its full (base) bounds lie inside that clip box so contained neighbours
366
- // (Mexico, Central America, the Caribbean) sharpen to match the 10m states,
367
- // while countries the clip would truncate (Canada, Greenland) keep their full
368
- // base shape. Coast off-frame still bleeds; nothing is lost.
702
+ // Copy the cached decode — the crisp-upgrade below mutates `worldLayer` via
703
+ // `.set()`, which must not poison the shared `decodeLayer` cache.
704
+ const worldLayer = new Map(decodeLayer(worldTopo));
705
+ // Crisp upgrade: swap a country's geometry to the 10m `naLand` version ONLY
706
+ // when its full (base) bounds lie inside the NA clip box.
369
707
  if (usCrisp && data.naLand) {
370
- // NA clip bbox from the data build (scripts/build-map-data.mjs NA_BBOX).
371
708
  const [nbW, nbS, nbE, nbN] = [-140, 10, -52, 66];
372
709
  const crisp = decodeLayer(data.naLand);
373
710
  for (const [iso, cf] of crisp) {
374
711
  const base = worldLayer.get(iso);
375
712
  if (!base) continue; // crisp-only id with no base → skip (avoid orphans)
376
713
  const [[bw, bs], [be, bn]] = geoBounds(base as never);
714
+ // Keep the base feature's `properties` (the country name) — the crisp
715
+ // `naLand` geometry carries none, and the context-label layer reads the
716
+ // name from here. Without this the label falls back to the bare ISO code.
377
717
  if (bw >= nbW && be <= nbE && bs >= nbS && bn <= nbN)
378
- worldLayer.set(iso, cf);
718
+ worldLayer.set(iso, { ...cf, properties: base.properties });
379
719
  }
380
720
  }
381
721
  const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
382
722
 
723
+ // -- Projection + fit (AR2) --
724
+ // The extent outline sampled as a MultiPoint (NOT a Polygon — a hand-built
725
+ // lat/lon rectangle's spherical winding is ambiguous to d3-geo). Sampled ALONG
726
+ // the four edges so a curved projection (natural-earth) is framed at its bulge.
727
+ const extentOutline = (): GeoFeature => {
728
+ const [[w, s], [e, n]] = resolved.extent;
729
+ const N = 16;
730
+ const coords: Array<[number, number]> = [];
731
+ for (let i = 0; i <= N; i++) {
732
+ const t = i / N;
733
+ const lon = w + (e - w) * t;
734
+ const lat = s + (n - s) * t;
735
+ coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
736
+ }
737
+ return {
738
+ type: 'Feature',
739
+ properties: {},
740
+ geometry: { type: 'MultiPoint', coordinates: coords },
741
+ };
742
+ };
743
+
744
+ let fitFeatures: GeoFeature[];
745
+ if (resolved.projection === 'albers-usa' && usLayer) {
746
+ // Frame the contiguous 48 + DC (insets/territories excluded). The conic
747
+ // projects everything else around it.
748
+ fitFeatures = [...usLayer.entries()]
749
+ .filter(([iso]) => !US_NON_CONUS.has(iso))
750
+ .map(([, f]) => f);
751
+ // Expand the frame to include referenced Canada/Mexico content so a
752
+ // near-border neighbour (e.g. Toronto) is visible rather than bleeding off
753
+ // the canvas edge. Only CA/MX content can reach this branch (the resolver's
754
+ // NA rule), so the frame can only grow toward those neighbours. AK/HI POIs
755
+ // stay insets — excluded here. Content-driven: a neighbour POI adds only its
756
+ // point (US barely shrinks); a neighbour country fill adds its full geometry.
757
+ const neighborPoints: Array<[number, number]> = resolved.pois
758
+ .filter((p) => !inAlaska(p.lon, p.lat) && !inHawaii(p.lon, p.lat))
759
+ .map((p) => [p.lon, p.lat]);
760
+ if (neighborPoints.length > 0) {
761
+ fitFeatures.push({
762
+ type: 'Feature',
763
+ properties: {},
764
+ geometry: { type: 'MultiPoint', coordinates: neighborPoints },
765
+ });
766
+ }
767
+ for (const r of resolved.regions) {
768
+ if (r.layer === 'country' && (r.iso === 'CA' || r.iso === 'MX')) {
769
+ const cf = worldLayer.get(r.iso);
770
+ if (cf) fitFeatures.push(cf);
771
+ }
772
+ }
773
+ } else {
774
+ fitFeatures = [extentOutline()];
775
+ }
776
+ const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
777
+
778
+ const projection = projectionFor(resolved.projection);
779
+ // mercator / natural-earth: rotate to the extent's center longitude BEFORE
780
+ // fitting (rotate changes the bounds fitExtent measures). albers-usa is a
781
+ // US-only composite with NO .rotate -- never call it (AR2).
782
+ if (resolved.projection !== 'albers-usa') {
783
+ let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
784
+ if (centerLon > 180) centerLon -= 360;
785
+ projection.rotate([-centerLon, 0]);
786
+ }
787
+
788
+ // Global vs regional classification (drives stretch-fill vs contain-fit).
789
+ const fitGB = geoBounds(fitTarget as never) as [
790
+ [number, number],
791
+ [number, number],
792
+ ];
793
+ const fitIsGlobal =
794
+ fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
795
+
796
+ return {
797
+ projection,
798
+ fitTarget,
799
+ fitIsGlobal,
800
+ worldLayer,
801
+ usLayer,
802
+ usCrisp,
803
+ wantsUsStates,
804
+ worldTopo,
805
+ };
806
+ }
807
+
808
+ /** Split a projected geoPath `d` into its subpath rings (point arrays). geoPath
809
+ * emits polygons as straight `M`/`L`/`Z` segments (no curves), so a flat parse
810
+ * is exact. Each ring is one subpath (an outer boundary OR a hole); classify
811
+ * outer-vs-hole downstream (e.g. via containment depth or signed area). Used by
812
+ * fill hit-testing here and by the renderer's coastline water-lines. */
813
+ export function parsePathRings(d: string): Array<Array<[number, number]>> {
814
+ const rings: Array<Array<[number, number]>> = [];
815
+ let cur: Array<[number, number]> = [];
816
+ const re = /([MLZ])([^MLZ]*)/g;
817
+ let m: RegExpExecArray | null;
818
+ while ((m = re.exec(d))) {
819
+ if (m[1] === 'Z') {
820
+ if (cur.length) rings.push(cur);
821
+ cur = [];
822
+ continue;
823
+ }
824
+ if (m[1] === 'M' && cur.length) {
825
+ rings.push(cur);
826
+ cur = [];
827
+ }
828
+ const nums = m[2]!.split(/[ ,]+/).map(Number);
829
+ for (let i = 0; i + 1 < nums.length; i += 2) {
830
+ const x = nums[i]!;
831
+ const y = nums[i + 1]!;
832
+ if (Number.isFinite(x) && Number.isFinite(y)) cur.push([x, y]);
833
+ }
834
+ }
835
+ if (cur.length) rings.push(cur);
836
+ return rings;
837
+ }
838
+
839
+ export function layoutMap(
840
+ resolved: ResolvedMap,
841
+ data: MapData,
842
+ size: Size,
843
+ opts: LayoutOptions
844
+ ): MapLayout {
845
+ const { palette, isDark } = opts;
846
+ const { width, height } = size;
847
+
848
+ // -- Projection, fit target & basemap decode (shared with mapContentAspect so
849
+ // the export canvas aspect matches the drawn geometry — see buildMapProjection).
850
+ // The projection here has .rotate applied but NOT .fitExtent (done below, as it
851
+ // depends on canvas width/height). --
852
+ const {
853
+ projection,
854
+ fitTarget,
855
+ fitIsGlobal,
856
+ worldLayer,
857
+ usLayer,
858
+ usCrisp,
859
+ worldTopo,
860
+ } = buildMapProjection(resolved, data);
861
+
383
862
  const usContext = usLayer !== null;
384
863
  // Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
385
864
  // colouring dimension is active — defined below, once `activeGroup` is known.
@@ -389,17 +868,33 @@ export function layoutMap(
389
868
  const regionStroke = isDark
390
869
  ? mix(palette.bg, palette.text, 78) // dark theme: near-bg dark outline
391
870
  : mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
871
+ // Lake shoreline. Lakes are painted as water OVER the land and the region
872
+ // borders, so without an edge they read as a featureless patch that simply
873
+ // erases whatever state/country border ran beneath them (worst in muted/data
874
+ // mode, where the water is a pale gray barely distinct from the land). A soft
875
+ // coastline — between the border colour and the water, not a hard black line —
876
+ // gives the lake a defined edge; that edge legitimately REPLACES the border
877
+ // running through it (real choropleths carve lakes out of the land, so the
878
+ // shoreline IS the boundary at the water). Defined here; `water` is below.
392
879
 
393
880
  // -- Region fill model (choropleth + categorical; AR4/AR6) --
394
881
  const values = resolved.regions
395
882
  .filter((r) => r.value !== undefined)
396
883
  .map((r) => r.value!);
397
- const scaleOverride = resolved.directives.scale;
398
- const rampMin = scaleOverride ? scaleOverride.min : Math.min(...values);
399
- const rampMax = scaleOverride ? scaleOverride.max : Math.max(...values);
400
- // Value ramp is red so valued regions stand out against the blue water
401
- // (palette.primary is a blue in most palettes and would blend in).
402
- const rampHue = palette.colors.red;
884
+ // Ramp auto-fits (the `scale` directive is gone). For all-non-negative data the
885
+ // low end anchors at 0 so every such choropleth shares a 0 baseline (decision
886
+ // C); mixed-sign data fits data-min→data-max. Only the LOW end is shared —
887
+ // different maxes still differ at the high end (cross-map comparability is not
888
+ // recovered, by design).
889
+ const allNonNegative = values.length > 0 && values.every((v) => v >= 0);
890
+ const rampMin = allNonNegative ? 0 : Math.min(...values);
891
+ const rampMax = Math.max(...values);
892
+ // Value ramp defaults to red so valued regions stand out against the blue
893
+ // water (palette.primary is a blue in most palettes and would blend in). A
894
+ // trailing color on `region-metric` (§24B.3) overrides the hue idiomatically.
895
+ const rampHue =
896
+ resolveColor(resolved.directives.regionMetricColor ?? '', palette) ??
897
+ palette.colors.red;
403
898
  const hasRamp = values.length > 0;
404
899
 
405
900
  // Colouring dimension (AR4, bivariate): the value ramp and each tag group are
@@ -433,22 +928,17 @@ export function layoutMap(
433
928
  }
434
929
  const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
435
930
 
436
- // Basemap dress. When a colouring dimension is active the regions carry the
437
- // signal, so the sea/land recede to neutral grays (the data hues — which may be
438
- // blue or green would otherwise blend into a blue ocean / green land). A
439
- // plain reference map (no score, no tag activeGroup null) keeps the blue
440
- // water + green land. The bare `muted` / `natural` flags force either dress
441
- // regardless (so two maps in a deck can match); absent this auto rule. In a
442
- // US view the surrounding world layer is always recessive gray so the US reads
443
- // as the subject.
444
- const mutedBasemap =
445
- resolved.directives.basemapStyle === 'muted'
446
- ? true
447
- : resolved.directives.basemapStyle === 'natural'
448
- ? false
449
- : activeGroup !== null;
931
+ // Basemap dress (fixed automatic aesthetic no directive). Subject water +
932
+ // land always wear the SAME faded blue/green dress (subtle enough that
933
+ // saturated tag/score tints never blend into it), so every map looks
934
+ // consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a colouring
935
+ // dimension is active the surrounding world recedes to a paler gray so the
936
+ // subject + its data fills dominate; a plain reference map keeps neighbour
937
+ // land at the fuller gray.
938
+ const mutedBasemap = activeGroup !== null;
450
939
  const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
451
940
  const water = mapBackgroundColor(palette, isDark, mutedBasemap);
941
+ const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
452
942
  const foreignFill = mix(
453
943
  palette.colors.gray,
454
944
  palette.bg,
@@ -461,6 +951,70 @@ export function layoutMap(
461
951
  : FOREIGN_TINT_LIGHT
462
952
  );
463
953
 
954
+ // -- Colorize: content-inferred distinct political fills (§24B) --
955
+ // Colorize is the DEFAULT dress for any map that is NOT colouring regions by
956
+ // data. The ONLY two things that turn it off: (1) a data dimension exists on a
957
+ // region (any `value:` or tag group) — data owns the saturation, so the basemap
958
+ // recedes to the gray choropleth/categorical dress; or (2) the `no-colorize`
959
+ // opt-out. Everything else — bare `map`, POI/route-only maps, named regions
960
+ // without data — gets distinct political pastels (markers/routes draw on top).
961
+ // Data EXISTENCE (not which dimension is *active*) is the discriminator, so a
962
+ // tag map viewed with `active-tag none` still keeps its neutral data dress; and
963
+ // the live-preview `California` → `California value: 92` edit transitions
964
+ // colorized → choropleth cleanly.
965
+ const colorizeActive =
966
+ resolved.directives.noColorize !== true &&
967
+ !hasRamp &&
968
+ resolved.tagGroups.length === 0;
969
+ // Hue per ISO over ONE UNIFIED graph spanning every drawn topology, so no two
970
+ // bordering regions share a hue — INCLUDING across the international seam. The
971
+ // world and us-states topologies share no TopoJSON arcs, so neighbors() is blind
972
+ // to the US↔Canada/Mexico border; those edges are fixed geographic facts (FOREIGN
973
+ // _BORDER) added explicitly. Coloring is global (whole topologies, not the drawn
974
+ // subset) and country codes sort before `US-XX`, so a country's colour is decided
975
+ // before any state is visited → extent-independent (France identical at any width
976
+ // and in an inset; AC10) and the same whether or not states are drawn. Every drawn
977
+ // ISO is in the graph, so the lookup never misses → no green leak (F14).
978
+ const colorByIso = new Map<string, string>();
979
+ if (colorizeActive) {
980
+ const adjacency = new Map<string, string[]>();
981
+ const addEdges = (src: ReadonlyMap<string, readonly string[]>): void => {
982
+ for (const [iso, ns] of src) {
983
+ const cur = adjacency.get(iso);
984
+ if (cur) cur.push(...ns);
985
+ else adjacency.set(iso, [...ns]);
986
+ }
987
+ };
988
+ addEdges(buildAdjacency(worldTopo)); // countries
989
+ if (usLayer) {
990
+ addEdges(buildAdjacency(data.usStates)); // US states
991
+ // International border seam (US states ↔ Canada/Mexico), both directions —
992
+ // the two topologies don't share arcs, so this is the only place the seam
993
+ // is expressible. Skip any endpoint not in the graph (defensive).
994
+ for (const [country, states] of Object.entries(FOREIGN_BORDER)) {
995
+ const cn = adjacency.get(country);
996
+ if (!cn) continue;
997
+ for (const st of states) {
998
+ const sn = adjacency.get(st);
999
+ if (!sn) continue;
1000
+ cn.push(st);
1001
+ sn.push(country);
1002
+ }
1003
+ }
1004
+ }
1005
+ const { byIso, huesNeeded } = assignColors(
1006
+ [...adjacency.keys()],
1007
+ adjacency
1008
+ );
1009
+ const tints = politicalTints(palette, huesNeeded, isDark);
1010
+ for (const [iso, idx] of byIso) colorByIso.set(iso, tints[idx]!);
1011
+ }
1012
+ /** Per-region boundary stroke under colorize. Distinct FILLS aren't enough —
1013
+ * the boundary sells the separation (F10). Darken per-region toward the
1014
+ * palette text so the outline tracks each pastel; width stays the renderer
1015
+ * constant (the darker tone, not weight, does the work — AC12). */
1016
+ const colorizeStroke = (fill: string): string => mix(fill, palette.text, 35);
1017
+
464
1018
  // Score ramp base: a NEUTRAL tint of the page, NOT the (green) land colour —
465
1019
  // blending red toward green produced muddy brown mid-tones that blurred into
466
1020
  // the unscored land. Anchored to a neutral, the ramp is a clean single-hue red
@@ -503,77 +1057,42 @@ export function layoutMap(
503
1057
  );
504
1058
  };
505
1059
 
506
- /** A region's fill under the ACTIVE colouring dimension (AR4, bivariate):
507
- * value-active ramp for valued regions, neutral otherwise; a tag group
508
- * active that group's tag colour, neutral otherwise (value ignored). */
1060
+ /** A §1.5 trailing-token color on a region/POI flat categorical fill, the
1061
+ * same saturated tint a tag entry gets (so direct colors and tag colors read
1062
+ * alike). Resolves the NAME against the active palette; null if unrecognized. */
1063
+ const directFill = (name: string | undefined): string | null => {
1064
+ const hex = name ? resolveColor(name, palette) : null;
1065
+ if (!hex) return null;
1066
+ return mix(hex, palette.bg, isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT);
1067
+ };
1068
+
1069
+ /** A region's fill. A direct trailing color (§24B.4) is a flat override that
1070
+ * paints regardless of the active dimension (no legend entry). Otherwise the
1071
+ * ACTIVE colouring dimension (AR4, bivariate): value-active → ramp for valued
1072
+ * regions, neutral otherwise; a tag group active → that group's tag colour,
1073
+ * neutral otherwise (value ignored). */
509
1074
  const regionFill = (r: {
1075
+ iso?: string;
510
1076
  value?: number;
1077
+ color?: string;
511
1078
  tags: Readonly<Record<string, string>>;
512
1079
  }): string => {
1080
+ const direct = directFill(r.color);
1081
+ if (direct) return direct; // §24B.4 direct color wins over colorize (F4)
513
1082
  if (activeIsScore) {
514
1083
  return r.value !== undefined ? fillForValue(r.value) : neutralFill;
515
1084
  }
1085
+ // Under colorize (activeGroup === null ⇒ not score) the terminal neutralFill
1086
+ // is replaced by the region's political pastel; the value-path above is dead
1087
+ // here (activeIsScore is false). Data/tag maps are untouched.
1088
+ if (colorizeActive) return (r.iso && colorByIso.get(r.iso)) ?? neutralFill;
516
1089
  return tagFill(r.tags, activeGroup) ?? neutralFill;
517
1090
  };
518
1091
 
519
1092
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
520
1093
 
521
- // -- Projection + fit (AR2, refined) --
522
- // For world projections we fit to the resolver's (padded, never-degenerate)
523
- // extent box — fitting to raw drawn points would collapse to a zero-size
524
- // target (single/coincident POIs → Infinity scale → NaN). albers-usa fits to
525
- // its own conus features (below).
526
- //
527
- // The extent outline sampled as a MultiPoint — NOT a Polygon. A hand-built
528
- // lat/lon rectangle's spherical winding is ambiguous to d3-geo, which can
529
- // read it as the whole-globe complement (→ tiny content framed on a world
530
- // map). Points have no interior/winding ambiguity, so fitExtent frames the
531
- // box exactly. We sample ALONG the four edges (not just the corners) because
532
- // a curved projection (natural-earth) bulges between corners — its widest x
533
- // is at the equator and its lowest/highest y at the central meridian, neither
534
- // of which is a corner. Fitting only corners under-frames the curve, so the
535
- // continents at the frame's top/bottom/sides spill off and clip (S. Africa,
536
- // Argentina, N. Russia). Equirectangular/mercator are linear, so the extra
537
- // samples are redundant-but-harmless there.
538
- const extentOutline = (): GeoFeature => {
539
- const [[w, s], [e, n]] = resolved.extent;
540
- const N = 16;
541
- const coords: Array<[number, number]> = [];
542
- for (let i = 0; i <= N; i++) {
543
- const t = i / N;
544
- const lon = w + (e - w) * t;
545
- const lat = s + (n - s) * t;
546
- coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
547
- }
548
- return {
549
- type: 'Feature',
550
- properties: {},
551
- geometry: { type: 'MultiPoint', coordinates: coords },
552
- };
553
- };
554
-
555
- let fitFeatures: GeoFeature[];
556
- if (resolved.projection === 'albers-usa' && usLayer) {
557
- // Frame the contiguous 48 + DC (insets/territories excluded). The conic
558
- // projects everything else — Canada, Mexico — around it, bleeding off the
559
- // canvas edges so there's no empty water band and no hard clip line.
560
- fitFeatures = [...usLayer.entries()]
561
- .filter(([iso]) => !US_NON_CONUS.has(iso))
562
- .map(([, f]) => f);
563
- } else {
564
- fitFeatures = [extentOutline()];
565
- }
566
- const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
567
-
568
- const projection = projectionFor(resolved.projection);
569
- // mercator / natural-earth: rotate to the extent's center longitude BEFORE
570
- // fitting (rotate changes the bounds fitExtent measures). albers-usa is a
571
- // US-only composite with NO .rotate -- never call it (AR2).
572
- if (resolved.projection !== 'albers-usa') {
573
- let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
574
- if (centerLon > 180) centerLon -= 360;
575
- projection.rotate([-centerLon, 0]);
576
- }
1094
+ // -- Fit the projection to the canvas (size-dependent; the projection + fit
1095
+ // target themselves came from buildMapProjection above). --
577
1096
  // Reserve top padding for the title/subtitle banner ONLY when there are POIs,
578
1097
  // so their markers/labels don't project up under the title (which renders in
579
1098
  // the foreground). A POI-less choropleth needs no reserve — the land fills to
@@ -603,15 +1122,19 @@ export function layoutMap(
603
1122
  // a full canvas), but POI radii + label font sizes are applied in the renderer
604
1123
  // (NOT here), so markers stay round and text stays un-squashed. Regional views
605
1124
  // keep contain-fit: no distortion, neighbour land not cropped.
606
- const fitGB = geoBounds(fitTarget as never) as [
607
- [number, number],
608
- [number, number],
609
- ];
610
- const fitIsGlobal =
611
- fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
1125
+ //
1126
+ // `preferContain` (set by the export-dimension helper when it clamps/floors the
1127
+ // canvas away from the content aspect) suppresses the stretch even for a global
1128
+ // extent: the canvas was intentionally sized off-aspect, so stretching would
1129
+ // re-introduce the very distortion the content-aware sizing removes. We then
1130
+ // contain-fit (letterbox over water) instead. The in-app preview pane never
1131
+ // sets preferContain, so it keeps stretch-filling the pane. (`fitIsGlobal` comes
1132
+ // from buildMapProjection.)
612
1133
  let path: GeoPath;
613
1134
  let project: (lon: number, lat: number) => [number, number] | null;
614
- if (fitIsGlobal) {
1135
+ // Captured for the geo-query (null unless this is a global stretch fit).
1136
+ let stretchParams: MapLayoutStretch | null = null;
1137
+ if (fitIsGlobal && !opts.preferContain) {
615
1138
  const cb = geoPath(projection).bounds(fitTarget as never);
616
1139
  const bx0 = cb[0][0];
617
1140
  const by0 = cb[0][1];
@@ -621,6 +1144,7 @@ export function layoutMap(
621
1144
  const oy = fitBox[0][1];
622
1145
  const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
623
1146
  const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
1147
+ stretchParams = { sx, sy, ox, oy, bx0, by0 };
624
1148
  const stretch = (x: number, y: number): [number, number] => [
625
1149
  ox + (x - bx0) * sx,
626
1150
  oy + (y - by0) * sy,
@@ -681,7 +1205,16 @@ export function layoutMap(
681
1205
  name: string;
682
1206
  lineNumber: number;
683
1207
  }[] = [];
684
- if (resolved.projection === 'albers-usa' && usLayer) {
1208
+ // AK/HI insets are inferred (no directive): draw a state's inset only when the
1209
+ // map references it (a valued/tagged state or a POI inside it). An all-US map
1210
+ // that names neither frames the contiguous states alone (§24B.2).
1211
+ const akRef =
1212
+ resolved.regions.some((r) => r.iso === 'US-AK') ||
1213
+ resolved.pois.some((p) => inAlaska(p.lon, p.lat));
1214
+ const hiRef =
1215
+ resolved.regions.some((r) => r.iso === 'US-HI') ||
1216
+ resolved.pois.some((p) => inHawaii(p.lon, p.lat));
1217
+ if (resolved.projection === 'albers-usa' && usLayer && (akRef || hiRef)) {
685
1218
  const PAD = 8;
686
1219
  const GAP = 12; // px the top edge rides below the coast
687
1220
  const yB = height - FIT_PAD; // lowest a box may reach (canvas bottom pad)
@@ -719,53 +1252,20 @@ export function layoutMap(
719
1252
  }
720
1253
  return y;
721
1254
  };
722
- // Top edge for a box over [x0, xr]: a straight line PARALLEL to the local
723
- // coast (least-squares over the land samples), pushed down so it clears every
724
- // land sample by GAP. Parallel → uniform, maximal clearance for how close it
725
- // sits, tilting the way the coast tilts. Open-ocean samples are skipped, so a
726
- // box reaching past the coast isn't dragged down by water. Falls back to a
727
- // flat line just under the lowest land if the fit is underdetermined.
728
- const coastTop = (x0: number, xr: number): ((x: number) => number) => {
1255
+ // Lowest the coast reaches across [x0, xr], or -Infinity over open ocean.
1256
+ const coastFloor = (x0: number, xr: number): number => {
729
1257
  const n = 24;
730
- const pts: Array<[number, number]> = [];
731
1258
  let maxY = -Infinity;
732
1259
  for (let i = 0; i <= n; i++) {
733
- const x = x0 + ((xr - x0) * i) / n;
734
- const y = at(x);
735
- if (y > -Infinity) {
736
- pts.push([x, y]);
737
- if (y > maxY) maxY = y;
738
- }
739
- }
740
- if (pts.length === 0) return () => yB - height * 0.42; // all ocean
741
- let m = 0;
742
- if (pts.length >= 2) {
743
- let sx = 0,
744
- sy = 0,
745
- sxx = 0,
746
- sxy = 0;
747
- for (const [x, y] of pts) {
748
- sx += x;
749
- sy += y;
750
- sxx += x * x;
751
- sxy += x * y;
752
- }
753
- const den = pts.length * sxx - sx * sx;
754
- if (den !== 0) m = (pts.length * sxy - sx * sy) / den;
1260
+ const y = at(x0 + ((xr - x0) * i) / n);
1261
+ if (y > maxY) maxY = y;
755
1262
  }
756
- // Cap the tilt so a steep coast (e.g. California's) doesn't turn the box
757
- // into a tall triangle — keep it a compact, gently-angled quad.
758
- m = Math.max(-0.35, Math.min(0.35, m));
759
- let c = -Infinity; // raise the line until it clears every land sample + GAP
760
- for (const [x, y] of pts) {
761
- const need = y - m * x + GAP;
762
- if (need > c) c = need;
763
- }
764
- return (x: number) => m * x + c;
1263
+ return maxY;
765
1264
  };
766
1265
  // A snug floating box that just contains the state, tucked up under the coast
767
- // with a coast-parallel slanted top. `iwReq` is the requested inner width.
768
- // Returns the box's right edge so the next inset can sit beside it.
1266
+ // with a flat top sitting GAP below the lowest the coast reaches over its
1267
+ // span. `iwReq` is the requested inner width. Returns the box's right edge so
1268
+ // the next inset can sit beside it.
769
1269
  const placeInset = (
770
1270
  iso: string,
771
1271
  proj: GeoProjection,
@@ -779,23 +1279,19 @@ export function layoutMap(
779
1279
  const iw = Math.min(iwReq, width - FIT_PAD - x0 - 2 * PAD);
780
1280
  if (iw < 24) return boxX; // canvas truly too narrow for another inset
781
1281
  const xr = x0 + iw + 2 * PAD;
782
- const top = coastTop(x0, xr);
783
- const yL = top(x0);
784
- const yR = top(xr);
1282
+ const floor = coastFloor(x0, xr);
1283
+ const topGuess = floor > -Infinity ? floor + GAP : yB - height * 0.42;
785
1284
  // Learn the state's height at this width, then size the box to just hold it.
786
1285
  proj.fitWidth(iw, f as never);
787
1286
  const bb = geoPath(proj).bounds(f as never);
788
1287
  const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
789
- // State sits below the lower top corner. If the coast runs so low the state
790
- // wouldn't fit above yB, raise the top (the corner stays over ocean) — the
791
- // box must never collapse and vanish.
1288
+ // Flat top sits just under the coast. If the coast runs so low the state
1289
+ // wouldn't fit above yB, raise the top (it stays over ocean) — the box must
1290
+ // never collapse and vanish.
792
1291
  const needH = sh + 2 * PAD;
793
- let topFit = Math.max(yL, yR);
1292
+ let topFit = topGuess;
794
1293
  const bottom = Math.min(topFit + needH, yB);
795
1294
  if (bottom - topFit < needH) topFit = bottom - needH;
796
- const lift = topFit - Math.max(yL, yR); // keep the slanted top straight
797
- const topL = yL + lift;
798
- const topR = yR + lift;
799
1295
  proj.fitExtent(
800
1296
  [
801
1297
  [x0 + PAD, topFit + PAD],
@@ -805,8 +1301,28 @@ export function layoutMap(
805
1301
  );
806
1302
  const d = geoPath(proj)(f as never) ?? '';
807
1303
  if (!d) return xr;
1304
+ // Neighbour land projected with this same fitted projection, clipped to the
1305
+ // box. Alaska's only land neighbour is Canada; drawing it behind AK turns
1306
+ // the eastern AK/Canada border into a land boundary so it grows no coastline
1307
+ // rings (and fills the box's upper-right corner with recessive context).
1308
+ let contextLand: { d: string; fill: string } | undefined;
1309
+ if (iso === 'US-AK') {
1310
+ const can = worldLayer.get('CA');
1311
+ const cd = can ? (geoPath(proj)(can as never) ?? '') : '';
1312
+ if (cd)
1313
+ contextLand = {
1314
+ d: cd,
1315
+ fill: colorizeActive
1316
+ ? (colorByIso.get('CA') ?? foreignFill)
1317
+ : foreignFill,
1318
+ };
1319
+ }
808
1320
  const r = regionById.get(iso);
809
- let fill = neutralFill;
1321
+ // Inset land reads the SAME colorByIso as the main frame → AK/HI identical
1322
+ // to their main-frame colour (extent-independent; AC10/AC11).
1323
+ let fill = colorizeActive
1324
+ ? (colorByIso.get(iso) ?? neutralFill)
1325
+ : neutralFill;
810
1326
  let lineNumber = -1;
811
1327
  if (r?.layer === 'us-state') {
812
1328
  fill = regionFill(r);
@@ -814,21 +1330,25 @@ export function layoutMap(
814
1330
  }
815
1331
  insets.push({
816
1332
  x: x0,
817
- y: Math.min(topL, topR),
1333
+ y: topFit,
818
1334
  w: xr - x0,
819
- h: bottom - Math.min(topL, topR),
1335
+ h: bottom - topFit,
820
1336
  points: [
821
- [x0, topL],
822
- [xr, topR],
1337
+ [x0, topFit],
1338
+ [xr, topFit],
823
1339
  [xr, bottom],
824
1340
  [x0, bottom],
825
1341
  ],
1342
+ // The FITTED inset projection (just fit to this box) — captured so the
1343
+ // geo-query can invert pixels inside the frame back to AK/HI coords.
1344
+ projection: proj,
1345
+ ...(contextLand && { contextLand }),
826
1346
  });
827
1347
  insetRegions.push({
828
1348
  id: iso,
829
1349
  d,
830
1350
  fill,
831
- stroke: regionStroke,
1351
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
832
1352
  lineNumber,
833
1353
  layer: 'us-state',
834
1354
  ...(r?.value !== undefined && { value: r.value }),
@@ -842,13 +1362,17 @@ export function layoutMap(
842
1362
  return xr;
843
1363
  };
844
1364
  // AK is the larger state; HI a small island group tucked to its right.
845
- const akRight = placeInset(
846
- 'US-AK',
847
- alaskaProjection(),
848
- FIT_PAD,
849
- width * 0.15
850
- );
851
- placeInset('US-HI', hawaiiProjection(), akRight + 24, width * 0.1);
1365
+ // Each draws only when referenced; HI slides left to FIT_PAD if AK is absent.
1366
+ let akRight = FIT_PAD;
1367
+ if (akRef)
1368
+ akRight = placeInset('US-AK', alaskaProjection(), FIT_PAD, width * 0.15);
1369
+ if (hiRef)
1370
+ placeInset(
1371
+ 'US-HI',
1372
+ hawaiiProjection(),
1373
+ akRef ? akRight + 24 : FIT_PAD,
1374
+ width * 0.1
1375
+ );
852
1376
  }
853
1377
 
854
1378
  // -- Basemap culling --
@@ -904,15 +1428,31 @@ export function layoutMap(
904
1428
  loMax = -Infinity,
905
1429
  rawMin = Infinity,
906
1430
  rawMax = -Infinity;
1431
+ const lons: number[] = [];
907
1432
  for (const [rawLon] of ring) {
908
1433
  const lon = normLon(rawLon);
1434
+ lons.push(lon);
909
1435
  if (lon < loMin) loMin = lon;
910
1436
  if (lon > loMax) loMax = lon;
911
1437
  if (rawLon < rawMin) rawMin = rawLon;
912
1438
  if (rawLon > rawMax) rawMax = rawLon;
913
1439
  }
914
- if (loMax - loMin > 270) return false; // circumpolar/polar-wrap garbage
915
- if (rawMax - rawMin > 180 && loMax - loMin < 90) return false; // seam sliver
1440
+ // OCCUPIED longitude arc (complement of the largest empty gap), NOT the raw
1441
+ // min→max span: a landmass crossing the antimeridian (Russia: points near
1442
+ // −180° AND +180° via Chukotka) has a ~360° min→max span but only a ~171°
1443
+ // occupied arc. The naive `loMax−loMin > 270` test mistook Russia for
1444
+ // circumpolar garbage and dropped all of mainland Russia from regional views.
1445
+ // A truly pole-wrapping ring occupies ~360° (no large gap) and is still
1446
+ // dropped. (#russia-cull)
1447
+ lons.sort((a, b) => a - b);
1448
+ let maxGap = 0;
1449
+ for (let i = 1; i < lons.length; i++)
1450
+ maxGap = Math.max(maxGap, lons[i]! - lons[i - 1]!);
1451
+ if (lons.length > 1)
1452
+ maxGap = Math.max(maxGap, lons[0]! + 360 - lons[lons.length - 1]!);
1453
+ const occupiedArc = 360 - maxGap;
1454
+ if (occupiedArc > 270) return false; // circumpolar/polar-wrap garbage
1455
+ if (rawMax - rawMin > 180 && occupiedArc < 90) return false; // seam sliver
916
1456
  // Projected-bbox ∩ canvas. project() honours the active projection (and
917
1457
  // ignores clipExtent, so positions are true), so this is exactly "does any
918
1458
  // of this ring fall on the canvas".
@@ -961,7 +1501,7 @@ export function layoutMap(
961
1501
 
962
1502
  // View-INDEPENDENT frame-fill guard. An antimeridian-crossing ring whose true
963
1503
  // occupied longitude arc is small (e.g. Fiji: islands at 177°E and 178°W, a
964
- // ~5° arc straddling the seam) projects under equirectangular to two slivers
1504
+ // ~5° arc straddling the seam) projects under a world projection to two slivers
965
1505
  // at opposite frame edges; the fill between them inverts to paint the WHOLE
966
1506
  // ocean as land. `cullFeatureToView` drops these in a regional view, but a
967
1507
  // global/world view skips culling — so they must be dropped here regardless.
@@ -1023,7 +1563,14 @@ export function layoutMap(
1023
1563
  for (const [iso, f] of layerFeatures) {
1024
1564
  // Alaska/Hawaii are drawn as insets under albers-usa — skip them in the
1025
1565
  // main conus layer (the conic would otherwise place them far off-frame).
1026
- if (layerKind === 'us-state' && usContext && INSET_STATES.has(iso))
1566
+ // Only albers-usa relocates them to insets; on a world/regional projection
1567
+ // they have no inset and must draw in place from the us-states layer.
1568
+ if (
1569
+ layerKind === 'us-state' &&
1570
+ usContext &&
1571
+ resolved.projection === 'albers-usa' &&
1572
+ INSET_STATES.has(iso)
1573
+ )
1027
1574
  continue;
1028
1575
  // In a US view the us-states layer paints the whole country — drop the
1029
1576
  // redundant US country polygon underneath it (it only adds a coarser base
@@ -1046,7 +1593,12 @@ export function layoutMap(
1046
1593
  const isThisLayer = r?.layer === layerKind;
1047
1594
  // Non-US neighbour land in a US view is gray context, not yellow land.
1048
1595
  const isForeign = layerKind === 'country' && usContext && iso !== 'US';
1049
- let fill = isForeign ? foreignFill : neutralFill;
1596
+ // Under colorize EVERY drawn political region — referenced, context, or
1597
+ // neighbour — gets its pastel, so the whole visible set reads as one map
1598
+ // (foreignFill/neutralFill bypassed; F9). The referenced branch below routes
1599
+ // through regionFill (direct color still wins).
1600
+ const baseFill = isForeign ? foreignFill : neutralFill;
1601
+ let fill = colorizeActive ? (colorByIso.get(iso) ?? baseFill) : baseFill;
1050
1602
  let label: string | undefined;
1051
1603
  let lineNumber = -1;
1052
1604
  let layer: MapLayoutRegion['layer'] = 'base';
@@ -1056,12 +1608,17 @@ export function layoutMap(
1056
1608
  lineNumber = r.lineNumber;
1057
1609
  layer = layerKind;
1058
1610
  label = r.name;
1611
+ } else {
1612
+ // Base/context land (not authored): still carry the display name so the
1613
+ // app can show it on hover. Names live on the geo feature's properties
1614
+ // (the same source the resolver/inset/context-label layers read).
1615
+ label = (f.properties as { name?: string } | null)?.name;
1059
1616
  }
1060
1617
  regions.push({
1061
1618
  id: iso,
1062
1619
  d,
1063
1620
  fill,
1064
- stroke: regionStroke,
1621
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
1065
1622
  lineNumber,
1066
1623
  layer,
1067
1624
  ...(label !== undefined && { label }),
@@ -1098,17 +1655,179 @@ export function layoutMap(
1098
1655
  id: 'lake',
1099
1656
  d,
1100
1657
  fill: water,
1101
- stroke: 'none',
1658
+ stroke: lakeStroke,
1102
1659
  lineNumber: -1,
1103
1660
  layer: 'base',
1104
1661
  });
1105
1662
  }
1106
1663
  }
1107
1664
 
1108
- // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land,
1109
- // the SAME blue as the ocean/lakes so a river reads as continuous with the
1110
- // water it drains into. Open paths: stroked, no fill; under POIs/edges/labels.
1111
- const riverColor = water;
1665
+ // -- Background-fill hit-testing (for connector-label contrast) --
1666
+ // A freight/edge label floats over whatever region the route crosses a dark
1667
+ // scored country, pale land, or open water. To pick a legible text shade (and
1668
+ // skip the ghost halo when not needed) we need the fill UNDER the label point.
1669
+ // Test in SCREEN space against the already-drawn region paths: that sidesteps
1670
+ // every projection wrinkle (global stretch, antimeridian, AK/HI insets) because
1671
+ // the geometry is already projected (see module-level `parsePathRings`).
1672
+ // Even-odd ray cast across ALL of a feature's rings at once, so polygons with
1673
+ // holes (a ring inside a ring) toggle correctly.
1674
+ const pointInRings = (
1675
+ px: number,
1676
+ py: number,
1677
+ rings: Array<Array<[number, number]>>
1678
+ ): boolean => {
1679
+ let inside = false;
1680
+ for (const ring of rings) {
1681
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1682
+ const [xi, yi] = ring[i]!;
1683
+ const [xj, yj] = ring[j]!;
1684
+ if (
1685
+ yi > py !== yj > py &&
1686
+ px < ((xj - xi) * (py - yi)) / (yj - yi) + xi
1687
+ )
1688
+ inside = !inside;
1689
+ }
1690
+ }
1691
+ return inside;
1692
+ };
1693
+ // Precompute hit targets once (regions are drawn in array order, so the LAST
1694
+ // containing one is topmost). Insets paint over neighbour land in their own box.
1695
+ const fillHitTargets = [...regions, ...insetRegions].map((r) => ({
1696
+ fill: r.fill,
1697
+ rings: parsePathRings(r.d),
1698
+ }));
1699
+ const fillAt = (x: number, y: number): string => {
1700
+ let hit = water; // open ocean / canvas backdrop when over no land
1701
+ for (const t of fillHitTargets)
1702
+ if (pointInRings(x, y, t.rings)) hit = t.fill;
1703
+ return hit;
1704
+ };
1705
+ // Contrast-pick text colour for a label sitting ON `fill` (shared by region
1706
+ // labels and connector labels): the genuinely higher-contrast of the palette's
1707
+ // light/dark on-fill text, with a halo only when that contrast is marginal
1708
+ // (mid-tone fills), so clear fills carry no ghost.
1709
+ const labelOnFill = (
1710
+ fill: string
1711
+ ): { color: string; halo: boolean; haloColor: string } => {
1712
+ const color =
1713
+ contrastRatio(fill, palette.textOnFillDark) >=
1714
+ contrastRatio(fill, palette.textOnFillLight)
1715
+ ? palette.textOnFillDark
1716
+ : palette.textOnFillLight;
1717
+ const haloColor =
1718
+ color === palette.textOnFillLight
1719
+ ? palette.textOnFillDark
1720
+ : palette.textOnFillLight;
1721
+ return {
1722
+ color,
1723
+ halo: contrastRatio(fill, color) < REGION_LABEL_HALO_RATIO,
1724
+ haloColor,
1725
+ };
1726
+ };
1727
+
1728
+ // Relief (notable mountain ranges) — horizontal hachure lines clipped to each
1729
+ // range, drawn over the base land and under rivers/POIs/data fills. Opt-in via
1730
+ // the `relief` flag; needs the optional `mountainRanges` asset. Each surviving
1731
+ // range is projected to a polygon path; the renderer unions them into a clip
1732
+ // and rules screen-spaced horizontal lines through it — a distinct texture
1733
+ // that reads as "mountains here" without elevation data. Ranges below a min
1734
+ // projected area/dimension are dropped (no slivers). Data-region suppression
1735
+ // (ADR-2) is handled at the RENDER clip — relief is clipped to land MINUS the
1736
+ // data-coloured regions, so a range that crosses a valued state still shows on
1737
+ // the un-valued land around it (a bbox drop here would nuke the whole range).
1738
+ // Relief is ALWAYS on; only the `no-relief` directive turns it off. It renders
1739
+ // on data maps too (the renderer lays the hachure ATOP the choropleth/tag fills
1740
+ // and the hatch tone flips to stay visible over muted land), at every zoom, and
1741
+ // at every width. The only remaining filters are per-range quality guards below
1742
+ // (sub-min-area / sub-min-dimension slivers are skipped so a range never draws
1743
+ // as a sub-pixel smudge) — those drop individual ranges, never the feature.
1744
+ const reliefAllowed = resolved.directives.noRelief !== true;
1745
+ const relief: MapLayoutRelief[] = [];
1746
+ let reliefHatch: MapLayoutReliefHatch | null = null;
1747
+ if (reliefAllowed && data.mountainRanges) {
1748
+ for (const [, f] of decodeLayer(data.mountainRanges)) {
1749
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1750
+ if (!viewF) continue;
1751
+ const area = path.area(viewF as never);
1752
+ if (!Number.isFinite(area) || area < RELIEF_MIN_AREA) continue;
1753
+ const box = path.bounds(viewF as never) as [
1754
+ [number, number],
1755
+ [number, number],
1756
+ ];
1757
+ if (
1758
+ box[1][0] - box[0][0] < RELIEF_MIN_DIM ||
1759
+ box[1][1] - box[0][1] < RELIEF_MIN_DIM
1760
+ )
1761
+ continue;
1762
+ const d = path(viewF as never) ?? '';
1763
+ if (!d) continue;
1764
+ relief.push({ d });
1765
+ }
1766
+ if (relief.length) {
1767
+ // Prefer DARK hachure (blend land toward the dark tone — bg on dark
1768
+ // themes, text on light). But on a muted/data map the un-valued land is
1769
+ // already near-black, so darkness can't show: if the dark tone barely
1770
+ // differs from the land, flip to the light tone so the lines stay visible.
1771
+ const darkTone = isDark ? palette.bg : palette.text;
1772
+ const lightTone = isDark ? palette.text : palette.bg;
1773
+ // Relief is ONE global clipped layer with a single colour (renderer.ts) —
1774
+ // a per-region hatch tone over varied pastels would need a renderer
1775
+ // rearchitecture (out of scope; v2). Under colorize the political tints are
1776
+ // pale washes sitting near the surface/bg, so referencing that base picks a
1777
+ // fixed mid-contrast hatch tone that reads over all of them (AC15/G2).
1778
+ const reliefLandRef = colorizeActive
1779
+ ? isDark
1780
+ ? palette.surface
1781
+ : palette.bg
1782
+ : neutralFill;
1783
+ const landLum = relativeLuminance(reliefLandRef);
1784
+ const tone =
1785
+ Math.abs(landLum - relativeLuminance(darkTone)) > 0.04
1786
+ ? darkTone
1787
+ : lightTone;
1788
+ reliefHatch = {
1789
+ color: mix(tone, reliefLandRef, RELIEF_HATCH_STRENGTH),
1790
+ spacing: RELIEF_HATCH_SPACING,
1791
+ width: RELIEF_HATCH_WIDTH,
1792
+ };
1793
+ }
1794
+ }
1795
+
1796
+ // Coastline water-lines style (opt-in `coastline`, §24B.2). No geometry/asset:
1797
+ // the renderer derives the lines from the already-drawn region paths and masks
1798
+ // them to the water side. We only resolve the proportional screen-space style
1799
+ // here (fractions of min(w,h) → absolute px, so the offshore distance stays a
1800
+ // constant fraction of the canvas at any export size — ADR-3). Differs from
1801
+ // relief: a touch more contrast than `lakeStroke` so the offshore lines read as
1802
+ // distinct from the coast stroke (R10/F14).
1803
+ let coastlineStyle: MapLayoutCoastlineStyle | null = null;
1804
+ if (resolved.directives.noCoastline !== true) {
1805
+ const minDim = Math.min(width, height);
1806
+ coastlineStyle = {
1807
+ color: mix(regionStroke, water, COASTLINE_STROKE_MIX),
1808
+ // N equal-width rings: distance steps outward by COASTLINE_STEP; opacity
1809
+ // fades linearly from NEAR (innermost) to FAR (outermost).
1810
+ lines: Array.from({ length: COASTLINE_RING_COUNT }, (_, k) => ({
1811
+ d: (COASTLINE_D0 + k * COASTLINE_STEP) * minDim,
1812
+ thickness: COASTLINE_THICKNESS * minDim,
1813
+ opacity:
1814
+ COASTLINE_OPACITY_NEAR +
1815
+ ((COASTLINE_OPACITY_FAR - COASTLINE_OPACITY_NEAR) * k) /
1816
+ (COASTLINE_RING_COUNT - 1),
1817
+ })),
1818
+ minExtent:
1819
+ (isGlobalView ? COASTLINE_MIN_EXTENT_GLOBAL : COASTLINE_MIN_EXTENT) *
1820
+ minDim,
1821
+ };
1822
+ }
1823
+
1824
+ // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land.
1825
+ // A deliberate water-blue — a more saturated cousin of the body-of-water
1826
+ // `water` tone (which is a very faded blue, §mapBackgroundColor) so the line
1827
+ // reads clearly as a water course, not a dark gap where it crosses a border.
1828
+ // Mixing toward the border tone instead reads as a broken boundary in
1829
+ // muted/data mode. Open paths: stroked, no fill; under POIs/edges/labels.
1830
+ const riverColor = mix(palette.colors.blue, water, 32);
1112
1831
  const rivers: MapLayoutRiver[] = [];
1113
1832
  if (data.rivers) {
1114
1833
  for (const [, f] of decodeLayer(data.rivers)) {
@@ -1138,8 +1857,12 @@ export function layoutMap(
1138
1857
  return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
1139
1858
  };
1140
1859
 
1141
- // POI tag color: FIRST declared group for which the POI has a value (AR4).
1860
+ // POI fill precedence (§24B.5): a direct §1.5 trailing color wins, then the
1861
+ // FIRST declared tag group for which the POI has a value (AR4), then orange.
1142
1862
  const poiFill = (p: ResolvedPoi): { fill: string; stroke: string } => {
1863
+ const directHex = p.color ? resolveColor(p.color, palette) : null;
1864
+ if (directHex)
1865
+ return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
1143
1866
  for (const group of resolved.tagGroups) {
1144
1867
  const val = p.tags[group.name.toLowerCase()];
1145
1868
  if (!val) continue;
@@ -1183,38 +1906,136 @@ export function layoutMap(
1183
1906
  const xy = project(p.lon, p.lat);
1184
1907
  if (xy) projected.push({ p, xy });
1185
1908
  }
1186
- const coloGroups = new Map<string, Proj[]>();
1909
+ const placePoi = (
1910
+ e: Proj,
1911
+ cx: number,
1912
+ cy: number,
1913
+ clusterId?: string
1914
+ ): void => {
1915
+ const { fill, stroke } = poiFill(e.p);
1916
+ poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
1917
+ const num = routeNumberById.get(e.p.id);
1918
+ pois.push({
1919
+ id: e.p.id,
1920
+ cx,
1921
+ cy,
1922
+ r: radiusFor(e.p),
1923
+ fill,
1924
+ stroke,
1925
+ lineNumber: e.p.lineNumber,
1926
+ implicit: !!e.p.implicit,
1927
+ isOrigin: originIds.has(e.p.id),
1928
+ ...(num !== undefined && { routeNumber: num }),
1929
+ ...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
1930
+ ...(clusterId !== undefined && { clusterId }),
1931
+ });
1932
+ };
1933
+
1934
+ // -- Coincident-POI spiderfy (stacks). Two dots "stack" when they visibly
1935
+ // overlap (centre distance < combined radii × STACK_OVERLAP). A ≥2-member stack
1936
+ // is laid out EXPANDED — members fanned onto a ring (golden-angle spiral past
1937
+ // STACK_RING_MAX), legs back to the centroid — which is the source of truth for
1938
+ // export + the no-JS default; the app collapses it to one ringed `+N` badge at
1939
+ // rest and expands on click. POIs that anchor an edge or route leg are EXCLUDED
1940
+ // (kept at true position; collapsing a connector endpoint is out of v1 scope).
1941
+ // Distinct-but-dense clusters never overlap at the combined-radii threshold, so
1942
+ // they keep today's true-position + leader/column behavior.
1943
+ const clusters: MapLayoutCluster[] = [];
1944
+ const connected = new Set<string>();
1945
+ for (const e of resolved.edges) {
1946
+ connected.add(e.fromId);
1947
+ connected.add(e.toId);
1948
+ }
1949
+ for (const rt of resolved.routes) {
1950
+ rt.stopIds.forEach((id) => connected.add(id));
1951
+ }
1952
+ const radiusOf = (e: Proj): number => radiusFor(e.p);
1953
+ // Connected endpoints: always true position.
1187
1954
  for (const e of projected) {
1188
- const key = `${Math.round(e.xy[0] / COLO_EPS)},${Math.round(e.xy[1] / COLO_EPS)}`;
1189
- const arr = coloGroups.get(key);
1190
- if (arr) arr.push(e);
1191
- else coloGroups.set(key, [e]);
1955
+ if (connected.has(e.p.id)) placePoi(e, e.xy[0], e.xy[1]);
1956
+ }
1957
+ // Distance-based transitive grouping among stackable POIs (first-matching-group
1958
+ // heuristic, matching the GROUP_R label-column grouping below).
1959
+ const groups: Proj[][] = [];
1960
+ for (const e of projected) {
1961
+ if (connected.has(e.p.id)) continue;
1962
+ const r = radiusOf(e);
1963
+ const near = groups.find((g) =>
1964
+ g.some(
1965
+ (q) =>
1966
+ Math.hypot(q.xy[0] - e.xy[0], q.xy[1] - e.xy[1]) <
1967
+ (r + radiusOf(q)) * STACK_OVERLAP
1968
+ )
1969
+ );
1970
+ if (near) near.push(e);
1971
+ else groups.push([e]);
1192
1972
  }
1193
- for (const group of coloGroups.values()) {
1194
- group.forEach((e, i) => {
1195
- let cx = e.xy[0];
1196
- let cy = e.xy[1];
1197
- if (group.length > 1) {
1198
- const ang = i * GOLDEN_ANGLE;
1199
- cx += Math.cos(ang) * COLO_R;
1200
- cy += Math.sin(ang) * COLO_R;
1973
+ for (const g of groups) {
1974
+ if (g.length === 1) {
1975
+ placePoi(g[0]!, g[0]!.xy[0], g[0]!.xy[1]);
1976
+ continue;
1977
+ }
1978
+ const clusterId = g[0]!.p.id; // line-number-ordered first member → stable
1979
+ const cx0 = g.reduce((s, e) => s + e.xy[0], 0) / g.length;
1980
+ const cy0 = g.reduce((s, e) => s + e.xy[1], 0) / g.length;
1981
+ const maxR = Math.max(...g.map(radiusOf));
1982
+ // Ring radius so adjacent expanded dots clear each other by STACK_RING_GAP.
1983
+ const sep = 2 * maxR + STACK_RING_GAP;
1984
+ const ringR = Math.max(
1985
+ COLO_R,
1986
+ sep / (2 * Math.sin(Math.PI / Math.max(g.length, 2)))
1987
+ );
1988
+ const positions = g.map((e, i) => {
1989
+ if (g.length <= STACK_RING_MAX) {
1990
+ const ang = -Math.PI / 2 + (i * 2 * Math.PI) / g.length;
1991
+ return {
1992
+ e,
1993
+ mx: cx0 + Math.cos(ang) * ringR,
1994
+ my: cy0 + Math.sin(ang) * ringR,
1995
+ };
1201
1996
  }
1202
- const { fill, stroke } = poiFill(e.p);
1203
- poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
1204
- const num = routeNumberById.get(e.p.id);
1205
- pois.push({
1206
- id: e.p.id,
1207
- cx,
1208
- cy,
1209
- r: radiusFor(e.p),
1210
- fill,
1211
- stroke,
1212
- lineNumber: e.p.lineNumber,
1213
- implicit: !!e.p.implicit,
1214
- isOrigin: originIds.has(e.p.id),
1215
- ...(num !== undefined && { routeNumber: num }),
1216
- ...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
1217
- });
1997
+ const ang = i * GOLDEN_ANGLE;
1998
+ const rr = ringR * Math.sqrt((i + 1) / g.length);
1999
+ return { e, mx: cx0 + Math.cos(ang) * rr, my: cy0 + Math.sin(ang) * rr };
2000
+ });
2001
+ // Off-canvas guard: translate the whole fan (centroid + members together) so
2002
+ // every DOT stays on-canvas. A pure shift preserves the spider geometry AND
2003
+ // keeps the collapsed badge honest — the ring is small, so the badge barely
2004
+ // moves off the true centroid. (Labels are NOT folded into this box: a label
2005
+ // is wide enough that shifting to fit it would drag the badge far from the
2006
+ // real location — a geographic lie. Instead the label block below flips each
2007
+ // member's radial label to the side that fits and clamps it to the frame.)
2008
+ let minX = cx0 - maxR;
2009
+ let maxX = cx0 + maxR;
2010
+ let minY = cy0 - maxR;
2011
+ let maxY = cy0 + maxR;
2012
+ for (const { mx, my, e } of positions) {
2013
+ const r = radiusOf(e);
2014
+ minX = Math.min(minX, mx - r);
2015
+ maxX = Math.max(maxX, mx + r);
2016
+ minY = Math.min(minY, my - r);
2017
+ maxY = Math.max(maxY, my + r);
2018
+ }
2019
+ let dx = 0;
2020
+ let dy = 0;
2021
+ if (minX + dx < 2) dx = 2 - minX;
2022
+ if (maxX + dx > width - 2) dx = width - 2 - maxX;
2023
+ if (minY + dy < 2) dy = 2 - minY;
2024
+ if (maxY + dy > height - 2) dy = height - 2 - maxY;
2025
+ const legsOut: Array<{ x2: number; y2: number; color: string }> = [];
2026
+ for (const { e, mx, my } of positions) {
2027
+ const fx = mx + dx;
2028
+ const fy = my + dy;
2029
+ placePoi(e, fx, fy, clusterId);
2030
+ legsOut.push({ x2: fx, y2: fy, color: poiFill(e.p).fill });
2031
+ }
2032
+ clusters.push({
2033
+ id: clusterId,
2034
+ cx: cx0 + dx,
2035
+ cy: cy0 + dy,
2036
+ count: g.length,
2037
+ hitR: ringR + maxR + 6,
2038
+ legs: legsOut,
1218
2039
  });
1219
2040
  }
1220
2041
 
@@ -1283,16 +2104,29 @@ export function layoutMap(
1283
2104
  if (!a || !b) continue;
1284
2105
  const mx = (a.cx + b.cx) / 2;
1285
2106
  const my = (a.cy + b.cy) / 2;
2107
+ const bow = {
2108
+ curved: leg.style === 'arc',
2109
+ offset: 0,
2110
+ labelX: mx,
2111
+ labelY: my - 4,
2112
+ };
2113
+ const routeLabelStyle =
2114
+ leg.label !== undefined
2115
+ ? labelOnFill(fillAt(bow.labelX, bow.labelY))
2116
+ : undefined;
1286
2117
  legs.push({
1287
- d: legPath(a, b, leg.style === 'arc', 0),
2118
+ d: legPath(a, b, bow.curved, bow.offset),
1288
2119
  width: routeWidthFor(Number(leg.value)),
1289
2120
  color: mix(palette.text, palette.bg, 72),
1290
2121
  arrow: true,
1291
2122
  lineNumber: leg.lineNumber,
1292
2123
  ...(leg.label !== undefined && {
1293
2124
  label: leg.label,
1294
- labelX: mx,
1295
- labelY: my - 4,
2125
+ labelX: bow.labelX,
2126
+ labelY: bow.labelY,
2127
+ labelColor: routeLabelStyle!.color,
2128
+ labelHalo: routeLabelStyle!.halo,
2129
+ labelHaloColor: routeLabelStyle!.haloColor,
1296
2130
  }),
1297
2131
  });
1298
2132
  }
@@ -1324,20 +2158,32 @@ export function layoutMap(
1324
2158
  const a = poiScreen.get(e.fromId);
1325
2159
  const b = poiScreen.get(e.toId);
1326
2160
  if (!a || !b) return;
1327
- const curved = e.style === 'arc' || n > 1;
1328
- const offset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
2161
+ const fanOffset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
1329
2162
  const mx = (a.cx + b.cx) / 2;
1330
2163
  const my = (a.cy + b.cy) / 2;
2164
+ const bow = {
2165
+ curved: e.style === 'arc' || n > 1,
2166
+ offset: fanOffset,
2167
+ labelX: mx,
2168
+ labelY: my - 4,
2169
+ };
2170
+ const edgeLabelStyle =
2171
+ e.label !== undefined
2172
+ ? labelOnFill(fillAt(bow.labelX, bow.labelY))
2173
+ : undefined;
1331
2174
  legs.push({
1332
- d: legPath(a, b, curved, offset),
2175
+ d: legPath(a, b, bow.curved, bow.offset),
1333
2176
  width: widthFor(e),
1334
2177
  color: mix(palette.text, palette.bg, 66),
1335
2178
  arrow: e.directed,
1336
2179
  lineNumber: e.lineNumber,
1337
2180
  ...(e.label !== undefined && {
1338
2181
  label: e.label,
1339
- labelX: mx,
1340
- labelY: my - 4,
2182
+ labelX: bow.labelX,
2183
+ labelY: bow.labelY,
2184
+ labelColor: edgeLabelStyle!.color,
2185
+ labelHalo: edgeLabelStyle!.halo,
2186
+ labelHaloColor: edgeLabelStyle!.haloColor,
1341
2187
  }),
1342
2188
  });
1343
2189
  });
@@ -1389,14 +2235,17 @@ export function layoutMap(
1389
2235
  obstacles.some((o) => rectsOverlap(rect, o)) ||
1390
2236
  legSegments.some((s) => segmentRectOverlap(s[0], s[1], s[2], s[3], rect));
1391
2237
 
1392
- // Region labels (default off). Rendered as haloed text — NO pill — so the
1393
- // choropleth fill (which encodes the data) stays fully visible. The text
1394
- // colour is contrast-picked against each region's OWN fill (dark on
1395
- // pastel/unscored land, light on saturated fills) with an opposite-lightness
1396
- // paint-order halo, the same convention POI labels use. A label is shown only
1397
- // when its (padded) footprint fits inside the region, so small states like the
1398
- // NE cluster auto-hide rather than overlap / spill onto the ocean.
1399
- const regionLabelMode = resolved.directives.regionLabels ?? 'off';
2238
+ // Region labels (default ON; `no-region-labels` suppresses). Rendered as plain
2239
+ // text — NO pill, NO halo — so the choropleth fill (which encodes the data)
2240
+ // stays fully visible. The text colour is contrast-picked against each region's
2241
+ // OWN fill. Auto-fit cascade full → abbrev → hide (decision A): the full name
2242
+ // shows when it fits its footprint; otherwise a US-state 2-letter abbreviation
2243
+ // is tried (countries have no abbrev source, so they degrade full → hide); if
2244
+ // nothing fits the label is hidden rather than overlapping / spilling onto the
2245
+ // ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
2246
+ // FIRST for US states.
2247
+ const showRegionLabels = resolved.directives.noRegionLabels !== true;
2248
+ const isCompact = width < COMPACT_WIDTH_PX;
1400
2249
  const LABEL_PADX = 6;
1401
2250
  const LABEL_PADY = 3;
1402
2251
  const labelW = (text: string): number =>
@@ -1409,22 +2258,28 @@ export function layoutMap(
1409
2258
  fill: string,
1410
2259
  lineNumber: number
1411
2260
  ): void => {
1412
- const color = contrastText(
1413
- fill,
1414
- palette.textOnFillLight,
1415
- palette.textOnFillDark
2261
+ // Colour is contrast-picked against the region's own fill (see labelOnFill).
2262
+ // The halo, though, is gated by CONTAINMENT — not fill tone. A label that
2263
+ // sits wholly within its own fill reads against a single known colour, so
2264
+ // the picked shade suffices and a halo is just noise (big states: TX, CA).
2265
+ // But when the glyphs spill past the region — a narrow shape (FL peninsula),
2266
+ // a tiny state (MD), or a small inset island (HI) — the text crosses onto
2267
+ // ocean / neighbour land whose tone we can't predict, so it needs the halo
2268
+ // to stay legible. Sample the label's screen footprint against the drawn
2269
+ // fills: if any extreme lands on a fill other than the region's own, the
2270
+ // label overflows and earns a halo.
2271
+ const { color, haloColor } = labelOnFill(fill);
2272
+ const halfW = measureLegendText(text, FONT) / 2;
2273
+ const overflows = [y - FONT * 0.55, y - FONT * 0.1].some(
2274
+ (sy) => fillAt(x - halfW, sy) !== fill || fillAt(x + halfW, sy) !== fill
1416
2275
  );
1417
- const haloColor =
1418
- color === palette.textOnFillLight
1419
- ? palette.textOnFillDark
1420
- : palette.textOnFillLight;
1421
2276
  labels.push({
1422
2277
  x,
1423
2278
  y,
1424
2279
  text,
1425
2280
  anchor: 'middle',
1426
2281
  color,
1427
- halo: true,
2282
+ halo: overflows,
1428
2283
  haloColor,
1429
2284
  lineNumber,
1430
2285
  });
@@ -1435,29 +2290,92 @@ export function layoutMap(
1435
2290
  const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
1436
2291
  US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
1437
2292
  };
1438
- if (regionLabelMode === 'full' || regionLabelMode === 'abbrev') {
1439
- for (const r of regions) {
1440
- if (r.layer === 'base' || r.label === undefined) continue;
1441
- const f =
1442
- r.layer === 'us-state' ? usLayer?.get(r.id) : worldLayer.get(r.id);
1443
- if (!f) continue;
1444
- const [[x0, y0], [x1, y1]] = path.bounds(f as never);
1445
- const text =
1446
- regionLabelMode === 'abbrev' ? r.id.replace(/^US-/, '') : r.label;
1447
- // Hide if the label wouldn't fit inside the region's footprint.
1448
- if (labelW(text) > x1 - x0 || labelH > y1 - y0) continue;
1449
- const anchor =
1450
- r.layer !== 'us-state' ? WORLD_LABEL_ANCHORS[r.id] : undefined;
1451
- const c = anchor
1452
- ? project(anchor[0], anchor[1])
1453
- : path.centroid(f as never);
1454
- if (!c || !Number.isFinite(c[0])) continue;
2293
+ // A region label's screen footprint, middle-anchored on its centroid, used to
2294
+ // keep two region labels from overlapping (a small gap adds breathing room).
2295
+ const REGION_LABEL_GAP = 2;
2296
+ const regionLabelRect = (cx: number, cy: number, text: string): LabelRect => {
2297
+ const w = measureLegendText(text, FONT) + 2 * REGION_LABEL_GAP;
2298
+ return { x: cx - w / 2, y: cy - FONT / 2, w, h: FONT };
2299
+ };
2300
+ if (showRegionLabels) {
2301
+ // Gather the placeable region labels, then commit them largest-footprint
2302
+ // first. Two adjacent regions can sit too close to both carry a label at the
2303
+ // current scale (Spain + Portugal on a whole-world view collapse to ~32px
2304
+ // apart). Rather than overlap, the bigger region keeps its label and the
2305
+ // smaller one yields; zoom in and the footprints separate, no collision
2306
+ // fires, and both labels show. Order is by projected box AREA (visual claim)
2307
+ // so the result is scale-driven, not source-order-driven.
2308
+ // POI-only region framing: the region(s) CONTAINING the POIs are labelled
2309
+ // prominently even though they carry no data (layer 'base'). Neighbour land
2310
+ // gets the muted context-label treatment further down.
2311
+ const frameContainers = new Set(resolved.poiFrameContainers);
2312
+ const entries = regions
2313
+ .map((r) => {
2314
+ const isContainer = frameContainers.has(r.id);
2315
+ if ((r.layer === 'base' && !isContainer) || r.label === undefined)
2316
+ return null;
2317
+ // A container state carries layer 'base', so key off the id shape too.
2318
+ const isUsState = r.layer === 'us-state' || r.id.startsWith('US-');
2319
+ const f = isUsState ? usLayer?.get(r.id) : worldLayer.get(r.id);
2320
+ if (!f) return null;
2321
+ const [[x0, y0], [x1, y1]] = path.bounds(f as never);
2322
+ const boxW = x1 - x0;
2323
+ const boxH = y1 - y0;
2324
+ // full → abbrev → hide. Abbrev exists only for US states; at the compact
2325
+ // breakpoint abbrev is tried first.
2326
+ const abbrev = isUsState ? r.id.replace(/^US-/, '') : undefined;
2327
+ const candidates =
2328
+ abbrev !== undefined
2329
+ ? isCompact
2330
+ ? [abbrev, r.label]
2331
+ : [r.label, abbrev]
2332
+ : [r.label];
2333
+ const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : undefined;
2334
+ const c = anchor
2335
+ ? project(anchor[0], anchor[1])
2336
+ : path.centroid(f as never);
2337
+ if (!c || !Number.isFinite(c[0])) return null;
2338
+ return { r, c, boxW, boxH, area: boxW * boxH, candidates };
2339
+ })
2340
+ .filter((e): e is NonNullable<typeof e> => e !== null)
2341
+ .sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
2342
+ const placedRegionRects: LabelRect[] = [];
2343
+ // POI markers are obstacles for region labels: a region whose centroid sits on
2344
+ // a POI (e.g. Colorado's centroid under the "Core POP" dot in Denver) must NOT
2345
+ // stamp its name there — the POI's own label owns that spot, and two names by
2346
+ // one dot is ambiguous. The dot rect is padded to also keep the region name
2347
+ // clear of the POI's adjacent label. Region labels with no nearby POI (a
2348
+ // container whose POIs cluster in one corner, or an empty neighbour state) are
2349
+ // unaffected. POI markers are positioned above; their labels place further
2350
+ // down, so dot-proximity is the signal available here.
2351
+ const POI_LABEL_PAD = 14; // px — rough room for the POI's own hugging label
2352
+ const poiObstacles: LabelRect[] = pois.map((p) => ({
2353
+ x: p.cx - p.r - POI_LABEL_PAD,
2354
+ y: p.cy - p.r - POI_LABEL_PAD,
2355
+ w: 2 * (p.r + POI_LABEL_PAD),
2356
+ h: 2 * (p.r + POI_LABEL_PAD),
2357
+ }));
2358
+ for (const { r, c, boxW, boxH, candidates } of entries) {
2359
+ // The first candidate that BOTH fits its own footprint AND clears every
2360
+ // already-placed region label AND every POI marker wins; none qualifies →
2361
+ // the label is hidden (a country has no abbrev, so it degrades full → hide;
2362
+ // a US state may fall back to its 2-letter code before hiding).
2363
+ const text = candidates.find((t) => {
2364
+ if (labelW(t) > boxW || labelH > boxH) return false;
2365
+ const rect = regionLabelRect(c[0], c[1], t);
2366
+ return (
2367
+ !placedRegionRects.some((p) => rectsOverlap(rect, p)) &&
2368
+ !poiObstacles.some((o) => rectsOverlap(rect, o))
2369
+ );
2370
+ });
2371
+ if (text === undefined) continue;
2372
+ placedRegionRects.push(regionLabelRect(c[0], c[1], text));
1455
2373
  pushRegionLabel(c[0], c[1], text, r.fill, r.lineNumber);
1456
2374
  }
1457
- // AK/HI labels live in their insets (own projection centroids).
2375
+ // AK/HI labels live in their insets (own projection centroids). Insets are
2376
+ // tiny, so prefer the abbreviation when the canvas is compact.
1458
2377
  for (const seed of insetLabelSeeds) {
1459
- const text =
1460
- regionLabelMode === 'abbrev' ? seed.iso.replace(/^US-/, '') : seed.name;
2378
+ const text = isCompact ? seed.iso.replace(/^US-/, '') : seed.name;
1461
2379
  const src = regionById.get(seed.iso);
1462
2380
  pushRegionLabel(
1463
2381
  seed.x,
@@ -1469,12 +2387,13 @@ export function layoutMap(
1469
2387
  }
1470
2388
  }
1471
2389
 
1472
- // POI labels (default auto; off -> none; all -> every POI).
1473
- const poiLabelMode = resolved.directives.poiLabels ?? 'auto';
1474
- if (poiLabelMode !== 'off') {
1475
- const ordered = [...pois].sort(
1476
- (a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1)
1477
- );
2390
+ // POI labels: default-on, collision-managed auto. `no-poi-labels` suppresses.
2391
+ if (resolved.directives.noPoiLabels !== true) {
2392
+ // Cluster (stack) members are laid out + labelled by the spiderfy block; keep
2393
+ // them out of the singleton/proximity-column placement here.
2394
+ const ordered = [...pois]
2395
+ .filter((p) => p.clusterId === undefined)
2396
+ .sort((a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1));
1478
2397
  const poiById = new Map(resolved.pois.map((q) => [q.id, q]));
1479
2398
  const labelText = (p: MapLayoutPoi): string => {
1480
2399
  const src = poiById.get(p.id);
@@ -1491,6 +2410,18 @@ export function layoutMap(
1491
2410
  // from the east AND west — Boulder in the route-cluster gauntlet).
1492
2411
  type Side = 'right' | 'left' | 'above' | 'below';
1493
2412
  const GAP = 3;
2413
+ // Coincident-stack members (spiderfy) are labelled via a tidy leader-lined
2414
+ // COLUMN beside the cluster (see the cluster-column pass after the column
2415
+ // helpers below) — NOT radial inline labels, which pile up unreadably when
2416
+ // the ring is tight. Group the members here; the pass commits them once the
2417
+ // column machinery is defined.
2418
+ const clusterMembersById = new Map<string, MapLayoutPoi[]>();
2419
+ for (const p of pois) {
2420
+ if (p.clusterId === undefined) continue;
2421
+ const arr = clusterMembersById.get(p.clusterId);
2422
+ if (arr) arr.push(p);
2423
+ else clusterMembersById.set(p.clusterId, [p]);
2424
+ }
1494
2425
  const inlineRect = (p: MapLayoutPoi, w: number, side: Side): LabelRect => {
1495
2426
  switch (side) {
1496
2427
  case 'right':
@@ -1530,7 +2461,7 @@ export function layoutMap(
1530
2461
  text,
1531
2462
  anchor,
1532
2463
  color: palette.text,
1533
- halo: true,
2464
+ halo: false,
1534
2465
  haloColor: palette.bg,
1535
2466
  poiId: p.id,
1536
2467
  lineNumber: p.lineNumber,
@@ -1567,39 +2498,89 @@ export function layoutMap(
1567
2498
  const ROW_GAP = 3;
1568
2499
  const step = poiLabH + ROW_GAP;
1569
2500
  const COL_GAP = 16;
1570
- const placeColumn = (group: MapLayoutPoi[]): void => {
1571
- const items = group
2501
+ type ColItem = { p: MapLayoutPoi; text: string; w: number };
2502
+ const makeItems = (group: MapLayoutPoi[]): ColItem[] =>
2503
+ group
1572
2504
  .map((p) => ({ p, ...labelInfo(p) }))
1573
2505
  .sort((a, b) => a.p.cy - b.p.cy || (a.text < b.text ? -1 : 1));
2506
+ // The column's per-row layout (side, colX, clamped startY, each row's rect).
2507
+ // Shared by the clean-check gate and the commit path so they never diverge.
2508
+ const columnRows = (
2509
+ items: ColItem[],
2510
+ side: 'right' | 'left'
2511
+ ): Array<{ o: ColItem; colX: number; rowCy: number; rect: LabelRect }> => {
1574
2512
  const left = Math.min(...items.map((o) => o.p.cx - o.p.r));
1575
2513
  const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2514
+ const maxW = Math.max(...items.map((o) => o.w));
1576
2515
  const cyMid =
1577
2516
  (Math.min(...items.map((o) => o.p.cy)) +
1578
2517
  Math.max(...items.map((o) => o.p.cy))) /
1579
2518
  2;
1580
- const maxW = Math.max(...items.map((o) => o.w));
1581
- // Prefer the right of the cluster; fall to the left if it runs off-canvas.
1582
- const side: 'right' | 'left' =
1583
- right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
1584
- const colX = side === 'right' ? right + COL_GAP : left - COL_GAP;
2519
+ // Column anchor x, clamped so the widest row's text box stays on-canvas.
2520
+ // (No-op for the clean callers; matters when a fallback column e.g. a
2521
+ // second spider cluster boxed out of its preferred side would otherwise
2522
+ // run a label off the frame.) A right column anchors its text start at
2523
+ // colX; a left column anchors its end at colX (text spans colX-maxW..colX).
2524
+ const colX =
2525
+ side === 'right'
2526
+ ? Math.min(right + COL_GAP, width - 2 - maxW)
2527
+ : Math.max(left - COL_GAP, 2 + maxW);
1585
2528
  const totalH = items.length * step;
1586
2529
  let startY = cyMid - totalH / 2;
1587
2530
  startY = Math.max(2, Math.min(startY, height - totalH - 2));
1588
- items.forEach((o, i) => {
2531
+ return items.map((o, i) => {
1589
2532
  const rowCy = startY + i * step + step / 2;
1590
- obstacles.push({
1591
- x: side === 'right' ? colX : colX - o.w,
1592
- y: rowCy - poiLabH / 2,
1593
- w: o.w,
1594
- h: poiLabH,
1595
- });
2533
+ return {
2534
+ o,
2535
+ colX,
2536
+ rowCy,
2537
+ rect: {
2538
+ x: side === 'right' ? colX : colX - o.w,
2539
+ y: rowCy - poiLabH / 2,
2540
+ w: o.w,
2541
+ h: poiLabH,
2542
+ },
2543
+ };
2544
+ });
2545
+ };
2546
+ // Pure gate (NO mutation): every row on-canvas AND collision-free, at the
2547
+ // post-startY-clamp positions the commit path will use.
2548
+ const wouldColumnBeClean = (
2549
+ items: ColItem[],
2550
+ side: 'right' | 'left'
2551
+ ): boolean =>
2552
+ columnRows(items, side).every(
2553
+ ({ rect }) =>
2554
+ rect.x >= 0 &&
2555
+ rect.x + rect.w <= width &&
2556
+ rect.y >= 0 &&
2557
+ rect.y + rect.h <= height &&
2558
+ !collides(rect)
2559
+ );
2560
+ // Today's side heuristic — used only for ungated singleton callouts.
2561
+ const defaultColumnSide = (items: ColItem[]): 'right' | 'left' => {
2562
+ const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2563
+ const maxW = Math.max(...items.map((o) => o.w));
2564
+ return right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
2565
+ };
2566
+ // Commit a visible callout column on the GIVEN side (no re-deriving the
2567
+ // side — the caller has already validated it). When `clusterId` is set the
2568
+ // rows are tagged `clusterMember` so the app shows/hides them (text AND
2569
+ // leader) with the collapsed-stack badge.
2570
+ const commitColumn = (
2571
+ items: ColItem[],
2572
+ side: 'right' | 'left',
2573
+ clusterId?: string
2574
+ ): void => {
2575
+ for (const { o, colX, rowCy, rect } of columnRows(items, side)) {
2576
+ obstacles.push(rect);
1596
2577
  labels.push({
1597
2578
  x: colX,
1598
2579
  y: rowCy + FONT / 3,
1599
2580
  text: o.text,
1600
2581
  anchor: side === 'right' ? 'start' : 'end',
1601
2582
  color: palette.text,
1602
- halo: true,
2583
+ halo: false,
1603
2584
  haloColor: palette.bg,
1604
2585
  leader: {
1605
2586
  x1: o.p.cx,
@@ -1610,26 +2591,207 @@ export function layoutMap(
1610
2591
  leaderColor: o.p.fill,
1611
2592
  poiId: o.p.id,
1612
2593
  lineNumber: o.p.lineNumber,
2594
+ ...(clusterId !== undefined && { clusterMember: clusterId }),
1613
2595
  });
2596
+ }
2597
+ };
2598
+ // Hover-only fallback: a single inline label beside the dot (no leader),
2599
+ // emitted invisible and revealed on hover. NOT added to obstacles (it's
2600
+ // invisible and must not displace visible labels). y is clamped on-canvas
2601
+ // because we skip the inlineFits four-edge check (F8).
2602
+ const pushHidden = (p: MapLayoutPoi): void => {
2603
+ const { text, w } = labelInfo(p);
2604
+ let x = p.cx + p.r + GAP;
2605
+ let anchor: 'start' | 'end' = 'start';
2606
+ if (x + w > width) {
2607
+ x = p.cx - p.r - GAP - w;
2608
+ anchor = 'end';
2609
+ }
2610
+ const y = Math.max(0, Math.min(p.cy - poiLabH / 2, height - poiLabH));
2611
+ labels.push({
2612
+ x: anchor === 'start' ? x : x + w,
2613
+ y: y + poiLabH / 2 + FONT / 3,
2614
+ text,
2615
+ anchor,
2616
+ color: palette.text,
2617
+ halo: false,
2618
+ haloColor: palette.bg,
2619
+ poiId: p.id,
2620
+ hidden: true,
2621
+ lineNumber: p.lineNumber,
1614
2622
  });
1615
2623
  };
1616
2624
 
2625
+ // Spiderfy clusters: label every member in a tidy leader-lined column beside
2626
+ // the ring (collision-free by row spacing), tagged `clusterMember` so the app
2627
+ // toggles them with the badge. Committed FIRST so the singleton/group passes
2628
+ // route around the column. The dots/legs/badge keep their true location — only
2629
+ // the labels move out to the column, which the startY-clamp keeps on-canvas.
2630
+ for (const [clusterId, members] of clusterMembersById) {
2631
+ if (members.length === 0) continue;
2632
+ const items = makeItems(members);
2633
+ // Prefer a clean (on-canvas, collision-free) side; fall back to the side
2634
+ // with more horizontal room. Cluster labels are always placed (never
2635
+ // hover-only) — readability beats the odd overlap with a faint basemap.
2636
+ const side = wouldColumnBeClean(items, 'right')
2637
+ ? 'right'
2638
+ : wouldColumnBeClean(items, 'left')
2639
+ ? 'left'
2640
+ : defaultColumnSide(items);
2641
+ commitColumn(items, side, clusterId);
2642
+ }
2643
+
2644
+ // Per-render extent threshold (resolution-relative; Decision #1, F9).
2645
+ const maxExtent = MAX_CLUSTER_EXTENT_FACTOR * Math.min(width, height);
2646
+ // Pass 1: place singletons (unchanged); for ≥2 clusters resolve gate
2647
+ // (a)/(a2) — sprawl/overflow → hover-only. These hides push NOTHING to
2648
+ // obstacles, so doing them first decouples the gate-(b) clean-checks below
2649
+ // from commit order (F4). Surviving clusters defer to pass 2.
2650
+ const clusterPending: ColItem[][] = [];
1617
2651
  for (const g of groups) {
1618
- // Singleton that fits inline → inline; everything else → callout column
1619
- // (the whole cluster, or a lone POI boxed in by legs/edges).
2652
+ const items = makeItems(g);
1620
2653
  if (g.length === 1) {
1621
- const p = g[0]!;
1622
- const { text, w } = labelInfo(p);
2654
+ // Singleton: inline if it fits, else today's single-row callout —
2655
+ // always placed, never hover-only (Decision #2 / AC9).
2656
+ const { p, text, w } = items[0]!;
1623
2657
  const side = (['right', 'left', 'above', 'below'] as const).find((s) =>
1624
2658
  inlineFits(p, w, s)
1625
2659
  );
1626
- if (side) {
1627
- pushInline(p, text, w, side);
1628
- continue;
2660
+ if (side) pushInline(p, text, w, side);
2661
+ else commitColumn(items, defaultColumnSide(items));
2662
+ continue;
2663
+ }
2664
+ // Gate (a): bounding-box diagonal over marker extents — a sprawling chain
2665
+ // whose column leaders would fan across the map. Gate (a2): too many rows
2666
+ // to stack readably. Either → whole cluster hover-only.
2667
+ const left = Math.min(...items.map((o) => o.p.cx - o.p.r));
2668
+ const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2669
+ const minCy = Math.min(...items.map((o) => o.p.cy));
2670
+ const maxCy = Math.max(...items.map((o) => o.p.cy));
2671
+ const diag = Math.hypot(right - left, maxCy - minCy);
2672
+ if (diag > maxExtent || items.length > MAX_COLUMN_ROWS) {
2673
+ items.forEach((o) => pushHidden(o.p));
2674
+ } else {
2675
+ clusterPending.push(items);
2676
+ }
2677
+ }
2678
+ // Pass 2: gate (b) — a surviving cluster shows its column only if a right-
2679
+ // or left-side column places fully clean; commit on that exact side, else
2680
+ // the whole cluster goes hover-only.
2681
+ for (const items of clusterPending) {
2682
+ const side = (['right', 'left'] as const).find((s) =>
2683
+ wouldColumnBeClean(items, s)
2684
+ );
2685
+ if (side) commitColumn(items, side);
2686
+ else items.forEach((o) => pushHidden(o.p));
2687
+ }
2688
+ }
2689
+
2690
+ // -- Context labels (orientation backdrop, §24B). Placed DEAD LAST so they
2691
+ // only fill leftover space and never displace a data/region/POI label
2692
+ // (Decision 7). Off by default; gated on the directive so it costs nothing. --
2693
+ if (resolved.directives.noContextLabels !== true) {
2694
+ // F1: context labels must dodge EVERY committed label (region/inset/POI/
2695
+ // route), not just the POI-label rects already in `obstacles`. Region
2696
+ // labels go into `labels` but never into `obstacles`, so add a footprint
2697
+ // rect for each committed label here (POI rects are already present —
2698
+ // duplicates are harmless). This upholds Decision 7's "never displace a
2699
+ // data/region/POI label" against the live `collides` closure.
2700
+ for (const l of labels) {
2701
+ // Hidden (hover-only) labels are invisible — context labels must not
2702
+ // reserve space around them (Decision #7).
2703
+ if (l.hidden) continue;
2704
+ const w = labelW(l.text);
2705
+ const x =
2706
+ l.anchor === 'start' ? l.x : l.anchor === 'end' ? l.x - w : l.x - w / 2;
2707
+ obstacles.push({ x, y: l.y - labelH / 2, w, h: labelH });
2708
+ }
2709
+ // Under albers-usa the AK/HI inset frames occupy the lower-left; a context
2710
+ // label must never sit on one (the original Decision 8 hazard). Feed each
2711
+ // inset box into the collision set so the placement dodges them.
2712
+ for (const box of insets)
2713
+ obstacles.push({ x: box.x, y: box.y, w: box.w, h: box.h });
2714
+ // Unreferenced notable countries: the FULL decoded country set (worldLayer
2715
+ // holds every country in the chosen tier — crisp `.set()` upgrades never
2716
+ // delete), minus any already labelled by region-labels (Decision 1). Geo
2717
+ // work (bbox/anchor) stays here; area-rank + fit + collision live in the
2718
+ // pure module so the strict density invariants (AC7) are unit-testable.
2719
+ const countryCandidates: CountryCandidate[] = [];
2720
+ for (const f of worldLayer.values()) {
2721
+ const iso = typeof f.id === 'string' ? f.id : String(f.id ?? '');
2722
+ if (!iso || regionById.has(iso)) continue;
2723
+ // F3: skip a country whose SUBDIVISIONS are the referenced data (e.g. a
2724
+ // US-states choropleth on a world projection) — the states ARE the data,
2725
+ // so don't slap a redundant "United States" context label over them.
2726
+ let hasReferencedSub = false;
2727
+ for (const k of regionById.keys())
2728
+ if (k.startsWith(iso + '-')) {
2729
+ hasReferencedSub = true;
2730
+ break;
1629
2731
  }
2732
+ if (hasReferencedSub) continue;
2733
+ const b = path.bounds(f as never) as [[number, number], [number, number]];
2734
+ const [x0, y0] = b[0];
2735
+ const [x1, y1] = b[1];
2736
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue;
2737
+ const anchorLngLat = WORLD_LABEL_ANCHORS[iso];
2738
+ const a = anchorLngLat
2739
+ ? project(anchorLngLat[0], anchorLngLat[1])
2740
+ : (path.centroid(f as never) as [number, number]);
2741
+ countryCandidates.push({
2742
+ name: (f.properties as { name?: string } | undefined)?.name ?? iso,
2743
+ bbox: [x0, y0, x1, y1],
2744
+ anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
2745
+ });
2746
+ }
2747
+ // Neighbour US states (POI-only region framing): when the frame is snapped to
2748
+ // a US-state container (e.g. California), label the surrounding in-frame states
2749
+ // (Nevada, Oregon, Arizona…) in the muted context style for orientation. They
2750
+ // are NOT containers and NOT data, so the region-label pass skipped them.
2751
+ // Anchor each to the centroid of its VISIBLE (culled) geometry so a state only
2752
+ // partly in frame (a sliver of Oregon at the top) still anchors on-screen
2753
+ // rather than at an off-frame centroid that `insideViewport` would reject.
2754
+ const framedStateContainers = (resolved.poiFrameContainers ?? []).some(
2755
+ (id) => id.startsWith('US-')
2756
+ );
2757
+ if (usLayer && framedStateContainers) {
2758
+ const containerSet = new Set(resolved.poiFrameContainers);
2759
+ for (const [iso, f] of usLayer) {
2760
+ if (containerSet.has(iso) || regionById.has(iso)) continue;
2761
+ const viewF = cullFeatureToView(f);
2762
+ if (!viewF) continue; // not in frame
2763
+ const b = path.bounds(viewF as never) as [
2764
+ [number, number],
2765
+ [number, number],
2766
+ ];
2767
+ const [x0, y0] = b[0];
2768
+ const [x1, y1] = b[1];
2769
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue;
2770
+ const a = path.centroid(viewF as never) as [number, number];
2771
+ countryCandidates.push({
2772
+ name: (f.properties as { name?: string } | undefined)?.name ?? iso,
2773
+ bbox: [x0, y0, x1, y1],
2774
+ anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
2775
+ });
1630
2776
  }
1631
- placeColumn(g);
1632
2777
  }
2778
+ const contextLabels = placeContextLabels({
2779
+ projection: resolved.projection,
2780
+ dLonSpan,
2781
+ dLatSpan,
2782
+ width,
2783
+ height,
2784
+ waterBodies: data.waterBodies,
2785
+ countries: countryCandidates,
2786
+ palette,
2787
+ project,
2788
+ collides,
2789
+ // Water labels must stay over open water — `fillAt` returns the ocean
2790
+ // backdrop colour off-land and a region fill on-land (lakes/states count
2791
+ // as land here, which is the safe side for an ocean name).
2792
+ overLand: (x, y) => fillAt(x, y) !== water,
2793
+ });
2794
+ labels.push(...contextLabels);
1633
2795
  }
1634
2796
 
1635
2797
  // -- Legend model (AR1: categorical via renderer's renderLegendD3) --
@@ -1671,11 +2833,18 @@ export function layoutMap(
1671
2833
  ...(resolved.caption !== undefined && { caption: resolved.caption }),
1672
2834
  regions,
1673
2835
  rivers,
2836
+ relief,
2837
+ reliefHatch,
2838
+ coastlineStyle,
1674
2839
  legs,
1675
2840
  pois,
2841
+ clusters,
1676
2842
  labels,
1677
2843
  legend,
1678
2844
  insets,
1679
2845
  insetRegions,
2846
+ projection,
2847
+ stretch: stretchParams,
2848
+ diagnostics: [],
1680
2849
  };
1681
2850
  }