@diagrammo/dgmo 0.19.0 → 0.20.1

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 (48) hide show
  1. package/dist/advanced.cjs +948 -321
  2. package/dist/advanced.d.cts +148 -54
  3. package/dist/advanced.d.ts +148 -54
  4. package/dist/advanced.js +949 -321
  5. package/dist/auto.cjs +930 -317
  6. package/dist/auto.js +117 -117
  7. package/dist/auto.mjs +934 -318
  8. package/dist/cli.cjs +160 -160
  9. package/dist/index.cjs +929 -316
  10. package/dist/index.js +933 -317
  11. package/dist/internal.cjs +948 -321
  12. package/dist/internal.d.cts +148 -54
  13. package/dist/internal.d.ts +148 -54
  14. package/dist/internal.js +949 -321
  15. package/dist/map-data/PROVENANCE.json +1 -1
  16. package/dist/map-data/lakes.json +1 -0
  17. package/dist/map-data/na-lakes.json +1 -0
  18. package/dist/map-data/na-land.json +1 -0
  19. package/dist/map-data/rivers.json +1 -0
  20. package/docs/language-reference.md +12 -7
  21. package/gallery/fixtures/map-region-scope.dgmo +15 -0
  22. package/package.json +4 -4
  23. package/src/advanced.ts +7 -6
  24. package/src/c4/parser.ts +6 -6
  25. package/src/completion.ts +6 -2
  26. package/src/echarts.ts +1 -1
  27. package/src/infra/parser.ts +10 -10
  28. package/src/journey-map/parser.ts +1 -1
  29. package/src/label-layout.ts +36 -0
  30. package/src/map/data/PROVENANCE.json +1 -1
  31. package/src/map/data/README.md +2 -0
  32. package/src/map/data/lakes.json +1 -0
  33. package/src/map/data/na-lakes.json +1 -0
  34. package/src/map/data/na-land.json +1 -0
  35. package/src/map/data/rivers.json +1 -0
  36. package/src/map/layout.ts +1022 -205
  37. package/src/map/load-data.ts +73 -17
  38. package/src/map/parser.ts +22 -13
  39. package/src/map/renderer.ts +200 -219
  40. package/src/map/resolved-types.ts +18 -1
  41. package/src/map/resolver.ts +79 -7
  42. package/src/map/types.ts +4 -0
  43. package/src/mindmap/parser.ts +1 -1
  44. package/src/sitemap/parser.ts +1 -1
  45. package/src/utils/legend-d3.ts +42 -0
  46. package/src/utils/legend-layout.ts +83 -3
  47. package/src/utils/legend-svg.ts +1 -8
  48. package/src/utils/legend-types.ts +44 -1
package/src/map/layout.ts CHANGED
@@ -8,19 +8,25 @@
8
8
  import {
9
9
  geoPath,
10
10
  geoNaturalEarth1,
11
- geoAlbersUsa,
11
+ geoEquirectangular,
12
+ geoConicEqualArea,
12
13
  geoMercator,
14
+ geoBounds,
15
+ geoTransform,
13
16
  type GeoProjection,
14
17
  type GeoPath,
15
18
  } from 'd3-geo';
16
19
  import { feature } from 'topojson-client';
17
- import { mix, shapeFill, contrastText } from '../palettes/color-utils';
20
+ import { mix, contrastText } from '../palettes/color-utils';
18
21
  import type { PaletteColors } from '../palettes/types';
19
- import { resolveActiveTagGroup } from '../utils/tag-groups';
20
- import type { TagGroup } from '../utils/tag-groups';
21
- import { rectsOverlap, rectCircleOverlap } from '../label-layout';
22
+ import {
23
+ rectsOverlap,
24
+ rectCircleOverlap,
25
+ segmentRectOverlap,
26
+ } from '../label-layout';
22
27
  import type { LabelRect, PointCircle } from '../label-layout';
23
28
  import { measureLegendText } from '../utils/legend-constants';
29
+ import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
24
30
  import type { BoundaryTopology } from './data/types';
25
31
  import type {
26
32
  MapData,
@@ -51,26 +57,27 @@ const R_MAX = 22;
51
57
  const W_MIN = 1.25; // edge stroke width
52
58
  const W_MAX = 8;
53
59
  const FONT = 11; // on-map label font px
54
- const LEADER_STEP = 14; // px ring radius step for label escalation
55
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;
65
+ // Categorical (tag) region fill: a flat, fairly saturated tint of the tag
66
+ // colour so a tagged region reads as its CATEGORY against the tinted land base
67
+ // — the generic 25% shape tint washes out and lets the olive land dominate.
68
+ const TAG_TINT_LIGHT = 60;
69
+ const TAG_TINT_DARK = 68;
70
+ const WATER_TINT = 55; // % palette-blue of bg for the ocean / backdrop
71
+ const RIVER_WIDTH = 1.3; // px stroke width for river lines
72
+ // % palette-gray of bg for non-US neighbour land. Higher on dark so it reads as
73
+ // a clear gray rather than sinking into the dark background.
74
+ const FOREIGN_TINT_LIGHT = 30;
75
+ const FOREIGN_TINT_DARK = 62;
56
76
  const COLO_R = 9; // spiderfy radius
57
77
  const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
58
78
  const FAN_STEP = 16; // px perpendicular offset between parallel edges
59
- const TINY_REGION_AREA = 600; // px^2: region label auto-hidden below this
60
79
  const ARC_CURVE_FRAC = 0.18; // default arc bow as a fraction of leg length
61
80
 
62
- // Fixed candidate ring for label escalation (E, S, W, N, then diagonals).
63
- const RING_DIRS: ReadonlyArray<[number, number]> = [
64
- [1, 0],
65
- [0, 1],
66
- [-1, 0],
67
- [0, -1],
68
- [1, 1],
69
- [-1, 1],
70
- [-1, -1],
71
- [1, -1],
72
- ];
73
-
74
81
  export interface MapLayoutRegion {
75
82
  readonly id: string; // iso
76
83
  readonly d: string; // SVG path data
@@ -79,6 +86,26 @@ export interface MapLayoutRegion {
79
86
  readonly label?: string;
80
87
  readonly lineNumber: number;
81
88
  readonly layer: 'base' | 'country' | 'us-state';
89
+ /** The region's score (if any) — emitted as `data-score` so the app can
90
+ * highlight by gradient-scrub proximity. */
91
+ readonly score?: number;
92
+ /** The region's tag values keyed by group (lowercased) — emitted as
93
+ * `data-tag-<group>` so the app can highlight on legend-entry hover. */
94
+ readonly tags?: Readonly<Record<string, string>>;
95
+ }
96
+
97
+ /** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
98
+ * quad whose TOP edge is angled to ride just under the conus southern coast,
99
+ * so a tall box can claim the deep lower-left water without covering AZ/TX.
100
+ * `points` are the four corners (top-left, top-right, bottom-right,
101
+ * bottom-left); `x/y/w/h` is the bounding box (legend-collision math + a
102
+ * rectangular fallback). */
103
+ export interface MapLayoutInset {
104
+ readonly x: number;
105
+ readonly y: number;
106
+ readonly w: number;
107
+ readonly h: number;
108
+ readonly points: ReadonlyArray<readonly [number, number]>;
82
109
  }
83
110
 
84
111
  export interface MapLayoutPoi {
@@ -113,8 +140,16 @@ export interface PlacedLabel {
113
140
  readonly anchor: 'start' | 'middle' | 'end';
114
141
  readonly color: string;
115
142
  readonly halo: boolean;
143
+ /** Halo/outline colour — the OPPOSITE lightness of `color`, so the text reads
144
+ * whether it sits on its fill or overflows onto a different-coloured area. */
145
+ readonly haloColor: string;
116
146
  readonly leader?: { x1: number; y1: number; x2: number; y2: number };
117
- readonly pin?: number; // numbered-pin fallback
147
+ /** Leader-line colour the POI's own marker colour, so a called-out label
148
+ * reads as belonging to its dot. Falls back to a neutral grey when absent. */
149
+ readonly leaderColor?: string;
150
+ /** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
151
+ * the label + leader so the app can spotlight the dot on label hover. */
152
+ readonly poiId?: string;
118
153
  readonly lineNumber: number;
119
154
  }
120
155
 
@@ -124,9 +159,21 @@ export interface MapLayoutLegend {
124
159
  entries: ReadonlyArray<{ value: string; color: string }>;
125
160
  }>;
126
161
  readonly activeGroup: string | null;
127
- readonly ramp?: { metric?: string; min: number; max: number; hue: string };
128
- readonly size?: { metric?: string; min: number; max: number };
129
- readonly weight?: { metric?: string; min: number; max: number };
162
+ readonly ramp?: {
163
+ metric?: string;
164
+ min: number;
165
+ max: number;
166
+ hue: string;
167
+ /** Low end of the ramp gradient (the land colour the fills blend from). */
168
+ base: string;
169
+ };
170
+ }
171
+
172
+ /** A drawn river centerline — an open stroked path (no fill). */
173
+ export interface MapLayoutRiver {
174
+ readonly d: string;
175
+ readonly color: string;
176
+ readonly width: number;
130
177
  }
131
178
 
132
179
  export interface MapLayout {
@@ -137,17 +184,28 @@ export interface MapLayout {
137
184
  readonly subtitle?: string;
138
185
  readonly caption?: string;
139
186
  readonly regions: readonly MapLayoutRegion[];
187
+ /** Major river centerlines, drawn over land/lakes and under POIs/edges. */
188
+ readonly rivers: readonly MapLayoutRiver[];
140
189
  readonly legs: readonly MapLayoutLeg[];
141
190
  readonly pois: readonly MapLayoutPoi[];
142
191
  readonly labels: readonly PlacedLabel[];
143
- /** Numbered-pin fallback legend list (pin -> label). */
144
- readonly pinList: ReadonlyArray<{ pin: number; label: string }>;
145
192
  readonly legend: MapLayoutLegend | null;
193
+ /** Framed AK/HI inset cutouts (albers-usa only; empty otherwise). */
194
+ readonly insets: readonly MapLayoutInset[];
195
+ /** AK/HI region paths drawn inside the inset boxes (foreground, over an
196
+ * opaque ocean fill). Paired positionally with `insets`. */
197
+ readonly insetRegions: readonly MapLayoutRegion[];
146
198
  }
147
199
 
148
200
  export interface LayoutOptions {
149
201
  readonly palette: PaletteColors;
150
202
  readonly isDark: boolean;
203
+ /** Live override of the active colouring group (the score ramp or a tag
204
+ * group). Highest priority — beats the `active-tag` directive. The app's
205
+ * interactive legend flip passes this; `'score'` (or the metric label)
206
+ * selects the choropleth ramp, a tag-group name selects that group, `'none'`
207
+ * / `null` clears it. `undefined` = not provided (use the directive/default). */
208
+ readonly activeGroup?: string | null;
151
209
  }
152
210
 
153
211
  interface Size {
@@ -173,18 +231,71 @@ function decodeLayer(topo: BoundaryTopology): Map<string, GeoFeature> {
173
231
  return out;
174
232
  }
175
233
 
234
+ // Our own US map (replaces d3 geoAlbersUsa, whose fixed composite clips
235
+ // Canada/Mexico to hard lines and bakes in inset boxes we can't control). A
236
+ // plain Albers conic for the contiguous 48 — it does NOT clip, so neighbour land
237
+ // projects naturally and bleeds off the canvas edges. Alaska & Hawaii are drawn
238
+ // as our own insets with the dedicated projections below.
239
+ const usConusProjection = (): GeoProjection =>
240
+ geoConicEqualArea().parallels([29.5, 45.5]).rotate([96, 0]);
241
+ const alaskaProjection = (): GeoProjection =>
242
+ geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
243
+ const hawaiiProjection = (): GeoProjection => geoMercator();
244
+
176
245
  function projectionFor(family: ProjectionFamily): GeoProjection {
177
246
  switch (family) {
178
247
  case 'albers-usa':
179
- return geoAlbersUsa();
248
+ return usConusProjection();
180
249
  case 'mercator':
181
250
  return geoMercator();
182
251
  case 'natural-earth':
183
- default:
184
252
  return geoNaturalEarth1();
253
+ case 'equirectangular':
254
+ default:
255
+ // Plate carrée: x = λ, y = -φ. Cylindrical, so the extent's four CORNERS
256
+ // are its projected extremes — fitExtent frames it edge-to-edge with no
257
+ // bulge overflow (unlike naturalEarth, whose curved sides overrun a
258
+ // corner fit and clip the continents). Fills the rectangle: no rounded
259
+ // gray corners, no split landmass at the frame edge.
260
+ return geoEquirectangular();
185
261
  }
186
262
  }
187
263
 
264
+ /** US state ISO codes that render as insets (drawn off the conus). */
265
+ const INSET_STATES = new Set(['US-AK', 'US-HI']);
266
+ /** US territories excluded from the contiguous-US fit frame. */
267
+ const US_NON_CONUS = new Set([
268
+ 'US-AK',
269
+ 'US-HI',
270
+ 'US-AS',
271
+ 'US-GU',
272
+ 'US-MP',
273
+ 'US-PR',
274
+ 'US-VI',
275
+ ]);
276
+
277
+ /** The map's water / backdrop colour for a palette — the single source of truth
278
+ * shared by the renderer's `<rect>` fill and any host wrapper that needs to
279
+ * match it (so letterbox gaps around the SVG don't show a stray band). */
280
+ export function mapBackgroundColor(palette: PaletteColors): string {
281
+ return mix(palette.colors.blue, palette.bg, WATER_TINT);
282
+ }
283
+
284
+ /** The map's neutral (unscored/untagged) LAND colour — the green base every
285
+ * region blends from. Exported so a host can DIM a region to plain land
286
+ * (rather than lowering opacity, which would let the blue water show through
287
+ * and make the shape read as ocean). Matches the layout's `neutralFill`. */
288
+ export function mapNeutralLandColor(
289
+ palette: PaletteColors,
290
+ isDark: boolean
291
+ ): string {
292
+ return mix(
293
+ palette.colors.green,
294
+ palette.bg,
295
+ isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT
296
+ );
297
+ }
298
+
188
299
  export function layoutMap(
189
300
  resolved: ResolvedMap,
190
301
  data: MapData,
@@ -195,15 +306,42 @@ export function layoutMap(
195
306
  const { width, height } = size;
196
307
 
197
308
  // -- Basemap decode --
198
- const worldTopo =
199
- resolved.basemaps.world === 'detail' ? data.worldDetail : data.worldCoarse;
309
+ const wantsUsStates = resolved.basemaps.subdivisions.includes('us-states');
310
+ // In a US (albers-usa + us-states) view the surrounding land was world-atlas
311
+ // 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
312
+ // assets are present, swap them in so neighbours (Canada/Mexico) and the Great
313
+ // Lakes match the states' resolution. Falls back to the world tiers otherwise.
314
+ const usCrisp =
315
+ resolved.projection === 'albers-usa' && wantsUsStates && !!data.naLand;
316
+ const worldTopo = usCrisp
317
+ ? data.naLand!
318
+ : resolved.basemaps.world === 'detail'
319
+ ? data.worldDetail
320
+ : data.worldCoarse;
200
321
  const worldLayer = decodeLayer(worldTopo);
201
- const usLayer = resolved.basemaps.subdivisions.includes('us-states')
202
- ? decodeLayer(data.usStates)
203
- : null;
322
+ const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
204
323
 
205
- const neutralFill = mix(palette.border, palette.bg, 32);
206
- const regionStroke = mix(palette.border, palette.bg, 70);
324
+ // Land is a muted green; the ocean/backdrop is blue. Scored/tagged regions
325
+ // paint over the land base, and the score ramp blends FROM the land colour so
326
+ // low scores stay land-toned rather than fading out. In a US view the world
327
+ // layer is just neighbour context (Mexico/Canada at the frame edge) — fill it
328
+ // gray so the green US reads as the subject; world maps (no us-states layer)
329
+ // keep green land for every country.
330
+ const landTint = isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT;
331
+ const neutralFill = mix(palette.colors.green, palette.bg, landTint);
332
+ const water = mapBackgroundColor(palette);
333
+ const usContext = usLayer !== null;
334
+ const foreignFill = mix(
335
+ palette.colors.gray,
336
+ palette.bg,
337
+ isDark ? FOREIGN_TINT_DARK : FOREIGN_TINT_LIGHT
338
+ );
339
+ // Region borders: a clearly dark outline in BOTH themes. palette.text flips
340
+ // (dark on light, light on dark), so mix toward whichever of text/bg is the
341
+ // dark one — never a light hairline over the land fills.
342
+ const regionStroke = isDark
343
+ ? mix(palette.bg, palette.text, 78) // dark theme: near-bg dark outline
344
+ : mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
207
345
 
208
346
  // -- Region fill model (choropleth + categorical; AR4/AR6) --
209
347
  const scores = resolved.regions
@@ -212,18 +350,52 @@ export function layoutMap(
212
350
  const scaleOverride = resolved.directives.scale;
213
351
  const rampMin = scaleOverride ? scaleOverride.min : Math.min(...scores);
214
352
  const rampMax = scaleOverride ? scaleOverride.max : Math.max(...scores);
215
- const rampHue = palette.primary;
353
+ // Score ramp is red so scored regions stand out against the blue water
354
+ // (palette.primary is a blue in most palettes and would blend in).
355
+ const rampHue = palette.colors.red;
216
356
  const hasRamp = scores.length > 0;
217
357
 
218
- const activeGroup = resolveActiveTagGroup(
219
- resolved.tagGroups as TagGroup[],
220
- resolved.directives.activeTag
221
- );
358
+ // Colouring dimension (AR4, bivariate): the score ramp and each tag group are
359
+ // mutually-exclusive selectable groups. `SCORE_NAME` is the ramp's group name
360
+ // (the metric label, or "Score"); the reserved token `score` also selects it.
361
+ // Exactly one dimension is active and drives every region's fill.
362
+ const SCORE_NAME = hasRamp
363
+ ? resolved.directives.metric?.trim() || 'Score'
364
+ : null;
365
+ const matchColorGroup = (v: string): string | null => {
366
+ const lv = v.trim().toLowerCase();
367
+ if (lv === 'none') return null;
368
+ if (SCORE_NAME && (lv === 'score' || lv === SCORE_NAME.toLowerCase()))
369
+ return SCORE_NAME;
370
+ const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
371
+ return tg ? tg.name : v; // unknown name passes through → renders neutral
372
+ };
373
+ const override = opts.activeGroup; // string | null | undefined
374
+ let activeGroup: string | null;
375
+ if (override !== undefined) {
376
+ activeGroup = override === null ? null : matchColorGroup(override);
377
+ } else if (resolved.directives.activeTag !== undefined) {
378
+ activeGroup = matchColorGroup(resolved.directives.activeTag);
379
+ } else {
380
+ // Default: colour by score when scores exist (preserves the historical
381
+ // "score wins" default), else the first declared tag group.
382
+ activeGroup =
383
+ SCORE_NAME ??
384
+ (resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
385
+ }
386
+ const activeIsScore = SCORE_NAME !== null && activeGroup === SCORE_NAME;
222
387
 
388
+ // Score ramp base: a NEUTRAL tint of the page, NOT the (green) land colour —
389
+ // blending red toward green produced muddy brown mid-tones that blurred into
390
+ // the unscored land. Anchored to a neutral, the ramp is a clean single-hue red
391
+ // scale (light → deep) distinct from the green base. On dark, lift the anchor
392
+ // off the near-black surface so the lowest scores read as a clear muted red
393
+ // rather than sinking to maroon-black.
394
+ const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
223
395
  const fillForScore = (s: number): string => {
224
396
  const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
225
397
  const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
226
- return mix(rampHue, isDark ? palette.surface : palette.bg, pct);
398
+ return mix(rampHue, rampBase, pct);
227
399
  };
228
400
 
229
401
  /** Resolve a tag value (name) -> tinted hex via a declared group, or null. */
@@ -246,56 +418,74 @@ export function layoutMap(
246
418
  // is used directly (do NOT run it through resolveColor, which rejects `#`).
247
419
  // An unknown tag VALUE (no matching entry) falls back to neutral (AR4/AC25).
248
420
  if (!entry?.color) return null;
249
- return shapeFill(palette, entry.color, isDark); // 25% tint, never solid by default
421
+ // Flat saturated tint (NOT the 25% shape default) so the category reads
422
+ // clearly over the tinted land — see TAG_TINT_*.
423
+ return mix(
424
+ entry.color,
425
+ palette.bg,
426
+ isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT
427
+ );
428
+ };
429
+
430
+ /** A region's fill under the ACTIVE colouring dimension (AR4, bivariate):
431
+ * score-active → ramp for scored regions, neutral otherwise; a tag group
432
+ * active → that group's tag colour, neutral otherwise (score ignored). */
433
+ const regionFill = (r: {
434
+ score?: number;
435
+ tags: Readonly<Record<string, string>>;
436
+ }): string => {
437
+ if (activeIsScore) {
438
+ return r.score !== undefined ? fillForScore(r.score) : neutralFill;
439
+ }
440
+ return tagFill(r.tags, activeGroup) ?? neutralFill;
250
441
  };
251
442
 
252
443
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
253
444
 
254
445
  // -- Projection + fit (AR2, refined) --
255
- // The drawn region polygons drive an albers fit; for world projections we fit
256
- // to the resolver's (padded, never-degenerate) extent box fitting to raw
257
- // drawn points would collapse to a zero-size target (single/coincident POIs
258
- // Infinity scale NaN). albers-usa is US-only with AK/HI insets, so a
259
- // geographic bbox is wrong there — fit to the actual US features instead, and
260
- // fall back to the whole-US base when only POIs are present.
261
- const regionFeatures: GeoFeature[] = [];
262
- for (const r of resolved.regions) {
263
- const f =
264
- r.layer === 'us-state' ? usLayer?.get(r.iso) : worldLayer.get(r.iso);
265
- if (f) regionFeatures.push(f);
266
- }
267
- // The extent's four CORNERS as a MultiPoint — NOT a Polygon. A hand-built
446
+ // For world projections we fit to the resolver's (padded, never-degenerate)
447
+ // extent box fitting to raw drawn points would collapse to a zero-size
448
+ // target (single/coincident POIs Infinity scale NaN). albers-usa fits to
449
+ // its own conus features (below).
450
+ //
451
+ // The extent outline sampled as a MultiPoint NOT a Polygon. A hand-built
268
452
  // lat/lon rectangle's spherical winding is ambiguous to d3-geo, which can
269
453
  // read it as the whole-globe complement (→ tiny content framed on a world
270
- // map). Corner points have no interior/winding ambiguity, so fitExtent frames
271
- // exactly the extent box.
272
- const extentCorners = (): GeoFeature => {
454
+ // map). Points have no interior/winding ambiguity, so fitExtent frames the
455
+ // box exactly. We sample ALONG the four edges (not just the corners) because
456
+ // a curved projection (natural-earth) bulges between corners — its widest x
457
+ // is at the equator and its lowest/highest y at the central meridian, neither
458
+ // of which is a corner. Fitting only corners under-frames the curve, so the
459
+ // continents at the frame's top/bottom/sides spill off and clip (S. Africa,
460
+ // Argentina, N. Russia). Equirectangular/mercator are linear, so the extra
461
+ // samples are redundant-but-harmless there.
462
+ const extentOutline = (): GeoFeature => {
273
463
  const [[w, s], [e, n]] = resolved.extent;
464
+ const N = 16;
465
+ const coords: Array<[number, number]> = [];
466
+ for (let i = 0; i <= N; i++) {
467
+ const t = i / N;
468
+ const lon = w + (e - w) * t;
469
+ const lat = s + (n - s) * t;
470
+ coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
471
+ }
274
472
  return {
275
473
  type: 'Feature',
276
474
  properties: {},
277
- geometry: {
278
- type: 'MultiPoint',
279
- coordinates: [
280
- [w, s],
281
- [e, s],
282
- [e, n],
283
- [w, n],
284
- ],
285
- },
475
+ geometry: { type: 'MultiPoint', coordinates: coords },
286
476
  };
287
477
  };
288
478
 
289
479
  let fitFeatures: GeoFeature[];
290
- if (resolved.projection === 'albers-usa') {
291
- if (regionFeatures.length > 0) fitFeatures = regionFeatures;
292
- else if (usLayer) fitFeatures = [...usLayer.values()];
293
- else {
294
- const us = worldLayer.get('US');
295
- fitFeatures = us ? [us] : [...worldLayer.values()];
296
- }
480
+ if (resolved.projection === 'albers-usa' && usLayer) {
481
+ // Frame the contiguous 48 + DC (insets/territories excluded). The conic
482
+ // projects everything else Canada, Mexico around it, bleeding off the
483
+ // canvas edges so there's no empty water band and no hard clip line.
484
+ fitFeatures = [...usLayer.entries()]
485
+ .filter(([iso]) => !US_NON_CONUS.has(iso))
486
+ .map(([, f]) => f);
297
487
  } else {
298
- fitFeatures = [extentCorners()];
488
+ fitFeatures = [extentOutline()];
299
489
  }
300
490
  const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
301
491
 
@@ -308,39 +498,460 @@ export function layoutMap(
308
498
  if (centerLon > 180) centerLon -= 360;
309
499
  projection.rotate([-centerLon, 0]);
310
500
  }
311
- projection.fitExtent(
501
+ // Reserve top padding for the title/subtitle banner ONLY when there are POIs,
502
+ // so their markers/labels don't project up under the title (which renders in
503
+ // the foreground). A POI-less choropleth needs no reserve — the land fills to
504
+ // the top and the title simply overlays it, so neighbour land (e.g. Canada)
505
+ // isn't cut short by a band of empty water above it.
506
+ const TITLE_GAP = 16;
507
+ let topPad = FIT_PAD;
508
+ if (resolved.title && resolved.pois.length > 0) {
509
+ const bannerBottom =
510
+ (resolved.subtitle ? TITLE_Y + TITLE_FONT_SIZE : TITLE_Y) +
511
+ TITLE_FONT_SIZE / 2;
512
+ topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP);
513
+ }
514
+ const fitBox: [[number, number], [number, number]] = [
515
+ [FIT_PAD, topPad],
312
516
  [
313
- [FIT_PAD, FIT_PAD],
314
- [
315
- Math.max(FIT_PAD + 1, width - FIT_PAD),
316
- Math.max(FIT_PAD + 1, height - FIT_PAD),
317
- ],
517
+ Math.max(FIT_PAD + 1, width - FIT_PAD),
518
+ Math.max(topPad + 1, height - FIT_PAD),
318
519
  ],
319
- fitTarget as never
320
- );
321
- const path: GeoPath = geoPath(projection);
322
- const project = (lon: number, lat: number): [number, number] | null =>
323
- projection([lon, lat]) ?? null;
520
+ ];
521
+ projection.fitExtent(fitBox, fitTarget as never);
522
+
523
+ // Global views stretch-fill the canvas. A whole-world map is ~2:1 but the
524
+ // preview pane is often near-square, so the honest contain-fit letterboxes it
525
+ // with large water bands. For GLOBAL extents we stretch the PROJECTED geometry
526
+ // non-uniformly to fill both axes — countries distort (a deliberate trade for
527
+ // a full canvas), but POI radii + label font sizes are applied in the renderer
528
+ // (NOT here), so markers stay round and text stays un-squashed. Regional views
529
+ // keep contain-fit: no distortion, neighbour land not cropped.
530
+ const fitGB = geoBounds(fitTarget as never) as [
531
+ [number, number],
532
+ [number, number],
533
+ ];
534
+ const fitIsGlobal =
535
+ fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
536
+ let path: GeoPath;
537
+ let project: (lon: number, lat: number) => [number, number] | null;
538
+ if (fitIsGlobal) {
539
+ const cb = geoPath(projection).bounds(fitTarget as never);
540
+ const bx0 = cb[0][0];
541
+ const by0 = cb[0][1];
542
+ const cw = cb[1][0] - bx0;
543
+ const ch = cb[1][1] - by0;
544
+ const ox = fitBox[0][0];
545
+ const oy = fitBox[0][1];
546
+ const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
547
+ const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
548
+ const stretch = (x: number, y: number): [number, number] => [
549
+ ox + (x - bx0) * sx,
550
+ oy + (y - by0) * sy,
551
+ ];
552
+ const baseProjection = projection;
553
+ // Post-projection non-uniform scale: baseProjection.stream projects each
554
+ // point, then this transform stretches it before it reaches the path sink.
555
+ const tx = geoTransform({
556
+ point(x: number, y: number) {
557
+ const [px, py] = stretch(x, y);
558
+ (
559
+ this as unknown as { stream: { point(x: number, y: number): void } }
560
+ ).stream.point(px, py);
561
+ },
562
+ });
563
+ path = geoPath({
564
+ stream: (s: never) =>
565
+ baseProjection.stream(
566
+ (tx as unknown as { stream: (d: never) => never }).stream(s)
567
+ ),
568
+ } as never);
569
+ project = (lon, lat) => {
570
+ const p = baseProjection([lon, lat]);
571
+ return p ? stretch(p[0], p[1]) : null;
572
+ };
573
+ } else {
574
+ path = geoPath(projection);
575
+ project = (lon, lat) => projection([lon, lat]) ?? null;
576
+ }
577
+
578
+ // -- Alaska & Hawaii insets (our own, replacing geoAlbersUsa's fixed boxes) --
579
+ // The conus conic projects AK/HI to their real positions (far off-frame), so
580
+ // they're culled from the main layer; instead each is drawn in its own framed
581
+ // box in the lower-left with a dedicated projection fit to that box. Inset
582
+ // region paths (computed here, in inset-projection screen coords) are appended
583
+ // to `regions` so the renderer draws them like any other region.
584
+ const insets: MapLayoutInset[] = [];
585
+ const insetRegions: MapLayoutRegion[] = [];
586
+ // Seeds for AK/HI labels (centroid in inset-projection coords) — turned into
587
+ // PlacedLabels in the labels section so they share the region-label styling.
588
+ const insetLabelSeeds: {
589
+ x: number;
590
+ y: number;
591
+ iso: string;
592
+ name: string;
593
+ lineNumber: number;
594
+ }[] = [];
595
+ if (resolved.projection === 'albers-usa' && usLayer) {
596
+ const PAD = 8;
597
+ const GAP = 12; // px the top edge rides below the coast
598
+ const yB = height - FIT_PAD; // lowest a box may reach (canvas bottom pad)
599
+ // Southern-coast profile sampled from the conus polygon VERTICES: the lowest
600
+ // (max-y) projected vertex per x-bucket. Accurate everywhere — including
601
+ // Texas's diagonal Rio Grande border, which a bounding box would misread.
602
+ // Open-ocean columns (no vertex) impose NO constraint, so a box may sit there
603
+ // freely; that lets the insets live anywhere in the lower water (no need to
604
+ // dodge Texas) and is what keeps both boxes placeable in any aspect ratio.
605
+ const BW = 8; // x-bucket width (px)
606
+ const coast = new Map<number, number>();
607
+ const addPt = (lon: number, lat: number): void => {
608
+ const p = projection([lon, lat]);
609
+ if (!p) return;
610
+ const bi = Math.floor(p[0] / BW);
611
+ const cur = coast.get(bi);
612
+ if (cur === undefined || p[1] > cur) coast.set(bi, p[1]);
613
+ };
614
+ const walk = (co: unknown): void => {
615
+ if (Array.isArray(co) && typeof co[0] === 'number')
616
+ addPt(co[0] as number, co[1] as number);
617
+ else if (Array.isArray(co)) for (const c of co) walk(c);
618
+ };
619
+ for (const [iso, f] of usLayer) {
620
+ if (US_NON_CONUS.has(iso)) continue;
621
+ walk((f.geometry as { coordinates?: unknown }).coordinates);
622
+ }
623
+ // Coast y at x, or -Infinity over open ocean (no land above → no constraint).
624
+ const at = (x: number): number => {
625
+ const bi = Math.floor(x / BW);
626
+ let y = -Infinity;
627
+ for (let k = bi - 1; k <= bi + 1; k++) {
628
+ const v = coast.get(k);
629
+ if (v !== undefined && v > y) y = v;
630
+ }
631
+ return y;
632
+ };
633
+ // Top edge for a box over [x0, xr]: a straight line PARALLEL to the local
634
+ // coast (least-squares over the land samples), pushed down so it clears every
635
+ // land sample by GAP. Parallel → uniform, maximal clearance for how close it
636
+ // sits, tilting the way the coast tilts. Open-ocean samples are skipped, so a
637
+ // box reaching past the coast isn't dragged down by water. Falls back to a
638
+ // flat line just under the lowest land if the fit is underdetermined.
639
+ const coastTop = (x0: number, xr: number): ((x: number) => number) => {
640
+ const n = 24;
641
+ const pts: Array<[number, number]> = [];
642
+ let maxY = -Infinity;
643
+ for (let i = 0; i <= n; i++) {
644
+ const x = x0 + ((xr - x0) * i) / n;
645
+ const y = at(x);
646
+ if (y > -Infinity) {
647
+ pts.push([x, y]);
648
+ if (y > maxY) maxY = y;
649
+ }
650
+ }
651
+ if (pts.length === 0) return () => yB - height * 0.42; // all ocean
652
+ let m = 0;
653
+ if (pts.length >= 2) {
654
+ let sx = 0,
655
+ sy = 0,
656
+ sxx = 0,
657
+ sxy = 0;
658
+ for (const [x, y] of pts) {
659
+ sx += x;
660
+ sy += y;
661
+ sxx += x * x;
662
+ sxy += x * y;
663
+ }
664
+ const den = pts.length * sxx - sx * sx;
665
+ if (den !== 0) m = (pts.length * sxy - sx * sy) / den;
666
+ }
667
+ // Cap the tilt so a steep coast (e.g. California's) doesn't turn the box
668
+ // into a tall triangle — keep it a compact, gently-angled quad.
669
+ m = Math.max(-0.35, Math.min(0.35, m));
670
+ let c = -Infinity; // raise the line until it clears every land sample + GAP
671
+ for (const [x, y] of pts) {
672
+ const need = y - m * x + GAP;
673
+ if (need > c) c = need;
674
+ }
675
+ return (x: number) => m * x + c;
676
+ };
677
+ // A snug floating box that just contains the state, tucked up under the coast
678
+ // with a coast-parallel slanted top. `iwReq` is the requested inner width.
679
+ // Returns the box's right edge so the next inset can sit beside it.
680
+ const placeInset = (
681
+ iso: string,
682
+ proj: GeoProjection,
683
+ boxX: number,
684
+ iwReq: number
685
+ ): number => {
686
+ const f = usLayer.get(iso);
687
+ if (!f) return boxX;
688
+ const x0 = boxX;
689
+ // Clamp the width to the remaining canvas so the box can't run off-frame.
690
+ const iw = Math.min(iwReq, width - FIT_PAD - x0 - 2 * PAD);
691
+ if (iw < 24) return boxX; // canvas truly too narrow for another inset
692
+ const xr = x0 + iw + 2 * PAD;
693
+ const top = coastTop(x0, xr);
694
+ const yL = top(x0);
695
+ const yR = top(xr);
696
+ // Learn the state's height at this width, then size the box to just hold it.
697
+ proj.fitWidth(iw, f as never);
698
+ const bb = geoPath(proj).bounds(f as never);
699
+ const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
700
+ // State sits below the lower top corner. If the coast runs so low the state
701
+ // wouldn't fit above yB, raise the top (the corner stays over ocean) — the
702
+ // box must never collapse and vanish.
703
+ const needH = sh + 2 * PAD;
704
+ let topFit = Math.max(yL, yR);
705
+ const bottom = Math.min(topFit + needH, yB);
706
+ if (bottom - topFit < needH) topFit = bottom - needH;
707
+ const lift = topFit - Math.max(yL, yR); // keep the slanted top straight
708
+ const topL = yL + lift;
709
+ const topR = yR + lift;
710
+ proj.fitExtent(
711
+ [
712
+ [x0 + PAD, topFit + PAD],
713
+ [xr - PAD, bottom - PAD],
714
+ ],
715
+ f as never
716
+ );
717
+ const d = geoPath(proj)(f as never) ?? '';
718
+ if (!d) return xr;
719
+ const r = regionById.get(iso);
720
+ let fill = neutralFill;
721
+ let lineNumber = -1;
722
+ if (r?.layer === 'us-state') {
723
+ fill = regionFill(r);
724
+ lineNumber = r.lineNumber;
725
+ }
726
+ insets.push({
727
+ x: x0,
728
+ y: Math.min(topL, topR),
729
+ w: xr - x0,
730
+ h: bottom - Math.min(topL, topR),
731
+ points: [
732
+ [x0, topL],
733
+ [xr, topR],
734
+ [xr, bottom],
735
+ [x0, bottom],
736
+ ],
737
+ });
738
+ insetRegions.push({
739
+ id: iso,
740
+ d,
741
+ fill,
742
+ stroke: regionStroke,
743
+ lineNumber,
744
+ layer: 'us-state',
745
+ ...(r?.score !== undefined && { score: r.score }),
746
+ ...(r && Object.keys(r.tags).length > 0 && { tags: r.tags }),
747
+ });
748
+ const ctr = geoPath(proj).centroid(f as never);
749
+ if (Number.isFinite(ctr[0])) {
750
+ const name = (f.properties as { name?: string } | null)?.name ?? iso;
751
+ insetLabelSeeds.push({ x: ctr[0], y: ctr[1], iso, name, lineNumber });
752
+ }
753
+ return xr;
754
+ };
755
+ // AK is the larger state; HI a small island group tucked to its right.
756
+ const akRight = placeInset(
757
+ 'US-AK',
758
+ alaskaProjection(),
759
+ FIT_PAD,
760
+ width * 0.15
761
+ );
762
+ placeInset('US-HI', hawaiiProjection(), akRight + 24, width * 0.1);
763
+ }
764
+
765
+ // -- Basemap culling --
766
+ // At a regional zoom (e.g. a Caribbean route) far-away land — especially the
767
+ // poles and antimeridian-spanning countries (Antarctica, Russia, Canada) —
768
+ // projects to frame-filling garbage whose fill covers the whole viewport,
769
+ // painting "sea" as land. Only draw features whose geographic bounds overlap
770
+ // the (padded) visible extent. A near-global view draws everything.
771
+ // In an albers-usa + us-states view the projection frames the ENTIRE
772
+ // contiguous 48 (it fits to `fitTarget` = the conus states, NOT the POI
773
+ // extent), so the cull box must be the CONUS bounds. Culling by
774
+ // resolved.extent — which is the POI cluster, often a single metro — would
775
+ // drop every in-frame state outside that cluster, leaving gray gaps where
776
+ // land should be. Far countries are still culled (to the conus box) so the
777
+ // unclipped conic doesn't paint frame-filling garbage; the us-states layer
778
+ // itself is never culled (every conus state is in frame by construction).
779
+ const conusFit = resolved.projection === 'albers-usa' && !!usLayer;
780
+ const cullExtent = conusFit
781
+ ? (geoBounds(fitTarget as never) as [[number, number], [number, number]])
782
+ : resolved.extent;
783
+ const [[exW, exS], [exE, exN]] = cullExtent;
784
+ const lonSpan = exE - exW;
785
+ const latSpan = exN - exS;
786
+ // A near-global view draws everything. (albers-usa is handled per-layer at the
787
+ // pushRegionLayer calls: the world layer IS culled by the contiguous-US extent
788
+ // so far countries don't project to frame-filling garbage, while the us-states
789
+ // layer is NEVER culled so Alaska & Hawaii — far outside that extent — survive.)
790
+ const isGlobalView = lonSpan >= 270 || latSpan >= 130;
791
+ const padLon = Math.max(8, lonSpan * 0.35);
792
+ const padLat = Math.max(8, latSpan * 0.35);
793
+ const vW = exW - padLon;
794
+ const vE = exE + padLon;
795
+ const vS = exS - padLat;
796
+ const vN = exN + padLat;
797
+ // Pacific-crossing extents use extended longitudes (e.g. 247 = 113°W), but
798
+ // ring vertices are in [-180,180]. Shift each vertex into the extent's frame
799
+ // so the overlap test compares like-for-like.
800
+ const vLonCenter = (exW + exE) / 2;
801
+ const normLon = (lon: number): number => {
802
+ let L = lon;
803
+ while (L < vLonCenter - 180) L += 360;
804
+ while (L > vLonCenter + 180) L -= 360;
805
+ return L;
806
+ };
807
+ // True if an outer ring overlaps the padded view box. A ring with a vertex
808
+ // inside is in; otherwise a non-wrapping bbox overlap also counts (a big
809
+ // coastal polygon whose edge clips the box). Antimeridian-wrapping rings with
810
+ // no in-view vertex are dropped — they are the frame-fill artifact source.
811
+ type Ring = ReadonlyArray<readonly [number, number]>;
812
+ const ringOverlapsView = (ring: Ring): boolean => {
813
+ let anyIn = false;
814
+ let loMin = Infinity,
815
+ loMax = -Infinity,
816
+ laMin = Infinity,
817
+ laMax = -Infinity,
818
+ rawMin = Infinity,
819
+ rawMax = -Infinity;
820
+ for (const [rawLon, lat] of ring) {
821
+ const lon = normLon(rawLon);
822
+ if (lon >= vW && lon <= vE && lat >= vS && lat <= vN) anyIn = true;
823
+ if (lon < loMin) loMin = lon;
824
+ if (lon > loMax) loMax = lon;
825
+ if (rawLon < rawMin) rawMin = rawLon;
826
+ if (rawLon > rawMax) rawMax = rawLon;
827
+ if (lat < laMin) laMin = lat;
828
+ if (lat > laMax) laMax = lat;
829
+ }
830
+ // A near-circumpolar ring (Antarctica, polar wrap) spans almost all
831
+ // longitudes and projects to a frame-filling fill at regional zoom — drop it.
832
+ if (loMax - loMin > 270) return false;
833
+ // An antimeridian-crossing ring (raw lons span >180 but normalize to a small
834
+ // arc — e.g. Fiji at 177°E..178°W) inverts under a rotated projection and
835
+ // fills the frame. At coarse tier these are tiny islands; drop them in
836
+ // regional views rather than paint the whole ocean as land.
837
+ if (rawMax - rawMin > 180 && loMax - loMin < 90) return false;
838
+ if (anyIn) return true;
839
+ if (loMax - loMin > 180) return false; // wraps antimeridian, none in view
840
+ return !(loMax < vW || loMin > vE || laMax < vS || laMin > vN);
841
+ };
842
+ // Drop a feature's sub-polygons that don't touch the view (e.g. Alaska's
843
+ // Aleutians on a US feature framed over the Caribbean). Returns null if the
844
+ // whole feature is out of view. Near-global views keep everything.
845
+ const cullFeatureToView = (f: GeoFeature): GeoFeature | null => {
846
+ if (isGlobalView) return f;
847
+ const g = f.geometry as {
848
+ type: string;
849
+ coordinates: number[][][] | number[][][][];
850
+ } | null;
851
+ if (!g) return f;
852
+ if (g.type === 'Polygon') {
853
+ const ring = (g.coordinates as number[][][])[0] as unknown as Ring;
854
+ return ringOverlapsView(ring) ? f : null;
855
+ }
856
+ if (g.type === 'MultiPolygon') {
857
+ const polys = g.coordinates as number[][][][];
858
+ const keep = polys.filter((p) =>
859
+ ringOverlapsView(p[0] as unknown as Ring)
860
+ );
861
+ if (!keep.length) return null;
862
+ if (keep.length === polys.length) return f;
863
+ return { ...f, geometry: { ...g, coordinates: keep } } as GeoFeature;
864
+ }
865
+ return f;
866
+ };
867
+
868
+ // View-INDEPENDENT frame-fill guard. An antimeridian-crossing ring whose true
869
+ // occupied longitude arc is small (e.g. Fiji: islands at 177°E and 178°W, a
870
+ // ~5° arc straddling the seam) projects under equirectangular to two slivers
871
+ // at opposite frame edges; the fill between them inverts to paint the WHOLE
872
+ // ocean as land. `cullFeatureToView` drops these in a regional view, but a
873
+ // global/world view skips culling — so they must be dropped here regardless.
874
+ // Distinguishes a real seam-crosser (Russia ≈170° arc, kept) from a sliver
875
+ // (Fiji ≈5° arc, dropped) by the occupied-arc width, computed from the ring's
876
+ // own longitudes (no view frame), so it's correct at any projection centre.
877
+ const SEAM_SLIVER_MAX_SPAN = 100; // ° — wider seam-crossers are real, kept
878
+ const ringIsFrameFiller = (ring: Ring): boolean => {
879
+ const lons = ring.map(([lon]) => lon).sort((a, b) => a - b);
880
+ if (lons.length < 2) return false;
881
+ let maxGap = -1;
882
+ let gapIdx = 0;
883
+ for (let i = 1; i < lons.length; i++) {
884
+ const g = lons[i]! - lons[i - 1]!;
885
+ if (g > maxGap) {
886
+ maxGap = g;
887
+ gapIdx = i;
888
+ }
889
+ }
890
+ const wrapGap = lons[0]! + 360 - lons[lons.length - 1]!;
891
+ // Occupied arc = complement of the largest empty gap. If the gap straddles
892
+ // the seam the data is contiguous in [−180,180] (no inversion); otherwise
893
+ // the occupied arc wraps the seam (east > 180).
894
+ if (wrapGap >= maxGap) return false; // contiguous, doesn't cross the seam
895
+ const span = 360 - maxGap;
896
+ const east = lons[gapIdx - 1]! + 360;
897
+ return east > 180 && span < SEAM_SLIVER_MAX_SPAN;
898
+ };
899
+ // Drop a feature's seam-sliver sub-polygons (always, even in a global view).
900
+ const dropFrameFillers = (f: GeoFeature): GeoFeature | null => {
901
+ const g = f.geometry as {
902
+ type: string;
903
+ coordinates: number[][][] | number[][][][];
904
+ } | null;
905
+ if (!g) return f;
906
+ if (g.type === 'Polygon') {
907
+ const ring = (g.coordinates as number[][][])[0] as unknown as Ring;
908
+ return ringIsFrameFiller(ring) ? null : f;
909
+ }
910
+ if (g.type === 'MultiPolygon') {
911
+ const polys = g.coordinates as number[][][][];
912
+ const keep = polys.filter(
913
+ (p) => !ringIsFrameFiller(p[0] as unknown as Ring)
914
+ );
915
+ if (!keep.length) return null;
916
+ if (keep.length === polys.length) return f;
917
+ return { ...f, geometry: { ...g, coordinates: keep } } as GeoFeature;
918
+ }
919
+ return f;
920
+ };
324
921
 
325
922
  // -- Regions: base layer (neutral) then resolved fills on top --
326
923
  const regions: MapLayoutRegion[] = [];
327
924
  const pushRegionLayer = (
328
925
  layerFeatures: Map<string, GeoFeature>,
329
- layerKind: 'country' | 'us-state'
926
+ layerKind: 'country' | 'us-state',
927
+ shouldCull: boolean
330
928
  ): void => {
331
929
  for (const [iso, f] of layerFeatures) {
332
- const d = path(f as never) ?? '';
333
- if (!d) continue;
930
+ // Alaska/Hawaii are drawn as insets under albers-usa — skip them in the
931
+ // main conus layer (the conic would otherwise place them far off-frame).
932
+ if (layerKind === 'us-state' && usContext && INSET_STATES.has(iso))
933
+ continue;
934
+ // In a US view the us-states layer paints the whole country — drop the
935
+ // redundant US country polygon underneath it (it only adds a coarser base
936
+ // and a doubled outline).
937
+ if (layerKind === 'country' && usContext && iso === 'US') continue;
334
938
  const r = regionById.get(iso);
939
+ // Cull off-view land in a regional view; in a global view keep all land
940
+ // but still drop antimeridian frame-fillers (Fiji et al.).
941
+ const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
942
+ if (!viewF) continue;
943
+ const d = path(viewF as never) ?? '';
944
+ if (!d) continue;
335
945
  const isThisLayer = r?.layer === layerKind;
336
- let fill = neutralFill;
946
+ // Non-US neighbour land in a US view is gray context, not yellow land.
947
+ const isForeign = layerKind === 'country' && usContext && iso !== 'US';
948
+ let fill = isForeign ? foreignFill : neutralFill;
337
949
  let label: string | undefined;
338
950
  let lineNumber = -1;
339
951
  let layer: MapLayoutRegion['layer'] = 'base';
340
952
  if (isThisLayer) {
341
- // score wins over tag (24B.4 / AR4)
342
- if (r.score !== undefined) fill = fillForScore(r.score);
343
- else fill = tagFill(r.tags, activeGroup) ?? neutralFill;
953
+ // Fill by the ACTIVE colouring dimension (score ramp or tag group).
954
+ fill = regionFill(r);
344
955
  lineNumber = r.lineNumber;
345
956
  layer = layerKind;
346
957
  label = r.name;
@@ -353,11 +964,60 @@ export function layoutMap(
353
964
  lineNumber,
354
965
  layer,
355
966
  ...(label !== undefined && { label }),
967
+ ...(isThisLayer && r.score !== undefined && { score: r.score }),
968
+ ...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
356
969
  });
357
970
  }
358
971
  };
359
- pushRegionLayer(worldLayer, 'country');
360
- if (usLayer) pushRegionLayer(usLayer, 'us-state');
972
+ // World/foreign layer: cull by the visible extent (unless near-global) so far
973
+ // countries don't project to frame-filling garbage under albers-usa. In a
974
+ // conus fit the cull box is the whole-CONUS bounds (above), so neighbour land
975
+ // around the US survives and only truly-distant countries drop.
976
+ pushRegionLayer(worldLayer, 'country', !isGlobalView);
977
+ // US-states layer: NEVER culled in a conus fit — every contiguous state is in
978
+ // frame by construction, and culling by a tight POI extent would blank most of
979
+ // them. AK/HI are handled as insets above. Outside a conus fit, cull off-view.
980
+ if (usLayer) pushRegionLayer(usLayer, 'us-state', !conusFit && !isGlobalView);
981
+ // NOTE: insetRegions (AK/HI) are returned SEPARATELY so the renderer can draw
982
+ // them in the foreground over an opaque box — drawn inline here they'd sit
983
+ // behind neighbour land (Mexico) showing through the inset.
984
+
985
+ // Lakes (Great Lakes etc.) painted as water OVER the land so they don't read
986
+ // as land — the coarse country polygons don't carve them out. Drawn last so
987
+ // they sit above both neighbour land and US states; culled like the world
988
+ // layer, and far lakes null-project away under albers-usa.
989
+ const lakesTopo = usCrisp && data.naLakes ? data.naLakes : data.lakes;
990
+ if (lakesTopo) {
991
+ for (const [, f] of decodeLayer(lakesTopo)) {
992
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
993
+ if (!viewF) continue;
994
+ const d = path(viewF as never) ?? '';
995
+ if (!d) continue;
996
+ regions.push({
997
+ id: 'lake',
998
+ d,
999
+ fill: water,
1000
+ stroke: 'none',
1001
+ lineNumber: -1,
1002
+ layer: 'base',
1003
+ });
1004
+ }
1005
+ }
1006
+
1007
+ // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land,
1008
+ // the SAME blue as the ocean/lakes so a river reads as continuous with the
1009
+ // water it drains into. Open paths: stroked, no fill; under POIs/edges/labels.
1010
+ const riverColor = water;
1011
+ const rivers: MapLayoutRiver[] = [];
1012
+ if (data.rivers) {
1013
+ for (const [, f] of decodeLayer(data.rivers)) {
1014
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1015
+ if (!viewF) continue;
1016
+ const d = path(viewF as never) ?? '';
1017
+ if (!d) continue;
1018
+ rivers.push({ d, color: riverColor, width: RIVER_WIDTH });
1019
+ }
1020
+ }
361
1021
 
362
1022
  // -- POIs: project, size-scale, co-located spiderfy --
363
1023
  const sizeVals = resolved.pois
@@ -388,9 +1048,12 @@ export function layoutMap(
388
1048
  const hex = entry?.color; // already hex (parser-resolved)
389
1049
  if (hex) return { fill: hex, stroke: mix(hex, palette.text, 18) };
390
1050
  }
1051
+ // Untagged markers default to orange — a warm hue that contrasts with BOTH
1052
+ // the green land and the blue water/lakes/rivers. `palette.accent` is a
1053
+ // blue-ish tone in some palettes (e.g. nord) and vanished against the ocean.
391
1054
  return {
392
- fill: palette.accent,
393
- stroke: mix(palette.accent, palette.text, 18),
1055
+ fill: palette.colors.orange,
1056
+ stroke: mix(palette.colors.orange, palette.text, 18),
394
1057
  };
395
1058
  };
396
1059
 
@@ -404,7 +1067,7 @@ export function layoutMap(
404
1067
  });
405
1068
  }
406
1069
 
407
- const poiScreen = new Map<string, { cx: number; cy: number }>();
1070
+ const poiScreen = new Map<string, { cx: number; cy: number; r: number }>();
408
1071
  const pois: MapLayoutPoi[] = [];
409
1072
  // Stable order for deterministic co-location indices (AR9).
410
1073
  const orderedPois = [...resolved.pois].sort(
@@ -436,7 +1099,7 @@ export function layoutMap(
436
1099
  cy += Math.sin(ang) * COLO_R;
437
1100
  }
438
1101
  const { fill, stroke } = poiFill(e.p);
439
- poiScreen.set(e.p.id, { cx, cy });
1102
+ poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
440
1103
  const num = routeNumberById.get(e.p.id);
441
1104
  pois.push({
442
1105
  id: e.p.id,
@@ -455,22 +1118,46 @@ export function layoutMap(
455
1118
 
456
1119
  // -- Connectors: routes + edges (with parallel fan-out) --
457
1120
  const legs: MapLayoutLeg[] = [];
1121
+ // Gap between a leg's endpoint and the POI rim, so the line/arrow touches the
1122
+ // circle edge rather than burying its tip at the centre dot.
1123
+ const RIM_GAP = 1.5;
458
1124
  const legPath = (
459
- a: { cx: number; cy: number },
460
- b: { cx: number; cy: number },
1125
+ a: { cx: number; cy: number; r: number },
1126
+ b: { cx: number; cy: number; r: number },
461
1127
  curved: boolean,
462
1128
  offset: number
463
1129
  ): string => {
464
- if (!curved && offset === 0) return `M${a.cx},${a.cy}L${b.cx},${b.cy}`;
465
1130
  const mx = (a.cx + b.cx) / 2;
466
1131
  const my = (a.cy + b.cy) / 2;
467
1132
  const dx = b.cx - a.cx;
468
1133
  const dy = b.cy - a.cy;
469
1134
  const len = Math.hypot(dx, dy) || 1;
1135
+ // Trim each end back to its POI rim, but never cross past the midpoint when
1136
+ // the circles nearly touch (keeps a hair of line rather than inverting).
1137
+ const trimA = Math.min(a.r + RIM_GAP, len * 0.45);
1138
+ const trimB = Math.min(b.r + RIM_GAP, len * 0.45);
1139
+ if (!curved && offset === 0) {
1140
+ const ux = dx / len;
1141
+ const uy = dy / len;
1142
+ const ax = a.cx + ux * trimA;
1143
+ const ay = a.cy + uy * trimA;
1144
+ const bx = b.cx - ux * trimB;
1145
+ const by = b.cy - uy * trimB;
1146
+ return `M${ax},${ay}L${bx},${by}`;
1147
+ }
470
1148
  const nx = -dy / len;
471
1149
  const ny = dx / len;
472
1150
  const bow = offset !== 0 ? offset : len * ARC_CURVE_FRAC;
473
- return `M${a.cx},${a.cy}Q${mx + nx * bow},${my + ny * bow} ${b.cx},${b.cy}`;
1151
+ const px = mx + nx * bow;
1152
+ const py = my + ny * bow;
1153
+ // Tangent at each end of the quadratic Q is toward/from the control point.
1154
+ const ta = Math.hypot(px - a.cx, py - a.cy) || 1;
1155
+ const tb = Math.hypot(b.cx - px, b.cy - py) || 1;
1156
+ const ax = a.cx + ((px - a.cx) / ta) * trimA;
1157
+ const ay = a.cy + ((py - a.cy) / ta) * trimA;
1158
+ const bx = b.cx - ((b.cx - px) / tb) * trimB;
1159
+ const by = b.cy - ((b.cy - py) / tb) * trimB;
1160
+ return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
474
1161
  };
475
1162
 
476
1163
  // Routes: legs between consecutive stops (loop closing leg included).
@@ -483,7 +1170,7 @@ export function layoutMap(
483
1170
  legs.push({
484
1171
  d: legPath(a, b, curved, 0),
485
1172
  width: W_MIN,
486
- color: mix(palette.text, palette.bg, 55),
1173
+ color: mix(palette.text, palette.bg, 72),
487
1174
  arrow: true,
488
1175
  lineNumber: rt.lineNumber,
489
1176
  });
@@ -523,7 +1210,7 @@ export function layoutMap(
523
1210
  legs.push({
524
1211
  d: legPath(a, b, curved, offset),
525
1212
  width: widthFor(e),
526
- color: mix(palette.text, palette.bg, 45),
1213
+ color: mix(palette.text, palette.bg, 66),
527
1214
  arrow: e.directed,
528
1215
  lineNumber: e.lineNumber,
529
1216
  ...(e.label !== undefined && {
@@ -537,19 +1224,96 @@ export function layoutMap(
537
1224
 
538
1225
  // -- Labels: regions + POIs with escalation (AR5) --
539
1226
  const labels: PlacedLabel[] = [];
540
- const pinList: { pin: number; label: string }[] = [];
541
1227
  const obstacles: LabelRect[] = [];
542
1228
  const markers: PointCircle[] = pois.map((p) => ({
543
1229
  cx: p.cx,
544
1230
  cy: p.cy,
545
1231
  r: p.r,
546
1232
  }));
1233
+ // Sample every drawn leg into straight segments so POI labels can dodge the
1234
+ // connector lines (not just markers + other labels) — otherwise a hub POI's
1235
+ // label lands on top of the fan of edges leaving it (e.g. Los Angeles).
1236
+ const legSegments: Array<[number, number, number, number]> = [];
1237
+ for (const leg of legs) {
1238
+ const m =
1239
+ /^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/.exec(
1240
+ leg.d
1241
+ );
1242
+ if (!m) continue;
1243
+ const x0 = +m[1]!;
1244
+ const y0 = +m[2]!;
1245
+ if (m[3] !== undefined) {
1246
+ legSegments.push([x0, y0, +m[3]!, +m[4]!]);
1247
+ } else {
1248
+ const cx = +m[5]!;
1249
+ const cy = +m[6]!;
1250
+ const ex = +m[7]!;
1251
+ const ey = +m[8]!;
1252
+ const N = 8;
1253
+ let px = x0;
1254
+ let py = y0;
1255
+ for (let i = 1; i <= N; i++) {
1256
+ const t = i / N;
1257
+ const u = 1 - t;
1258
+ const qx = u * u * x0 + 2 * u * t * cx + t * t * ex;
1259
+ const qy = u * u * y0 + 2 * u * t * cy + t * t * ey;
1260
+ legSegments.push([px, py, qx, qy]);
1261
+ px = qx;
1262
+ py = qy;
1263
+ }
1264
+ }
1265
+ }
547
1266
  const collides = (rect: LabelRect): boolean =>
548
1267
  markers.some((m) => rectCircleOverlap(rect, m)) ||
549
- obstacles.some((o) => rectsOverlap(rect, o));
1268
+ obstacles.some((o) => rectsOverlap(rect, o)) ||
1269
+ legSegments.some((s) => segmentRectOverlap(s[0], s[1], s[2], s[3], rect));
550
1270
 
551
- // Region labels (default off; auto-hide tiny).
1271
+ // Region labels (default off). Rendered as haloed text — NO pill — so the
1272
+ // choropleth fill (which encodes the data) stays fully visible. The text
1273
+ // colour is contrast-picked against each region's OWN fill (dark on
1274
+ // pastel/unscored land, light on saturated fills) with an opposite-lightness
1275
+ // paint-order halo, the same convention POI labels use. A label is shown only
1276
+ // when its (padded) footprint fits inside the region, so small states like the
1277
+ // NE cluster auto-hide rather than overlap / spill onto the ocean.
552
1278
  const regionLabelMode = resolved.directives.regionLabels ?? 'off';
1279
+ const LABEL_PADX = 6;
1280
+ const LABEL_PADY = 3;
1281
+ const labelW = (text: string): number =>
1282
+ measureLegendText(text, FONT) + 2 * LABEL_PADX;
1283
+ const labelH = FONT + 2 * LABEL_PADY;
1284
+ const pushRegionLabel = (
1285
+ x: number,
1286
+ y: number,
1287
+ text: string,
1288
+ fill: string,
1289
+ lineNumber: number
1290
+ ): void => {
1291
+ const color = contrastText(
1292
+ fill,
1293
+ palette.textOnFillLight,
1294
+ palette.textOnFillDark
1295
+ );
1296
+ const haloColor =
1297
+ color === palette.textOnFillLight
1298
+ ? palette.textOnFillDark
1299
+ : palette.textOnFillLight;
1300
+ labels.push({
1301
+ x,
1302
+ y,
1303
+ text,
1304
+ anchor: 'middle',
1305
+ color,
1306
+ halo: true,
1307
+ haloColor,
1308
+ lineNumber,
1309
+ });
1310
+ };
1311
+ // A few countries have far-flung territory that drags the area-weighted
1312
+ // centroid off the mainland (US → Alaska pulls it up into Canada). Anchor
1313
+ // their world-layer label to a mainland [lon, lat] instead.
1314
+ const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
1315
+ US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
1316
+ };
553
1317
  if (regionLabelMode === 'full' || regionLabelMode === 'abbrev') {
554
1318
  for (const r of regions) {
555
1319
  if (r.layer === 'base' || r.label === undefined) continue;
@@ -557,24 +1321,30 @@ export function layoutMap(
557
1321
  r.layer === 'us-state' ? usLayer?.get(r.id) : worldLayer.get(r.id);
558
1322
  if (!f) continue;
559
1323
  const [[x0, y0], [x1, y1]] = path.bounds(f as never);
560
- if ((x1 - x0) * (y1 - y0) < TINY_REGION_AREA) continue; // auto-hide
561
- const c = path.centroid(f as never);
562
- if (!Number.isFinite(c[0])) continue;
563
1324
  const text =
564
1325
  regionLabelMode === 'abbrev' ? r.id.replace(/^US-/, '') : r.label;
565
- labels.push({
566
- x: c[0],
567
- y: c[1],
1326
+ // Hide if the label wouldn't fit inside the region's footprint.
1327
+ if (labelW(text) > x1 - x0 || labelH > y1 - y0) continue;
1328
+ const anchor =
1329
+ r.layer !== 'us-state' ? WORLD_LABEL_ANCHORS[r.id] : undefined;
1330
+ const c = anchor
1331
+ ? project(anchor[0], anchor[1])
1332
+ : path.centroid(f as never);
1333
+ if (!c || !Number.isFinite(c[0])) continue;
1334
+ pushRegionLabel(c[0], c[1], text, r.fill, r.lineNumber);
1335
+ }
1336
+ // AK/HI labels live in their insets (own projection centroids).
1337
+ for (const seed of insetLabelSeeds) {
1338
+ const text =
1339
+ regionLabelMode === 'abbrev' ? seed.iso.replace(/^US-/, '') : seed.name;
1340
+ const src = regionById.get(seed.iso);
1341
+ pushRegionLabel(
1342
+ seed.x,
1343
+ seed.y,
568
1344
  text,
569
- anchor: 'middle',
570
- color: contrastText(
571
- r.fill,
572
- palette.textOnFillLight,
573
- palette.textOnFillDark
574
- ),
575
- halo: true,
576
- lineNumber: r.lineNumber,
577
- });
1345
+ src ? regionFill(src) : neutralFill,
1346
+ seed.lineNumber
1347
+ );
578
1348
  }
579
1349
  }
580
1350
 
@@ -589,76 +1359,134 @@ export function layoutMap(
589
1359
  const src = poiById.get(p.id);
590
1360
  return src?.label ?? src?.name ?? p.id;
591
1361
  };
592
- let pinCounter = 0;
593
- for (const p of ordered) {
1362
+ const poiLabH = FONT * 1.25;
1363
+ const labelInfo = (p: MapLayoutPoi): { text: string; w: number } => {
594
1364
  const text = labelText(p);
595
- const w = measureLegendText(text, FONT);
596
- const h = FONT * 1.25;
597
- const inline: LabelRect = { x: p.cx + p.r + 3, y: p.cy - h / 2, w, h };
598
- if (!collides(inline)) {
599
- obstacles.push(inline);
1365
+ return { text, w: measureLegendText(text, FONT) };
1366
+ };
1367
+ const pushInline = (
1368
+ p: MapLayoutPoi,
1369
+ text: string,
1370
+ w: number,
1371
+ side: 'right' | 'left'
1372
+ ): void => {
1373
+ const tx = side === 'right' ? p.cx + p.r + 3 : p.cx - p.r - 3;
1374
+ obstacles.push({
1375
+ x: side === 'right' ? tx : tx - w,
1376
+ y: p.cy - poiLabH / 2,
1377
+ w,
1378
+ h: poiLabH,
1379
+ });
1380
+ labels.push({
1381
+ x: tx,
1382
+ y: p.cy + FONT / 3,
1383
+ text,
1384
+ anchor: side === 'right' ? 'start' : 'end',
1385
+ color: palette.text,
1386
+ halo: true,
1387
+ haloColor: palette.bg,
1388
+ poiId: p.id,
1389
+ lineNumber: p.lineNumber,
1390
+ });
1391
+ };
1392
+ const inlineFits = (
1393
+ p: MapLayoutPoi,
1394
+ w: number,
1395
+ side: 'right' | 'left'
1396
+ ): boolean => {
1397
+ const tx = side === 'right' ? p.cx + p.r + 3 : p.cx - p.r - 3;
1398
+ const rect: LabelRect = {
1399
+ x: side === 'right' ? tx : tx - w,
1400
+ y: p.cy - poiLabH / 2,
1401
+ w,
1402
+ h: poiLabH,
1403
+ };
1404
+ return rect.x >= 0 && rect.x + rect.w <= width && !collides(rect);
1405
+ };
1406
+
1407
+ // Pre-group POIs by proximity. A tight cluster (offshore platforms, a metro
1408
+ // of offices) gets ONE tidy callout column so its labels never pile up; an
1409
+ // isolated POI gets a normal inline label. This keeps the whole cluster's
1410
+ // labels together rather than seating a lucky few inline and stacking the
1411
+ // rest.
1412
+ const GROUP_R = 30; // px: POIs within this are one cluster
1413
+ const groups: MapLayoutPoi[][] = [];
1414
+ for (const p of ordered) {
1415
+ const near = groups.find((g) =>
1416
+ g.some((q) => Math.hypot(q.cx - p.cx, q.cy - p.cy) < GROUP_R)
1417
+ );
1418
+ if (near) near.push(p);
1419
+ else groups.push([p]);
1420
+ }
1421
+
1422
+ // Tidy callout column: stack a cluster's labels beside it (collision-free by
1423
+ // row spacing), each row leader-lined back to its dot in the dot's colour.
1424
+ const ROW_GAP = 3;
1425
+ const step = poiLabH + ROW_GAP;
1426
+ const COL_GAP = 16;
1427
+ const placeColumn = (group: MapLayoutPoi[]): void => {
1428
+ const items = group
1429
+ .map((p) => ({ p, ...labelInfo(p) }))
1430
+ .sort((a, b) => a.p.cy - b.p.cy || (a.text < b.text ? -1 : 1));
1431
+ const left = Math.min(...items.map((o) => o.p.cx - o.p.r));
1432
+ const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
1433
+ const cyMid =
1434
+ (Math.min(...items.map((o) => o.p.cy)) +
1435
+ Math.max(...items.map((o) => o.p.cy))) /
1436
+ 2;
1437
+ const maxW = Math.max(...items.map((o) => o.w));
1438
+ // Prefer the right of the cluster; fall to the left if it runs off-canvas.
1439
+ const side: 'right' | 'left' =
1440
+ right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
1441
+ const colX = side === 'right' ? right + COL_GAP : left - COL_GAP;
1442
+ const totalH = items.length * step;
1443
+ let startY = cyMid - totalH / 2;
1444
+ startY = Math.max(2, Math.min(startY, height - totalH - 2));
1445
+ items.forEach((o, i) => {
1446
+ const rowCy = startY + i * step + step / 2;
1447
+ obstacles.push({
1448
+ x: side === 'right' ? colX : colX - o.w,
1449
+ y: rowCy - poiLabH / 2,
1450
+ w: o.w,
1451
+ h: poiLabH,
1452
+ });
600
1453
  labels.push({
601
- x: inline.x,
602
- y: p.cy + FONT / 3,
603
- text,
604
- anchor: 'start',
1454
+ x: colX,
1455
+ y: rowCy + FONT / 3,
1456
+ text: o.text,
1457
+ anchor: side === 'right' ? 'start' : 'end',
605
1458
  color: palette.text,
606
1459
  halo: true,
607
- lineNumber: p.lineNumber,
1460
+ haloColor: palette.bg,
1461
+ leader: {
1462
+ x1: o.p.cx,
1463
+ y1: o.p.cy,
1464
+ x2: side === 'right' ? colX - 2 : colX + 2,
1465
+ y2: rowCy,
1466
+ },
1467
+ leaderColor: o.p.fill,
1468
+ poiId: o.p.id,
1469
+ lineNumber: o.p.lineNumber,
608
1470
  });
609
- continue;
610
- }
611
- // Escalate: fixed candidate ring -> leader line to first free slot.
612
- let placed = false;
613
- for (let k = 1; k <= 2 && !placed; k++) {
614
- for (const [dx, dy] of RING_DIRS) {
615
- const cx = p.cx + dx * LEADER_STEP * k;
616
- const cy = p.cy + dy * LEADER_STEP * k;
617
- const rect: LabelRect = {
618
- x: dx >= 0 ? cx : cx - w,
619
- y: cy - h / 2,
620
- w,
621
- h,
622
- };
623
- // Keep escalated labels on-canvas (#8) and collision-free.
624
- if (
625
- rect.x < 0 ||
626
- rect.x + rect.w > width ||
627
- rect.y < 0 ||
628
- rect.y + rect.h > height
629
- ) {
630
- continue;
631
- }
632
- if (collides(rect)) continue;
633
- obstacles.push(rect);
634
- labels.push({
635
- x: cx,
636
- y: cy + FONT / 3,
637
- text,
638
- anchor: dx >= 0 ? 'start' : 'end',
639
- color: palette.text,
640
- halo: true,
641
- leader: { x1: p.cx, y1: p.cy, x2: cx, y2: cy },
642
- lineNumber: p.lineNumber,
643
- });
644
- placed = true;
645
- break;
1471
+ });
1472
+ };
1473
+
1474
+ for (const g of groups) {
1475
+ // Singleton that fits inline inline; everything else callout column
1476
+ // (the whole cluster, or a lone POI boxed in by legs/edges).
1477
+ if (g.length === 1) {
1478
+ const p = g[0]!;
1479
+ const { text, w } = labelInfo(p);
1480
+ if (inlineFits(p, w, 'right')) {
1481
+ pushInline(p, text, w, 'right');
1482
+ continue;
1483
+ }
1484
+ if (inlineFits(p, w, 'left')) {
1485
+ pushInline(p, text, w, 'left');
1486
+ continue;
646
1487
  }
647
1488
  }
648
- if (placed) continue;
649
- // Final fallback: numbered pin + legend list entry.
650
- pinCounter += 1;
651
- pinList.push({ pin: pinCounter, label: text });
652
- labels.push({
653
- x: p.cx + p.r + 2,
654
- y: p.cy - p.r,
655
- text: String(pinCounter),
656
- anchor: 'start',
657
- color: palette.text,
658
- halo: true,
659
- pin: pinCounter,
660
- lineNumber: p.lineNumber,
661
- });
1489
+ placeColumn(g);
662
1490
  }
663
1491
  }
664
1492
 
@@ -669,12 +1497,10 @@ export function layoutMap(
669
1497
  name: g.name,
670
1498
  entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
671
1499
  }));
672
- const hasAnything =
673
- tagGroups.length > 0 ||
674
- hasRamp ||
675
- sizeVals.length > 0 ||
676
- weightVals.length > 0;
677
- if (hasAnything) {
1500
+ // Only the colouring dimensions (score ramp + tag groups) get a legend.
1501
+ // POI size and edge weight are self-evident from the marker/line scale and
1502
+ // intentionally carry no key.
1503
+ if (tagGroups.length > 0 || hasRamp) {
678
1504
  legend = {
679
1505
  tagGroups,
680
1506
  activeGroup,
@@ -686,20 +1512,9 @@ export function layoutMap(
686
1512
  min: rampMin,
687
1513
  max: rampMax,
688
1514
  hue: rampHue,
1515
+ base: rampBase,
689
1516
  },
690
1517
  }),
691
- ...(sizeVals.length > 0 && {
692
- size: {
693
- ...(resolved.directives.sizeMetric !== undefined && {
694
- metric: resolved.directives.sizeMetric,
695
- }),
696
- min: sizeMin,
697
- max: sizeMax,
698
- },
699
- }),
700
- ...(weightVals.length > 0 && {
701
- weight: { min: wMin, max: wMax },
702
- }),
703
1518
  };
704
1519
  }
705
1520
  }
@@ -707,15 +1522,17 @@ export function layoutMap(
707
1522
  return {
708
1523
  width,
709
1524
  height,
710
- background: palette.bg,
1525
+ background: water,
711
1526
  title: resolved.title,
712
1527
  ...(resolved.subtitle !== undefined && { subtitle: resolved.subtitle }),
713
1528
  ...(resolved.caption !== undefined && { caption: resolved.caption }),
714
1529
  regions,
1530
+ rivers,
715
1531
  legs,
716
1532
  pois,
717
1533
  labels,
718
- pinList,
719
1534
  legend,
1535
+ insets,
1536
+ insetRegions,
720
1537
  };
721
1538
  }