@diagrammo/dgmo 0.26.0 → 0.28.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 (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +101 -25
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1212 -96
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
package/src/map/layout.ts CHANGED
@@ -23,8 +23,9 @@ import {
23
23
  contrastRatio,
24
24
  relativeLuminance,
25
25
  politicalTints,
26
+ valueRampColor,
26
27
  } from '../palettes/color-utils';
27
- import { buildAdjacency } from './geo';
28
+ import { buildAdjacency, featureBboxPrimary } from './geo';
28
29
  import { assignColors } from './colorize';
29
30
  import { resolveColor } from '../colors';
30
31
  import type { PaletteColors } from '../palettes/types';
@@ -35,6 +36,7 @@ import {
35
36
  } from '../label-layout';
36
37
  import type { LabelRect, PointCircle } from '../label-layout';
37
38
  import { measureLegendText } from '../utils/legend-constants';
39
+ import { compactNumber } from '../utils/number-format';
38
40
  import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
39
41
  import type { LegendMode } from '../utils/legend-types';
40
42
  import { mapLegendBand } from './legend-band';
@@ -47,6 +49,7 @@ import type {
47
49
  ResolvedPoi,
48
50
  ResolvedEdge,
49
51
  ProjectionFamily,
52
+ GeoExtent,
50
53
  } from './resolved-types';
51
54
  import { placeContextLabels } from './context-labels';
52
55
  import type { CountryCandidate } from './context-labels';
@@ -65,10 +68,85 @@ interface GeoFC {
65
68
 
66
69
  // -- Tunable constants (deterministic; no magic at call sites) --
67
70
  const FIT_PAD = 24; // px padding inside the viewport
71
+ // Fractional digits for projected path `d` coordinates. d3-geo defaults to 3
72
+ // (sub-micropixel at our canvas scale) — full-world detail geometry then emits
73
+ // multi-MB SVGs that bloat the page and overflow downstream HTML reparsers.
74
+ // One decimal is 0.1px: visually identical, ~half the coordinate bytes.
75
+ const PATH_DIGITS = 1;
76
+
77
+ // Screen-space vertex tolerance for thinning (px). Projected points within this
78
+ // distance of the previously kept point are dropped. Sub-pixel, so invisible.
79
+ const THIN_TOL = 0.6;
80
+
81
+ interface ThinStream {
82
+ stream: {
83
+ point(x: number, y: number): void;
84
+ lineStart(): void;
85
+ lineEnd(): void;
86
+ };
87
+ _has?: boolean;
88
+ _pending?: boolean;
89
+ _ex?: number;
90
+ _ey?: number;
91
+ _lx?: number;
92
+ _ly?: number;
93
+ }
94
+
95
+ /**
96
+ * A geoTransform that thins projected vertices in screen space: it forwards a
97
+ * point only when it lies more than THIN_TOL px from the last forwarded point.
98
+ * Inserted just before the path serializer so it sees final screen coordinates,
99
+ * it is scale-aware by construction — at world scale the dense 50m coastline
100
+ * collapses to a few px-spaced vertices (the multi-MB bloat that overflows the
101
+ * SSG HTML reparse), while a regional zoom spreads the same coastline over many
102
+ * px so almost nothing is dropped (full detail preserved). The last vertex of
103
+ * every ring is always emitted so polygon fills stay gap-free.
104
+ */
105
+ function geoThin(): ReturnType<typeof geoTransform> {
106
+ const tol2 = THIN_TOL * THIN_TOL;
107
+ return geoTransform({
108
+ lineStart(this: unknown) {
109
+ const t = this as ThinStream;
110
+ t._has = false;
111
+ t._pending = false;
112
+ t.stream.lineStart();
113
+ },
114
+ point(this: unknown, x: number, y: number) {
115
+ const t = this as ThinStream;
116
+ t._lx = x;
117
+ t._ly = y;
118
+ if (t._has) {
119
+ const dx = x - (t._ex as number);
120
+ const dy = y - (t._ey as number);
121
+ if (dx * dx + dy * dy < tol2) {
122
+ t._pending = true;
123
+ return;
124
+ }
125
+ }
126
+ t.stream.point(x, y);
127
+ t._ex = x;
128
+ t._ey = y;
129
+ t._has = true;
130
+ t._pending = false;
131
+ },
132
+ lineEnd(this: unknown) {
133
+ const t = this as ThinStream;
134
+ if (t._pending) t.stream.point(t._lx as number, t._ly as number);
135
+ t._pending = false;
136
+ t.stream.lineEnd();
137
+ },
138
+ });
139
+ }
68
140
  const RAMP_FLOOR = 15; // % tint floor so min still reads as "low, present" (24B.3)
69
141
  const R_DEFAULT = 6; // POI radius without size:
70
142
  const R_MIN = 4;
71
143
  const R_MAX = 22;
144
+ // Larger POIs fade their FILL so big bubbles read as light/airy instead of heavy
145
+ // solid slabs (overlaps stay legible); the stroke stays fully opaque so every
146
+ // marker keeps a crisp edge regardless of size. Gentle so the largest (= most
147
+ // important) marker still reads. Linear in radius over [R_MIN, R_MAX].
148
+ const POI_FILL_OPACITY_MAX = 0.92; // at R_MIN (smallest)
149
+ const POI_FILL_OPACITY_MIN = 0.55; // at R_MAX (largest)
72
150
  const W_MIN = 1.25; // edge stroke width
73
151
  const W_MAX = 8;
74
152
  const FONT = 11; // on-map label font px
@@ -81,6 +159,13 @@ const FONT = 11; // on-map label font px
81
159
  // previously used for hover) mistook the wrapped sliver for half the shape.
82
160
  const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
83
161
  US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
162
+ // Russia crosses the antimeridian (Chukotka at ~170°W), so on a non-global
163
+ // (e.g. Europe) projection its geometry smears across the whole frame and the
164
+ // area-weighted centroid lands mid-map (over Europe) — useless as a label
165
+ // anchor. Pin it to European Russia (~Volga) so a Europe view labels visible
166
+ // western Russia on its eastern margin; on a world view this still sits over
167
+ // Russian land. (See the curated-anchor smear-gate bypass in context-labels.)
168
+ RU: [45, 58],
84
169
  };
85
170
  // POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
86
171
  // column falls back to hover-only labels when it would sprawl or overflow:
@@ -263,6 +348,9 @@ export interface MapLayoutPoi {
263
348
  readonly cy: number;
264
349
  readonly r: number;
265
350
  readonly fill: string;
351
+ /** Fill opacity scaled by radius — larger bubbles fade so they read as light
352
+ * rather than heavy. Stroke stays fully opaque (crisp edge at every size). */
353
+ readonly fillOpacity: number;
266
354
  readonly stroke: string;
267
355
  readonly lineNumber: number;
268
356
  readonly implicit: boolean;
@@ -311,6 +399,15 @@ export interface MapLayoutLeg {
311
399
  readonly width: number;
312
400
  readonly color: string;
313
401
  readonly arrow: boolean;
402
+ /** Endpoint POI ids (resolved `fromId`/`toId`), emitted as `data-from-id` /
403
+ * `data-to-id`. Lets an interactive preview co-highlight a leg's two endpoint
404
+ * POIs when the leg is focused (§17 sync). */
405
+ readonly fromId: string;
406
+ readonly toId: string;
407
+ /** Tag values (keyed by lowercased group name) — emitted as `data-tag-*`, like
408
+ * POI markers, so a legend-entry hover spotlights only the matching lines
409
+ * (§24B.6). Omitted when the leg carries no tag. */
410
+ readonly tags?: Readonly<Record<string, string>>;
314
411
  readonly label?: string;
315
412
  readonly labelX?: number;
316
413
  readonly labelY?: number;
@@ -345,6 +442,11 @@ export interface PlacedLabel {
345
442
  /** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
346
443
  * the label + leader so the app can spotlight the dot on label hover. */
347
444
  readonly poiId?: string;
445
+ /** Per-label font size in px. Set on context COUNTRY labels, which scale up with
446
+ * their projected footprint (a big country reads as a faded backdrop name, a
447
+ * small one stays at the base label font). Absent ⇒ the renderer's default
448
+ * LABEL_FONT, so every other label type renders byte-identically. */
449
+ readonly fontSize?: number;
348
450
  /** Cartographic italic (context-label water names, §24B). Default upright. */
349
451
  readonly italic?: boolean;
350
452
  /** Cartographic letter-spacing in px (context-label water names). Default 0. */
@@ -363,6 +465,16 @@ export interface PlacedLabel {
363
465
  * visible (export + expanded view) but tagged `data-cluster-member` so the app
364
466
  * hides it when the stack is collapsed to its badge. */
365
467
  readonly clusterMember?: string;
468
+ /** A choropleth region's metric VALUE (already compact-formatted, e.g. `39.5M`),
469
+ * drawn as a smaller, dimmer second line UNDER `text` (the region name). Set
470
+ * only on region labels of a `region-metric` map when `no-region-value` is off.
471
+ * The renderer stacks it as a sub-line; absent ⇒ single name line. */
472
+ readonly valueLine?: string;
473
+ /** A region too small to carry its name+value stack in place gets a leader-lined
474
+ * callout in a margin column; this marks the region's true centroid so the
475
+ * renderer draws a small anchor dot there (the leader runs dot → chip). The
476
+ * colour is the region's fill, tying the dot/leader/chip together. */
477
+ readonly calloutDot?: { x: number; y: number; color: string };
366
478
  readonly lineNumber: number;
367
479
  }
368
480
 
@@ -372,6 +484,15 @@ export interface PlacedLabel {
372
484
  // value-imports mapLegendBand from ./legend-band).
373
485
  export type { MapLayoutLegend };
374
486
 
487
+ /** A subtle gazetteer city dot for basemap orientation (§24B `no-cities`). Just
488
+ * a position + radius; the renderer paints it muted/low-opacity. No label, no
489
+ * interactivity — purely decorative context. */
490
+ export interface MapLayoutCityDot {
491
+ readonly cx: number;
492
+ readonly cy: number;
493
+ readonly r: number;
494
+ }
495
+
375
496
  /** A drawn river centerline — an open stroked path (no fill). */
376
497
  export interface MapLayoutRiver {
377
498
  readonly d: string;
@@ -439,6 +560,9 @@ export interface MapLayout {
439
560
  readonly coastlineStyle: MapLayoutCoastlineStyle | null;
440
561
  readonly legs: readonly MapLayoutLeg[];
441
562
  readonly pois: readonly MapLayoutPoi[];
563
+ /** Subtle gazetteer city dots for orientation (empty when `no-cities` or no
564
+ * cities fall on-canvas). Drawn over the basemap, under connectors/POIs. */
565
+ readonly cityDots: readonly MapLayoutCityDot[];
442
566
  /** Coincident POI stacks (spiderfy). Empty when no ≥2-member overlap exists.
443
567
  * The renderer draws a collapsed badge per stack; the app collapses/expands. */
444
568
  readonly clusters: readonly MapLayoutCluster[];
@@ -479,6 +603,28 @@ export interface LayoutOptions {
479
603
  * `'preview'` keeps inactive pills. Used to size the reserved legend band so
480
604
  * the projected land starts below the legend. Defaults to `'preview'`. */
481
605
  readonly legendMode?: LegendMode;
606
+ /** INTERNAL (set by layoutMap's own second pass — do not pass in). When tiny
607
+ * valued regions need margin callouts, the first pass measures them and
608
+ * re-runs with reserved bands: the projection fits into the canvas MINUS these
609
+ * bands so the data shrinks/shifts inward, opening label room. A cluster on
610
+ * EACH side reserves its own band (px), so tiny regions on both coasts each get
611
+ * a column. An absent side reserves nothing there. Also carries the POI
612
+ * edge-clearance bands (any of the four sides) measured by the POI-label pass
613
+ * (same fit-box mechanism). Region callouts only ever set left/right. */
614
+ readonly _calloutReserve?: {
615
+ left?: number;
616
+ right?: number;
617
+ top?: number;
618
+ bottom?: number;
619
+ };
620
+ /** INTERNAL (set by layoutMap's own POI-clearance pass — do not pass in). After
621
+ * POI-label placement, any POI dot/label crossing the edge-clearance band
622
+ * triggers a re-fit that ADDS the residual intrusion to the reserved band on
623
+ * that side, sliding the data inward. Re-measured each pass and accumulated
624
+ * until nothing intrudes (or the pass cap), so a tight cluster on a small canvas
625
+ * converges instead of giving up after one under-shoot. This counts the passes
626
+ * taken to bound the recursion. */
627
+ readonly _poiClearancePass?: number;
482
628
  }
483
629
 
484
630
  interface Size {
@@ -559,10 +705,23 @@ const alaskaProjection = (): GeoProjection =>
559
705
  geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
560
706
  const hawaiiProjection = (): GeoProjection => geoMercator();
561
707
 
562
- function projectionFor(family: ProjectionFamily): GeoProjection {
708
+ function projectionFor(
709
+ family: ProjectionFamily,
710
+ extent: GeoExtent
711
+ ): GeoProjection {
563
712
  switch (family) {
564
713
  case 'albers-usa':
565
714
  return usConusProjection();
715
+ case 'conic-equal-area': {
716
+ // Albers for a single continent: standard parallels at 1/6 and 5/6 of the
717
+ // extent's latitude band (distortion-minimizing), centered on the band's
718
+ // mid-latitude. Longitude centering is handled by the shared .rotate below.
719
+ const s = extent[0][1];
720
+ const n = extent[1][1];
721
+ return geoConicEqualArea()
722
+ .parallels([s + (n - s) / 6, s + ((n - s) * 5) / 6])
723
+ .center([0, (s + n) / 2]);
724
+ }
566
725
  case 'mercator':
567
726
  return geoMercator();
568
727
  case 'equal-earth':
@@ -700,8 +859,9 @@ export function buildMapProjection(
700
859
  // 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
701
860
  // assets are present, swap them in so neighbours (Canada/Mexico) and the Great
702
861
  // Lakes match the states' resolution. Falls back to the world tiers otherwise.
703
- // Crisp NA assets apply to BOTH the national albers-usa view AND a regional
704
- // US mercator view (POI-only region framing — e.g. a single state). A
862
+ // Crisp NA assets apply to BOTH the national albers-usa view AND a regional US
863
+ // mercator view (POI-only region framing — e.g. a single state — OR a compact
864
+ // region/choropleth that auto-zooms; map-us-subnational-zoom, both mercator). A
705
865
  // US-oriented mercator frame is sub-world and entirely within North America by
706
866
  // construction, so the NA-clipped 10m land/lakes fit it; the bbox guard below
707
867
  // still keeps non-NA countries on world geometry. Excludes equirectangular
@@ -795,7 +955,7 @@ export function buildMapProjection(
795
955
  }
796
956
  const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
797
957
 
798
- const projection = projectionFor(resolved.projection);
958
+ const projection = projectionFor(resolved.projection, resolved.extent);
799
959
  // mercator / natural-earth: rotate to the extent's center longitude BEFORE
800
960
  // fitting (rotate changes the bounds fitExtent measures). albers-usa is a
801
961
  // US-only composite with NO .rotate -- never call it (AR2).
@@ -936,11 +1096,14 @@ export function layoutMap(
936
1096
  const usContext = usLayer !== null;
937
1097
  // Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
938
1098
  // colouring dimension is active — defined below, once `activeGroup` is known.
939
- // Region borders: a clearly dark outline in BOTH themes. palette.text flips
940
- // (dark on light, light on dark), so mix toward whichever of text/bg is the
941
- // dark one never a light hairline over the land fills.
1099
+ // Region borders. Light theme: a near-text dark outline (a dark hairline
1100
+ // reads well over the pale ground). Dark theme: a near-bg dark outline
1101
+ // vanishes against the deep ground, so instead lean on the palette's
1102
+ // dedicated `border` grid-line token (tuned to pop against that ground) and
1103
+ // nudge it toward `text` for a touch more lift — a visible boundary that
1104
+ // still reads as a line, not a glaring white seam over the land fills.
942
1105
  const regionStroke = isDark
943
- ? mix(palette.bg, palette.text, 78) // dark theme: near-bg dark outline
1106
+ ? mix(palette.border, palette.text, 65) // dark theme: lifted grid-line
944
1107
  : mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
945
1108
  // Lake shoreline. Lakes are painted as water OVER the land and the region
946
1109
  // borders, so without an edge they read as a featureless patch that simply
@@ -955,13 +1118,14 @@ export function layoutMap(
955
1118
  const values = resolved.regions
956
1119
  .filter((r) => r.value !== undefined)
957
1120
  .map((r) => r.value!);
958
- // Ramp auto-fits (the `scale` directive is gone). For all-non-negative data the
959
- // low end anchors at 0 so every such choropleth shares a 0 baseline (decision
960
- // C); mixed-sign data fits data-min→data-max. Only the LOW end is shared —
961
- // different maxes still differ at the high end (cross-map comparability is not
962
- // recovered, by design).
963
- const allNonNegative = values.length > 0 && values.every((v) => v >= 0);
964
- const rampMin = allNonNegative ? 0 : Math.min(...values);
1121
+ // Ramp auto-fits (the `scale` directive is gone) to data-min→data-max the
1122
+ // low end anchors at the lowest value, not 0. This maximises within-map
1123
+ // dynamic range and matches the size/thickness metric ramps (poi-metric,
1124
+ // flow-metric), which already floor at their data minimum. Cross-map low-end
1125
+ // comparability (the old 0-anchor, "decision C") is intentionally dropped: a
1126
+ // shared baseline only helped side-by-side maps and flattened single-map
1127
+ // contrast. Equal-value data (rampMin === rampMax) falls back to t = 1 below.
1128
+ const rampMin = values.length > 0 ? Math.min(...values) : 0;
965
1129
  const rampMax = Math.max(...values);
966
1130
  // Value ramp defaults to red so valued regions stand out against the blue
967
1131
  // water (palette.primary is a blue in most palettes and would blend in). A
@@ -969,6 +1133,13 @@ export function layoutMap(
969
1133
  const rampHue =
970
1134
  resolveColor(resolved.directives.regionMetricColor ?? '', palette) ??
971
1135
  palette.colors.red;
1136
+ // Explicit LOW endpoint (`region-metric Sales green red`). Only the 11
1137
+ // recognized names peel, so resolveColor always succeeds when a name is
1138
+ // present; absent ⇒ single-colour behaviour (neutral low). §24B.3.
1139
+ const rampLow = resolved.directives.regionMetricLowColor
1140
+ ? (resolveColor(resolved.directives.regionMetricLowColor, palette) ??
1141
+ undefined)
1142
+ : undefined;
972
1143
  const hasRamp = values.length > 0;
973
1144
 
974
1145
  // Colouring dimension (AR4, bivariate): the value ramp and each tag group are
@@ -987,6 +1158,21 @@ export function layoutMap(
987
1158
  const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
988
1159
  return tg ? tg.name : v; // unknown name passes through → renders neutral
989
1160
  };
1161
+ // A tag group is a "fill group" only if its alias actually lands on a region
1162
+ // or a POI. A group used solely on connector lines (§24B.6) colours edges,
1163
+ // never the basemap — so it must not drive the region/active-tag dress or
1164
+ // suppress colorize.
1165
+ const fillGroupNames = new Set<string>();
1166
+ for (const g of resolved.tagGroups) {
1167
+ const k = g.name.toLowerCase();
1168
+ if (
1169
+ resolved.regions.some((r) => r.tags[k]) ||
1170
+ resolved.pois.some((p) => p.tags[k])
1171
+ )
1172
+ fillGroupNames.add(g.name);
1173
+ }
1174
+ const firstFillGroup =
1175
+ resolved.tagGroups.find((g) => fillGroupNames.has(g.name))?.name ?? null;
990
1176
  const override = opts.activeGroup; // string | null | undefined
991
1177
  let activeGroup: string | null;
992
1178
  if (override !== undefined) {
@@ -995,21 +1181,25 @@ export function layoutMap(
995
1181
  activeGroup = matchColorGroup(resolved.directives.activeTag);
996
1182
  } else {
997
1183
  // Default: colour by the value ramp when values exist, else the first
998
- // declared tag group.
1184
+ // declared tag group that fills a region/POI. When the only groups are
1185
+ // edge/leg groups (no fill group), fall back to the first declared group so
1186
+ // the legend still renders it as a line-colour KEY — but it won't mute the
1187
+ // basemap (see mutedBasemap below) since it fills no region.
999
1188
  activeGroup =
1000
- VALUE_NAME ??
1001
- (resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
1189
+ VALUE_NAME ?? firstFillGroup ?? resolved.tagGroups[0]?.name ?? null;
1002
1190
  }
1003
1191
  const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
1004
1192
 
1005
1193
  // Basemap dress (fixed automatic aesthetic — no directive). Subject water +
1006
1194
  // land always wear the SAME faded blue/green dress (subtle enough that
1007
1195
  // saturated tag/score tints never blend into it), so every map looks
1008
- // consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a colouring
1009
- // dimension is active the surrounding world recedes to a paler gray so the
1010
- // subject + its data fills dominate; a plain reference map keeps neighbour
1011
- // land at the fuller gray.
1012
- const mutedBasemap = activeGroup !== null;
1196
+ // consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a REGION-
1197
+ // filling dimension is active the surrounding world recedes to a paler gray so
1198
+ // the subject + its data fills dominate; a plain reference map or one whose
1199
+ // only tag group colours connector LINES (§24B.6), not regions — keeps
1200
+ // neighbour land at the fuller gray.
1201
+ const mutedBasemap =
1202
+ activeIsScore || (activeGroup !== null && fillGroupNames.has(activeGroup));
1013
1203
  const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
1014
1204
  const water = mapBackgroundColor(palette, isDark, mutedBasemap);
1015
1205
  const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
@@ -1043,7 +1233,7 @@ export function layoutMap(
1043
1233
  resolved.directives.noColorize !== true &&
1044
1234
  !hasRamp &&
1045
1235
  !hasDirectColor &&
1046
- resolved.tagGroups.length === 0;
1236
+ fillGroupNames.size === 0;
1047
1237
  // Hue per ISO over ONE UNIFIED graph spanning every drawn topology, so no two
1048
1238
  // bordering regions share a hue — INCLUDING across the international seam. The
1049
1239
  // world and us-states topologies share no TopoJSON arcs, so neighbors() is blind
@@ -1100,8 +1290,16 @@ export function layoutMap(
1100
1290
  // off the near-black surface so the lowest scores read as a clear muted red
1101
1291
  // rather than sinking to maroon-black.
1102
1292
  const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
1293
+ // Floored neutral the single-colour ramp blends up from — also the LOW
1294
+ // endpoint the legend shows when no explicit low colour was given.
1295
+ const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
1103
1296
  const fillForValue = (s: number): string => {
1104
1297
  const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
1298
+ // Two-colour ramp: shared low→high interpolation (direct or via midpoint).
1299
+ if (rampLow !== undefined)
1300
+ return valueRampColor(rampLow, rampHue, t, { isDark });
1301
+ // Single/zero-colour ramp: byte-identical to pre-change output — feed `mix`
1302
+ // the SAME numeric pct (NO float round-trip, which could drift a channel).
1105
1303
  const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
1106
1304
  return mix(rampHue, rampBase, pct);
1107
1305
  };
@@ -1190,8 +1388,8 @@ export function layoutMap(
1190
1388
  }),
1191
1389
  min: rampMin,
1192
1390
  max: rampMax,
1193
- hue: rampHue,
1194
- base: rampBase,
1391
+ low: rampLow ?? rampLowFloor,
1392
+ high: rampHue,
1195
1393
  },
1196
1394
  }),
1197
1395
  };
@@ -1228,15 +1426,70 @@ export function layoutMap(
1228
1426
  hasSubtitle: Boolean(resolved.subtitle),
1229
1427
  });
1230
1428
  if (legendBand > topPad) topPad = legendBand;
1429
+ // Reserve a side band for margin callouts (second pass only): the projection
1430
+ // fits into the canvas MINUS this band, so the data shrinks and slides away
1431
+ // from that edge, opening room for the callout chips + leaders.
1432
+ const reserve = opts._calloutReserve;
1433
+ const fitLeft = FIT_PAD + (reserve?.left ?? 0);
1434
+ const fitRight = width - FIT_PAD - (reserve?.right ?? 0);
1435
+ const fitTop = topPad + (reserve?.top ?? 0);
1436
+ const fitBottom = height - FIT_PAD - (reserve?.bottom ?? 0);
1231
1437
  const fitBox: [[number, number], [number, number]] = [
1232
- [FIT_PAD, topPad],
1233
- [
1234
- Math.max(FIT_PAD + 1, width - FIT_PAD),
1235
- Math.max(topPad + 1, height - FIT_PAD),
1236
- ],
1438
+ [fitLeft, fitTop],
1439
+ [Math.max(fitLeft + 1, fitRight), Math.max(fitTop + 1, fitBottom)],
1237
1440
  ];
1238
1441
  projection.fitExtent(fitBox, fitTarget as never);
1239
1442
 
1443
+ // Data-centered vertical fit (regional region-maps only). `fitExtent` centers
1444
+ // the EXTENT rectangle in the box; when a choropleth's data clusters away from
1445
+ // that rectangle's vertical center it lands off-center — e.g. a Europe map's
1446
+ // colored countries are mostly central/southern, but Sweden drags the extent's
1447
+ // north edge into empty Arctic, so the data sits low under a band of ocean.
1448
+ // Shift the projection vertically so the data's vertical SPAN is centered in the
1449
+ // fit box, CLAMPED so the data still fits inside the box (we never push a colored
1450
+ // region off-frame). The span comes from each region's PRIMARY landmass bbox
1451
+ // (featureBboxPrimary) — NOT the full feature, whose detached overseas
1452
+ // territories (French Guiana, the Canaries, the Dutch Caribbean) would project
1453
+ // far off-frame and wreck the bounds. POI-only regional frames are already
1454
+ // cluster-centered (container + zoom floor) and the albers-usa composite frames
1455
+ // the nation itself — both skip this.
1456
+ if (
1457
+ !fitIsGlobal &&
1458
+ resolved.projection !== 'albers-usa' &&
1459
+ resolved.regions.length > 0
1460
+ ) {
1461
+ let yMin = Infinity;
1462
+ let yMax = -Infinity;
1463
+ for (const r of resolved.regions) {
1464
+ const bb = r.iso ? featureBboxPrimary(data.worldCoarse, r.iso) : null;
1465
+ if (!bb) continue;
1466
+ for (const lon of [bb[0][0], bb[1][0]]) {
1467
+ for (const lat of [bb[0][1], bb[1][1]]) {
1468
+ const p = projection([lon, lat]);
1469
+ if (p && Number.isFinite(p[1])) {
1470
+ if (p[1] < yMin) yMin = p[1];
1471
+ if (p[1] > yMax) yMax = p[1];
1472
+ }
1473
+ }
1474
+ }
1475
+ }
1476
+ if (yMin < yMax) {
1477
+ const boxTop = fitTop;
1478
+ const boxBottom = fitBottom;
1479
+ // Center the data's vertical span; the bbox midpoint balances the northern
1480
+ // and southern extremes evenly (an area-weighted centroid would skew toward
1481
+ // the larger landmasses and over-shoot the frame).
1482
+ let dy = (boxTop + boxBottom) / 2 - (yMin + yMax) / 2;
1483
+ // Clamp so the data span stays within [boxTop, boxBottom]; if it is taller
1484
+ // than the box, the midpoint target already gives symmetric overflow.
1485
+ const minDy = boxTop - yMin;
1486
+ const maxDy = boxBottom - yMax;
1487
+ if (minDy <= maxDy) dy = Math.max(minDy, Math.min(maxDy, dy));
1488
+ const [tx, ty] = projection.translate();
1489
+ projection.translate([tx, ty + dy]);
1490
+ }
1491
+ }
1492
+
1240
1493
  // Global views stretch-fill the canvas. A whole-world map is ~2:1 but the
1241
1494
  // preview pane is often near-square, so the honest contain-fit letterboxes it
1242
1495
  // with large water bands. For GLOBAL extents we stretch the PROJECTED geometry
@@ -1292,12 +1545,15 @@ export function layoutMap(
1292
1545
  ).stream.point(px, py);
1293
1546
  },
1294
1547
  });
1548
+ const thin = geoThin();
1295
1549
  path = geoPath({
1296
1550
  stream: (s: never) =>
1297
1551
  baseProjection.stream(
1298
- (tx as unknown as { stream: (d: never) => never }).stream(s)
1552
+ (tx as unknown as { stream: (d: never) => never }).stream(
1553
+ (thin as unknown as { stream: (d: never) => never }).stream(s)
1554
+ )
1299
1555
  ),
1300
- } as never);
1556
+ } as never).digits(PATH_DIGITS);
1301
1557
  project = (lon, lat) => {
1302
1558
  const p = baseProjection([lon, lat]);
1303
1559
  return p ? stretch(p[0], p[1]) : null;
@@ -1316,7 +1572,13 @@ export function layoutMap(
1316
1572
  [0, 0],
1317
1573
  [width, height],
1318
1574
  ]);
1319
- path = geoPath(projection);
1575
+ const thin = geoThin();
1576
+ path = geoPath({
1577
+ stream: (s: never) =>
1578
+ projection.stream(
1579
+ (thin as unknown as { stream: (d: never) => never }).stream(s)
1580
+ ),
1581
+ } as never).digits(PATH_DIGITS);
1320
1582
  project = (lon, lat) => projection([lon, lat]) ?? null;
1321
1583
  }
1322
1584
 
@@ -1431,7 +1693,8 @@ export function layoutMap(
1431
1693
  ],
1432
1694
  f as never
1433
1695
  );
1434
- const d = geoPath(proj)(f as never) ?? '';
1696
+ const insetPath = geoPath(proj).digits(PATH_DIGITS);
1697
+ const d = insetPath(f as never) ?? '';
1435
1698
  if (!d) return xr;
1436
1699
  // Neighbour land projected with this same fitted projection, clipped to the
1437
1700
  // box. Alaska's only land neighbour is Canada; drawing it behind AK turns
@@ -1440,7 +1703,7 @@ export function layoutMap(
1440
1703
  let contextLand: { d: string; fill: string } | undefined;
1441
1704
  if (iso === 'US-AK') {
1442
1705
  const can = worldLayer.get('CA');
1443
- const cd = can ? (geoPath(proj)(can as never) ?? '') : '';
1706
+ const cd = can ? (insetPath(can as never) ?? '') : '';
1444
1707
  if (cd)
1445
1708
  contextLand = {
1446
1709
  d: cd,
@@ -2003,6 +2266,13 @@ export function layoutMap(
2003
2266
  : 1;
2004
2267
  return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
2005
2268
  };
2269
+ // Fade the fill as the bubble grows (stroke handled separately at render).
2270
+ const fillOpacityFor = (r: number): number => {
2271
+ const t = Math.max(0, Math.min(1, (r - R_MIN) / (R_MAX - R_MIN)));
2272
+ return (
2273
+ POI_FILL_OPACITY_MAX - t * (POI_FILL_OPACITY_MAX - POI_FILL_OPACITY_MIN)
2274
+ );
2275
+ };
2006
2276
 
2007
2277
  // POI fill precedence (§24B.5): a direct §1.5 trailing color wins, then the
2008
2278
  // FIRST declared tag group for which the POI has a value (AR4), then orange.
@@ -2028,6 +2298,21 @@ export function layoutMap(
2028
2298
  };
2029
2299
  };
2030
2300
 
2301
+ // Connector colour (§24B.6): a tag on the edge/leg LINE colours the line. Walk
2302
+ // the declared tag groups (first match wins, like poiFill) and return its hex,
2303
+ // or null → caller falls back to the neutral connector mix.
2304
+ const lineColor = (tags: Readonly<Record<string, string>>): string | null => {
2305
+ for (const group of resolved.tagGroups) {
2306
+ const val = tags[group.name.toLowerCase()];
2307
+ if (!val) continue;
2308
+ const entry = group.entries.find(
2309
+ (e) => e.value.toLowerCase() === val.toLowerCase()
2310
+ );
2311
+ if (entry?.color) return entry.color; // already hex (parser-resolved)
2312
+ }
2313
+ return null;
2314
+ };
2315
+
2031
2316
  // Route metadata first so POIs know origin/number.
2032
2317
  const routeNumberById = new Map<string, number>();
2033
2318
  const originIds = new Set<string>();
@@ -2060,14 +2345,16 @@ export function layoutMap(
2060
2345
  clusterId?: string
2061
2346
  ): void => {
2062
2347
  const { fill, stroke } = poiFill(e.p);
2063
- poiScreen.set(e.p.id, { cx, cy, r: radiusFor(e.p) });
2348
+ const r = radiusFor(e.p);
2349
+ poiScreen.set(e.p.id, { cx, cy, r });
2064
2350
  const num = routeNumberById.get(e.p.id);
2065
2351
  pois.push({
2066
2352
  id: e.p.id,
2067
2353
  cx,
2068
2354
  cy,
2069
- r: radiusFor(e.p),
2355
+ r,
2070
2356
  fill,
2357
+ fillOpacity: fillOpacityFor(r),
2071
2358
  stroke,
2072
2359
  lineNumber: e.p.lineNumber,
2073
2360
  implicit: !!e.p.implicit,
@@ -2264,8 +2551,11 @@ export function layoutMap(
2264
2551
  legs.push({
2265
2552
  d: legPath(a, b, bow.curved, bow.offset),
2266
2553
  width: routeWidthFor(Number(leg.value)),
2267
- color: mix(palette.text, palette.bg, 72),
2554
+ color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
2268
2555
  arrow: true,
2556
+ fromId: leg.fromId,
2557
+ toId: leg.toId,
2558
+ ...(Object.keys(leg.tags).length > 0 && { tags: leg.tags }),
2269
2559
  lineNumber: leg.lineNumber,
2270
2560
  ...(leg.label !== undefined && {
2271
2561
  label: leg.label,
@@ -2321,8 +2611,11 @@ export function layoutMap(
2321
2611
  legs.push({
2322
2612
  d: legPath(a, b, bow.curved, bow.offset),
2323
2613
  width: widthFor(e),
2324
- color: mix(palette.text, palette.bg, 66),
2614
+ color: lineColor(e.tags) ?? mix(palette.text, palette.bg, 66),
2325
2615
  arrow: e.directed,
2616
+ fromId: e.fromId,
2617
+ toId: e.toId,
2618
+ ...(Object.keys(e.tags).length > 0 && { tags: e.tags }),
2326
2619
  lineNumber: e.lineNumber,
2327
2620
  ...(e.label !== undefined && {
2328
2621
  label: e.label,
@@ -2339,6 +2632,11 @@ export function layoutMap(
2339
2632
  // -- Labels: regions + POIs with escalation (AR5) --
2340
2633
  const labels: PlacedLabel[] = [];
2341
2634
  const obstacles: LabelRect[] = [];
2635
+ // Region/orientation labels are the frame; POI labels are the subject. The
2636
+ // region pass runs first (can't yet see where POI labels land), so each region
2637
+ // label registers a guard here; after POI placement any guard a POI label
2638
+ // overlaps yields — the region label is removed rather than crammed.
2639
+ const regionLabelGuards: Array<{ label: PlacedLabel; rect: LabelRect }> = [];
2342
2640
  const markers: PointCircle[] = pois.map((p) => ({
2343
2641
  cx: p.cx,
2344
2642
  cy: p.cy,
@@ -2392,18 +2690,91 @@ export function layoutMap(
2392
2690
  // ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
2393
2691
  // FIRST for US states.
2394
2692
  const showRegionLabels = resolved.directives.noRegionLabels !== true;
2693
+ // Metric value shown UNDER each data region's name (`no-region-value` opts out).
2694
+ // The value line is rendered smaller + dimmer than the name; see the renderer.
2695
+ // Scoped to a `region-metric` choropleth: only when the SCORE ramp is the active
2696
+ // colouring dimension (not a tag-coloured / categorical map) is the numeric
2697
+ // value the data on display, so that's the only case it's surfaced.
2698
+ const showRegionValues =
2699
+ resolved.directives.noRegionValue !== true && activeIsScore;
2700
+ // Compact value string for a region, or undefined when there's nothing to show
2701
+ // (no value, or the feature is off). Shared formatter so it matches the legend.
2702
+ const regionValueStr = (value: number | undefined): string | undefined =>
2703
+ showRegionValues && value !== undefined ? compactNumber(value) : undefined;
2395
2704
  const isCompact = width < COMPACT_WIDTH_PX;
2705
+ // Zoomed sub-national US choropleth (map-us-subnational-zoom): a US-states
2706
+ // mercator view with the score ramp active. Here a cramped state (NH, RI, CT,
2707
+ // NJ, DE) should NOT degrade to its 2-letter abbreviation — the user reads the
2708
+ // abbreviation poorly and a stray hover-name then steps on it. Instead it keeps
2709
+ // its FULL name and, if that won't fit in place, takes a leader-lined margin
2710
+ // callout (full name + value). Only a handful of states are in frame at this
2711
+ // zoom, so the callout column stays short. National (albers) maps keep the
2712
+ // abbreviation cascade — 50 full-name callouts would be unreadable.
2713
+ const usChoroplethZoom =
2714
+ resolved.projection === 'mercator' &&
2715
+ resolved.basemaps.subdivisions.includes('us-states') &&
2716
+ activeIsScore;
2396
2717
  const LABEL_PADX = 6;
2397
2718
  const LABEL_PADY = 3;
2398
- const labelW = (text: string): number =>
2399
- measureLegendText(text, FONT) + 2 * LABEL_PADX;
2719
+ // The value line is ~0.82× the name size; a hair of vertical gap separates them.
2720
+ const VALUE_FONT = Math.round(FONT * 0.82);
2721
+ const VALUE_GAP = 1;
2722
+ const labelW = (text: string, font: number = FONT): number =>
2723
+ measureLegendText(text, font) + 2 * LABEL_PADX;
2400
2724
  const labelH = FONT + 2 * LABEL_PADY;
2725
+ // Footprint of a name (+optional value) stack used for the box-fit cascade.
2726
+ // `font` defaults to the base size (every existing call is byte-identical);
2727
+ // the post-placement growth pass passes a larger size to test an upscaled fit.
2728
+ const stackW = (
2729
+ text: string,
2730
+ valueText?: string,
2731
+ font: number = FONT
2732
+ ): number =>
2733
+ Math.max(
2734
+ labelW(text, font),
2735
+ valueText
2736
+ ? measureLegendText(valueText, Math.round(font * 0.82)) + 2 * LABEL_PADX
2737
+ : 0
2738
+ );
2739
+ const stackH = (hasValue: boolean, font: number = FONT): number => {
2740
+ const lh = font + 2 * LABEL_PADY;
2741
+ return hasValue ? lh + VALUE_GAP + Math.round(font * 0.82) : lh;
2742
+ };
2743
+ // Footprint-driven label growth (size-up + fade), gradual + resolution-free.
2744
+ // Applies to ORIENTATION backdrop names ONLY (neighbour land / frame
2745
+ // containers with no data value): a big one reads as a large, gently-faded
2746
+ // backdrop, a small one stays at the base font. DATA labels are deliberately
2747
+ // EXCLUDED — fading a choropleth value washes it lighter than its own fill and
2748
+ // a loose bbox overran irregular regions. Size scales with the region's
2749
+ // projected footprint as a fraction of the canvas's linear extent. Growth runs
2750
+ // AFTER the base-font fit cascade picks the text+anchor, and only while the
2751
+ // larger glyphs still fit the box, clear neighbours/POIs, and stay inside the
2752
+ // region's own fill.
2753
+ const REGION_FONT_MAX_ORIENT = 22; // px ceiling, backdrop names
2754
+ const REGION_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
2755
+ const REGION_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
2756
+ const REGION_FADE_ORIENT = 45; // % toward bg at max size, backdrop
2757
+ const canvasLinear = Math.sqrt(Math.max(1, width * height));
2758
+ const sizeT = (boxW: number, boxH: number): number => {
2759
+ const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
2760
+ return Math.min(
2761
+ 1,
2762
+ Math.max(
2763
+ 0,
2764
+ (frac - REGION_SIZE_FRAC_MIN) /
2765
+ (REGION_SIZE_FRAC_MAX - REGION_SIZE_FRAC_MIN)
2766
+ )
2767
+ );
2768
+ };
2401
2769
  const pushRegionLabel = (
2402
2770
  x: number,
2403
2771
  y: number,
2404
2772
  text: string,
2405
2773
  fill: string,
2406
- lineNumber: number
2774
+ lineNumber: number,
2775
+ valueLine?: string,
2776
+ fontSize: number = FONT,
2777
+ fade: number = 0
2407
2778
  ): void => {
2408
2779
  // Colour is contrast-picked against the region's own fill (see labelOnFill).
2409
2780
  // The halo, though, is gated by CONTAINMENT — not fill tone. A label that
@@ -2415,9 +2786,20 @@ export function layoutMap(
2415
2786
  // to stay legible. Sample the label's screen footprint against the drawn
2416
2787
  // fills: if any extreme lands on a fill other than the region's own, the
2417
2788
  // label overflows and earns a halo.
2418
- const { color, haloColor } = labelOnFill(fill);
2419
- const halfW = measureLegendText(text, FONT) / 2;
2420
- const overflows = [y - FONT * 0.55, y - FONT * 0.1].some(
2789
+ const { color: baseColor, haloColor } = labelOnFill(fill);
2790
+ // Subdue a grown label toward the background — gentle on data (value stays
2791
+ // readable), stronger on orientation backdrop. Zero fade exact base color.
2792
+ const color = fade > 0 ? mix(baseColor, palette.bg, fade) : baseColor;
2793
+ // Widest of name / value drives the overflow sample (the value line can be
2794
+ // the wider of the two, e.g. a short name over a long number). Scales with
2795
+ // the actual (possibly grown) font so the halo gate matches what's drawn.
2796
+ const vf = Math.round(fontSize * 0.82);
2797
+ const halfW =
2798
+ Math.max(
2799
+ measureLegendText(text, fontSize),
2800
+ valueLine ? measureLegendText(valueLine, vf) : 0
2801
+ ) / 2;
2802
+ const overflows = [y - fontSize * 0.55, y - fontSize * 0.1].some(
2421
2803
  (sy) => fillAt(x - halfW, sy) !== fill || fillAt(x + halfW, sy) !== fill
2422
2804
  );
2423
2805
  labels.push({
@@ -2428,15 +2810,31 @@ export function layoutMap(
2428
2810
  color,
2429
2811
  halo: overflows,
2430
2812
  haloColor,
2813
+ ...(fontSize !== FONT && { fontSize }),
2814
+ ...(valueLine !== undefined && { valueLine }),
2431
2815
  lineNumber,
2432
2816
  });
2433
2817
  };
2434
2818
  // A region label's screen footprint, middle-anchored on its centroid, used to
2435
2819
  // keep two region labels from overlapping (a small gap adds breathing room).
2820
+ // With a value line the box grows to the taller two-line stack.
2436
2821
  const REGION_LABEL_GAP = 2;
2437
- const regionLabelRect = (cx: number, cy: number, text: string): LabelRect => {
2438
- const w = measureLegendText(text, FONT) + 2 * REGION_LABEL_GAP;
2439
- return { x: cx - w / 2, y: cy - FONT / 2, w, h: FONT };
2822
+ const regionLabelRect = (
2823
+ cx: number,
2824
+ cy: number,
2825
+ text: string,
2826
+ valueText?: string,
2827
+ font: number = FONT
2828
+ ): LabelRect => {
2829
+ const vf = Math.round(font * 0.82);
2830
+ const w =
2831
+ Math.max(
2832
+ measureLegendText(text, font),
2833
+ valueText ? measureLegendText(valueText, vf) : 0
2834
+ ) +
2835
+ 2 * REGION_LABEL_GAP;
2836
+ const h = valueText ? font + VALUE_GAP + vf : font;
2837
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
2440
2838
  };
2441
2839
  if (showRegionLabels) {
2442
2840
  // Gather the placeable region labels, then commit them largest-footprint
@@ -2463,13 +2861,23 @@ export function layoutMap(
2463
2861
  const boxW = x1 - x0;
2464
2862
  const boxH = y1 - y0;
2465
2863
  // full → abbrev → hide. Abbrev exists only for US states; at the compact
2466
- // breakpoint abbrev is tried first.
2467
- const abbrev = isUsState ? r.id.replace(/^US-/, '') : undefined;
2864
+ // breakpoint abbrev is tried first. A POI-frame CONTAINER (e.g. the
2865
+ // "California" framing a US cloud-regions map) never degrades to the
2866
+ // 2-letter code to squeeze past its own POIs — it stays full or yields
2867
+ // entirely (the post-POI guard below hides it on collision).
2868
+ // On a zoomed US choropleth, drop the abbreviation entirely (full name or
2869
+ // a leader callout — never "NH"). Elsewhere the full → abbrev → hide
2870
+ // cascade stands (compact tries abbrev first; a POI container never
2871
+ // abbreviates).
2872
+ const abbrev =
2873
+ isUsState && !usChoroplethZoom ? r.id.replace(/^US-/, '') : undefined;
2468
2874
  const candidates =
2469
2875
  abbrev !== undefined
2470
2876
  ? isCompact
2471
2877
  ? [abbrev, r.label]
2472
- : [r.label, abbrev]
2878
+ : isContainer
2879
+ ? [r.label]
2880
+ : [r.label, abbrev]
2473
2881
  : [r.label];
2474
2882
  const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : undefined;
2475
2883
  const c = anchor
@@ -2481,6 +2889,18 @@ export function layoutMap(
2481
2889
  .filter((e): e is NonNullable<typeof e> => e !== null)
2482
2890
  .sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
2483
2891
  const placedRegionRects: LabelRect[] = [];
2892
+ // Valued regions too small to carry their name+value stack in place — gathered
2893
+ // here and laid out as a margin callout column after the in-place pass.
2894
+ const regionCallouts: Array<{
2895
+ name: string;
2896
+ value: string;
2897
+ cx: number;
2898
+ cy: number;
2899
+ bw: number;
2900
+ bh: number;
2901
+ fill: string;
2902
+ lineNumber: number;
2903
+ }> = [];
2484
2904
  // POI markers are obstacles for region labels: a region whose centroid sits on
2485
2905
  // a POI (e.g. Colorado's centroid under the "Core POP" dot in Denver) must NOT
2486
2906
  // stamp its name there — the POI's own label owns that spot, and two names by
@@ -2496,35 +2916,394 @@ export function layoutMap(
2496
2916
  w: 2 * (p.r + POI_LABEL_PAD),
2497
2917
  h: 2 * (p.r + POI_LABEL_PAD),
2498
2918
  }));
2919
+ // Ocean side of the frame (zoomed US choropleth callouts column there). Sample
2920
+ // a vertical strip just inside each side edge; the side with more open water
2921
+ // hosts the callout column, so leaders run over sea, not across the interior.
2922
+ const waterSideOf = (): 'left' | 'right' => {
2923
+ let leftHits = 0;
2924
+ let rightHits = 0;
2925
+ const lx = width * 0.06;
2926
+ const rx = width * 0.94;
2927
+ for (let i = 1; i < 12; i++) {
2928
+ const y = topPad + ((height - topPad) * i) / 12;
2929
+ if (fillAt(lx, y) === water) leftHits++;
2930
+ if (fillAt(rx, y) === water) rightHits++;
2931
+ }
2932
+ return rightHits >= leftHits ? 'right' : 'left';
2933
+ };
2934
+ const calloutSide = usChoroplethZoom ? waterSideOf() : undefined;
2499
2935
  for (const { r, c, boxW, boxH, candidates } of entries) {
2936
+ const valStr = regionValueStr(r.value);
2937
+ // A region hugs a canvas edge if it sits within a short leader's reach of
2938
+ // it — only such a region may use a margin callout column, so the leader is
2939
+ // always SHORT (no cross-map lines for a centred region).
2940
+ const maxLeader = Math.min(width * 0.26, 300);
2941
+ // "Near an edge" is measured against the LAND-facing edge of each reserved
2942
+ // band when a reserve is active (second pass) — the map has shrunk away from
2943
+ // that side, so the cluster now sits at the band's inner edge, not the raw
2944
+ // canvas edge. Without a reserve (first pass) this is just the canvas edge.
2945
+ const rsv = opts._calloutReserve;
2946
+ const rEdge = rsv?.right ? width - rsv.right : width;
2947
+ const lEdge = rsv?.left ?? 0;
2948
+ // On a zoomed US choropleth a cramped state always takes a margin callout (a
2949
+ // full-name + value chip in the ocean-side column, leader from its centroid)
2950
+ // rather than degrading to an abbreviation — only a handful of states are in
2951
+ // frame, so the column stays short. Otherwise a callout is reserved for
2952
+ // edge-hugging regions so no leader runs across a wide view.
2953
+ const nearEdge =
2954
+ usChoroplethZoom ||
2955
+ c[0] >= rEdge - maxLeader ||
2956
+ c[0] <= lEdge + maxLeader;
2957
+ // A tiny region hugging a canvas edge — one whose FULL name won't fit its
2958
+ // own box (RI/CT/NH/MA on a US map) — goes straight to a clean margin
2959
+ // column: a tidy full-name list reads far better than crammed 2-letter
2960
+ // abbreviations piled on the cluster, and the edge keeps the leader short. A
2961
+ // region whose full name DOES fit labels in place as usual; an interior tiny
2962
+ // region (a centred world-map country) is handled by the on-land overflow
2963
+ // below — never a long cross-map leader.
2964
+ if (
2965
+ valStr &&
2966
+ nearEdge &&
2967
+ r.label !== undefined &&
2968
+ (labelW(r.label) > boxW || labelH > boxH)
2969
+ ) {
2970
+ regionCallouts.push({
2971
+ name: r.label,
2972
+ value: valStr,
2973
+ cx: c[0],
2974
+ cy: c[1],
2975
+ bw: boxW,
2976
+ bh: boxH,
2977
+ fill: r.fill,
2978
+ lineNumber: r.lineNumber,
2979
+ });
2980
+ continue;
2981
+ }
2500
2982
  // The first candidate that BOTH fits its own footprint AND clears every
2501
2983
  // already-placed region label AND every POI marker wins; none qualifies →
2502
2984
  // the label is hidden (a country has no abbrev, so it degrades full → hide;
2503
2985
  // a US state may fall back to its 2-letter code before hiding).
2504
- const text = candidates.find((t) => {
2505
- if (labelW(t) > boxW || labelH > boxH) return false;
2506
- const rect = regionLabelRect(c[0], c[1], t);
2507
- return (
2508
- !placedRegionRects.some((p) => rectsOverlap(rect, p)) &&
2509
- !poiObstacles.some((o) => rectsOverlap(rect, o))
2986
+ // When the region carries a metric value, the name+value STACK is tried
2987
+ // first; if the stack won't fit (a smaller state), it degrades to the bare
2988
+ // name (today's behaviour) so adding values never costs an existing label.
2989
+ //
2990
+ // Two collision tests, deliberately different footprints:
2991
+ // - vs other REGION labels: use the FULL stack rect (two stacks must not
2992
+ // overlap).
2993
+ // - vs POI obstacles: use only the NAME rect. A POI obstacle exists to keep
2994
+ // the region NAME off a POI's dot/label; the (shorter, dimmer) value line
2995
+ // hanging below a name that already clears the dot is fine. Testing the
2996
+ // taller stack here made a region with a nearby POI (Texas under the big
2997
+ // Dallas marker) silently drop its value even though the name fit.
2998
+ const fitsRegions = (rect: LabelRect): boolean =>
2999
+ !placedRegionRects.some((p) => rectsOverlap(rect, p));
3000
+ const fitsPois = (rect: LabelRect): boolean =>
3001
+ !poiObstacles.some((o) => rectsOverlap(rect, o));
3002
+ // Try the centroid first (existing placement — unchanged when it fits),
3003
+ // then a ring of offsets WITHIN the region's box so a label blocked at the
3004
+ // centroid (typically a POI marker sitting on it — Dallas on Texas) is
3005
+ // re-seated on open land of the SAME region rather than exiled to a far
3006
+ // callout column. Off-centroid anchors are kept on the region's own fill
3007
+ // (fillAt) so the label never drifts onto a neighbour or the sea.
3008
+ // Centroid is always tried. The off-centroid re-seat ring is added ONLY for
3009
+ // a region that carries a value — the point of seeking is to not lose the
3010
+ // region's VALUE to a POI on its centroid. A valueless frame container
3011
+ // (e.g. the state hosting a POI hub) keeps the old behaviour: it yields the
3012
+ // spot to the POI rather than sprouting a re-seated name near the hub.
3013
+ const seekAnchors: Array<{ x: number; y: number; guard: boolean }> = [
3014
+ { x: c[0], y: c[1], guard: false },
3015
+ ];
3016
+ if (valStr) {
3017
+ seekAnchors.push(
3018
+ { x: c[0], y: c[1] + boxH * 0.26, guard: true },
3019
+ { x: c[0], y: c[1] - boxH * 0.26, guard: true },
3020
+ { x: c[0] + boxW * 0.26, y: c[1], guard: true },
3021
+ { x: c[0] - boxW * 0.26, y: c[1], guard: true },
3022
+ { x: c[0] + boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
3023
+ { x: c[0] - boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
3024
+ { x: c[0] + boxW * 0.22, y: c[1] - boxH * 0.22, guard: true },
3025
+ { x: c[0] - boxW * 0.22, y: c[1] - boxH * 0.22, guard: true }
2510
3026
  );
3027
+ }
3028
+ let chosen:
3029
+ | { text: string; valueLine?: string; ax: number; ay: number }
3030
+ | undefined;
3031
+ for (const a of seekAnchors) {
3032
+ if (a.guard && fillAt(a.x, a.y) !== r.fill) continue;
3033
+ for (const t of candidates) {
3034
+ const nameRect = regionLabelRect(a.x, a.y, t);
3035
+ if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
3036
+ const stackRect = regionLabelRect(a.x, a.y, t, valStr);
3037
+ if (fitsRegions(stackRect) && fitsPois(nameRect)) {
3038
+ chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
3039
+ break;
3040
+ }
3041
+ }
3042
+ if (labelW(t) <= boxW && labelH <= boxH) {
3043
+ if (fitsRegions(nameRect) && fitsPois(nameRect)) {
3044
+ chosen = { text: t, ax: a.x, ay: a.y };
3045
+ break;
3046
+ }
3047
+ }
3048
+ }
3049
+ if (chosen) break;
3050
+ }
3051
+ if (chosen === undefined && valStr) {
3052
+ // A VALUED region not placed in-box, and not an edge-hugging tiny region
3053
+ // (those columned above). Label it ON its own land, letting the name
3054
+ // OVERFLOW its small box onto neighbours/ocean (the halo keeps it legible),
3055
+ // as long as it clears already-placed labels + POIs. This keeps a country
3056
+ // on a world choropleth (Germany, France) labelled in place instead of
3057
+ // exiled to a far margin. If even that collides, the label simply drops —
3058
+ // never a long cross-map leader. Gated to valued regions so a valueless
3059
+ // POI-frame container keeps its old behaviour (yield rather than overflow).
3060
+ for (const a of seekAnchors) {
3061
+ if (fillAt(a.x, a.y) !== r.fill) continue;
3062
+ for (const t of candidates) {
3063
+ const nameRect = regionLabelRect(a.x, a.y, t);
3064
+ if (
3065
+ valStr &&
3066
+ fitsRegions(regionLabelRect(a.x, a.y, t, valStr)) &&
3067
+ fitsPois(nameRect)
3068
+ ) {
3069
+ chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
3070
+ break;
3071
+ }
3072
+ if (fitsRegions(nameRect) && fitsPois(nameRect)) {
3073
+ chosen = { text: t, ax: a.x, ay: a.y };
3074
+ break;
3075
+ }
3076
+ }
3077
+ if (chosen) break;
3078
+ }
3079
+ }
3080
+ // Nothing placed (a valueless region that didn't fit, or a valued region
3081
+ // whose overflow also collided) → drop, leaving the map clean.
3082
+ if (chosen === undefined) continue;
3083
+ // Footprint-driven growth applies ONLY to orientation backdrop names — a
3084
+ // data-less neighbour/frame region (Canada framing a POI, foreign land).
3085
+ // DATA labels (a choropleth value) keep the base font + full contrast and
3086
+ // the existing fit-inside cascade UNCHANGED: fading a value washed it
3087
+ // lighter than its own region fill, and a loose bbox let a wide name
3088
+ // ("United States of America") spill past its region. Orientation names sit
3089
+ // on neutral basemap land where a larger, gently-faded backdrop reads well.
3090
+ const isOrient = r.value === undefined && r.layer === 'base';
3091
+ let font = FONT;
3092
+ let fade = 0;
3093
+ if (isOrient) {
3094
+ const growT = sizeT(boxW, boxH);
3095
+ const desiredFont = Math.round(
3096
+ FONT + growT * (REGION_FONT_MAX_ORIENT - FONT)
3097
+ );
3098
+ const hasVal = chosen.valueLine !== undefined;
3099
+ for (let f = desiredFont; f > FONT; f--) {
3100
+ // Fit the footprint box, clear neighbours/POIs, AND — the real guard —
3101
+ // stay INSIDE the region's own fill at the bigger size. The bbox is far
3102
+ // too loose for an irregular shape (Alaska blows up the US bbox), so
3103
+ // sample the grown name's horizontal extremes against `fillAt`: if
3104
+ // either leaves this region's fill, don't grow that far.
3105
+ if (
3106
+ stackW(chosen.text, chosen.valueLine, f) > boxW ||
3107
+ stackH(hasVal, f) > boxH
3108
+ )
3109
+ continue;
3110
+ const gRect = regionLabelRect(
3111
+ chosen.ax,
3112
+ chosen.ay,
3113
+ chosen.text,
3114
+ chosen.valueLine,
3115
+ f
3116
+ );
3117
+ const gName = regionLabelRect(
3118
+ chosen.ax,
3119
+ chosen.ay,
3120
+ chosen.text,
3121
+ undefined,
3122
+ f
3123
+ );
3124
+ if (!fitsRegions(gRect) || !fitsPois(gName)) continue;
3125
+ const halfW = measureLegendText(chosen.text, f) / 2;
3126
+ if (
3127
+ fillAt(chosen.ax - halfW, chosen.ay) !== r.fill ||
3128
+ fillAt(chosen.ax + halfW, chosen.ay) !== r.fill
3129
+ )
3130
+ continue;
3131
+ font = f;
3132
+ break;
3133
+ }
3134
+ fade = font > FONT ? Math.round(growT * REGION_FADE_ORIENT) : 0;
3135
+ }
3136
+ const rRect = regionLabelRect(
3137
+ chosen.ax,
3138
+ chosen.ay,
3139
+ chosen.text,
3140
+ chosen.valueLine,
3141
+ font
3142
+ );
3143
+ placedRegionRects.push(rRect);
3144
+ pushRegionLabel(
3145
+ chosen.ax,
3146
+ chosen.ay,
3147
+ chosen.text,
3148
+ r.fill,
3149
+ r.lineNumber,
3150
+ chosen.valueLine,
3151
+ font,
3152
+ fade
3153
+ );
3154
+ // Guard so a POI label landing here later makes this label yield (below).
3155
+ regionLabelGuards.push({
3156
+ label: labels[labels.length - 1]!,
3157
+ rect: rRect,
2511
3158
  });
2512
- if (text === undefined) continue;
2513
- placedRegionRects.push(regionLabelRect(c[0], c[1], text));
2514
- pushRegionLabel(c[0], c[1], text, r.fill, r.lineNumber);
2515
3159
  }
2516
3160
  // AK/HI labels live in their insets (own projection centroids). Insets are
2517
3161
  // tiny, so prefer the abbreviation when the canvas is compact.
2518
3162
  for (const seed of insetLabelSeeds) {
2519
3163
  const text = isCompact ? seed.iso.replace(/^US-/, '') : seed.name;
2520
3164
  const src = regionById.get(seed.iso);
3165
+ const valStr = regionValueStr(src?.value);
2521
3166
  pushRegionLabel(
2522
3167
  seed.x,
2523
3168
  seed.y,
2524
3169
  text,
2525
3170
  src ? regionFill(src) : neutralFill,
2526
- seed.lineNumber
3171
+ seed.lineNumber,
3172
+ valStr
2527
3173
  );
3174
+ regionLabelGuards.push({
3175
+ label: labels[labels.length - 1]!,
3176
+ rect: regionLabelRect(seed.x, seed.y, text, valStr),
3177
+ });
3178
+ }
3179
+
3180
+ // Zoom-out reserve (first pass → re-run): tiny valued regions need margin
3181
+ // callouts, and the map currently fills the canvas with no room for them.
3182
+ // Measure them, reserve a band on the side the cluster leans toward, and
3183
+ // re-run the whole layout fitted into the canvas MINUS that band — the map
3184
+ // shrinks/shifts away from that edge and the callouts get real room. Guarded
3185
+ // by `_calloutReserve` so it recurses exactly once.
3186
+ if (regionCallouts.length > 0 && !opts._calloutReserve) {
3187
+ // Split the callouts by the side of the canvas they fall on — a cluster on
3188
+ // each coast gets its own reserved band + column. Band = widest chip in the
3189
+ // group + a leader run + edge padding, clamped so one stray callout never
3190
+ // over-shrinks the map nor a long name starves it.
3191
+ const bandFor = (group: typeof regionCallouts): number | undefined => {
3192
+ if (group.length === 0) return undefined;
3193
+ const maxChipW = group.reduce(
3194
+ (m, rc) =>
3195
+ Math.max(
3196
+ m,
3197
+ measureLegendText(rc.name, FONT),
3198
+ measureLegendText(rc.value, VALUE_FONT)
3199
+ ),
3200
+ 0
3201
+ );
3202
+ return Math.max(130, Math.min(maxChipW + 96, Math.floor(width * 0.3)));
3203
+ };
3204
+ // On a zoomed US choropleth all callouts share the ocean-side column (leaders
3205
+ // over sea, never across the interior); elsewhere split by the side each
3206
+ // region leans toward.
3207
+ const right =
3208
+ calloutSide === 'right'
3209
+ ? regionCallouts
3210
+ : calloutSide === 'left'
3211
+ ? []
3212
+ : regionCallouts.filter((rc) => rc.cx >= width / 2);
3213
+ const left =
3214
+ calloutSide === 'left'
3215
+ ? regionCallouts
3216
+ : calloutSide === 'right'
3217
+ ? []
3218
+ : regionCallouts.filter((rc) => rc.cx < width / 2);
3219
+ const leftPx = bandFor(left);
3220
+ const rightPx = bandFor(right);
3221
+ return layoutMap(resolved, data, size, {
3222
+ ...opts,
3223
+ _calloutReserve: {
3224
+ ...(leftPx !== undefined && { left: leftPx }),
3225
+ ...(rightPx !== undefined && { right: rightPx }),
3226
+ },
3227
+ });
3228
+ }
3229
+
3230
+ // ── Radial callouts for valued regions too small to label in place ──
3231
+ // Each gathered region gets a leader-lined chip (its name over the metric
3232
+ // value, same stack as an in-place label) placed in the OPEN SPACE around the
3233
+ // cluster: the chip marches OUTWARD from the cluster centre along its own
3234
+ // angle (so a dense cluster fans its labels out in all directions — east into
3235
+ // the ocean, north over Canada, etc.) until it clears the data regions, the
3236
+ // in-place labels, and the other chips. A small dot marks the region's true
3237
+ // centroid; the leader runs dot → chip. Chips may overlay unvalued base land
3238
+ // (e.g. Canada) but never a VALUED region's fill (keep the choropleth clean).
3239
+ if (regionCallouts.length > 0) {
3240
+ // Tidy callout column(s) in the reserved margin(s) the zoom-out pass opened.
3241
+ // Each chip is a name+value stack anchored just inside the band; a leader
3242
+ // runs from the region's centroid dot to the chip's inner edge. Rows are
3243
+ // ordered top→bottom by screen latitude so the column reads geographically
3244
+ // and the leaders stay short and roughly parallel. A cluster on each side of
3245
+ // the canvas gets its own column in its own reserved band.
3246
+ const reserveInfo = opts._calloutReserve;
3247
+ const EDGE = 28;
3248
+ const COL_GAP = 16; // chip inset from the land-facing edge of the band
3249
+ const chipH = FONT + VALUE_GAP + VALUE_FONT;
3250
+ const ROW = chipH + 10;
3251
+ const placeColumn = (
3252
+ group: typeof regionCallouts,
3253
+ side: 'left' | 'right',
3254
+ bandPx: number
3255
+ ): void => {
3256
+ if (group.length === 0) return;
3257
+ const anchor: PlacedLabel['anchor'] =
3258
+ side === 'right' ? 'start' : 'end';
3259
+ const colX =
3260
+ side === 'right' ? width - bandPx + COL_GAP : bandPx - COL_GAP;
3261
+ const rows = [...group].sort((a, b) => a.cy - b.cy);
3262
+ const meanCy = rows.reduce((s, rc) => s + rc.cy, 0) / rows.length;
3263
+ const totalH = rows.length * ROW;
3264
+ const minTop = topPad + 6 + ROW / 2;
3265
+ const maxTop = Math.max(minTop, height - EDGE - totalH + ROW / 2);
3266
+ const startY = Math.max(
3267
+ minTop,
3268
+ Math.min(meanCy - totalH / 2 + ROW / 2, maxTop)
3269
+ );
3270
+ rows.forEach((rc, i) => {
3271
+ const ry = startY + i * ROW;
3272
+ const innerX = side === 'right' ? colX - 4 : colX + 4;
3273
+ // Darken the region's hue toward the text colour for leader/dot contrast
3274
+ // (a pale low-value fill on its own is near-invisible) while still tying
3275
+ // the line to its region by colour.
3276
+ const dark = mix(rc.fill, palette.text, 60);
3277
+ labels.push({
3278
+ x: colX,
3279
+ y: ry,
3280
+ text: rc.name,
3281
+ anchor,
3282
+ color: palette.text,
3283
+ halo: true,
3284
+ haloColor: palette.bg,
3285
+ valueLine: rc.value,
3286
+ leader: { x1: rc.cx, y1: rc.cy, x2: innerX, y2: ry },
3287
+ leaderColor: dark,
3288
+ calloutDot: { x: rc.cx, y: rc.cy, color: dark },
3289
+ lineNumber: rc.lineNumber,
3290
+ });
3291
+ });
3292
+ };
3293
+ const right =
3294
+ calloutSide === 'right'
3295
+ ? regionCallouts
3296
+ : calloutSide === 'left'
3297
+ ? []
3298
+ : regionCallouts.filter((rc) => rc.cx >= width / 2);
3299
+ const left =
3300
+ calloutSide === 'left'
3301
+ ? regionCallouts
3302
+ : calloutSide === 'right'
3303
+ ? []
3304
+ : regionCallouts.filter((rc) => rc.cx < width / 2);
3305
+ placeColumn(right, 'right', reserveInfo?.right ?? 150);
3306
+ placeColumn(left, 'left', reserveInfo?.left ?? 150);
2528
3307
  }
2529
3308
  }
2530
3309
 
@@ -2551,6 +3330,16 @@ export function layoutMap(
2551
3330
  // from the east AND west — Boulder in the route-cluster gauntlet).
2552
3331
  type Side = 'right' | 'left' | 'above' | 'below';
2553
3332
  const GAP = 3;
3333
+ // Comfort buffer between any dot/label and the canvas edge — canvas-proportional
3334
+ // (≈3% of the shorter axis, floored) so a big preview pane breathes more than a
3335
+ // thumbnail. Used BOTH by the leader-column clamp (so a column never seats hard
3336
+ // against the frame) and by the edge-clearance re-fit below (dots + inline
3337
+ // labels). Keeping the two in sync is what stops the re-fit from fighting a
3338
+ // column that would otherwise re-clamp to the edge each pass.
3339
+ const POI_EDGE_CLEAR = Math.max(
3340
+ 20,
3341
+ Math.round(Math.min(width, height) * 0.03)
3342
+ );
2554
3343
  // Coincident-stack members (spiderfy) are labelled via a tidy leader-lined
2555
3344
  // COLUMN beside the cluster (see the cluster-column pass after the column
2556
3345
  // helpers below) — NOT radial inline labels, which pile up unreadably when
@@ -2589,7 +3378,8 @@ export function layoutMap(
2589
3378
  p: MapLayoutPoi,
2590
3379
  text: string,
2591
3380
  w: number,
2592
- side: Side
3381
+ side: Side,
3382
+ clusterId?: string
2593
3383
  ): void => {
2594
3384
  const rect = inlineRect(p, w, side);
2595
3385
  obstacles.push(rect);
@@ -2606,6 +3396,7 @@ export function layoutMap(
2606
3396
  haloColor: palette.bg,
2607
3397
  poiId: p.id,
2608
3398
  lineNumber: p.lineNumber,
3399
+ ...(clusterId !== undefined && { clusterMember: clusterId }),
2609
3400
  });
2610
3401
  };
2611
3402
  const inlineFits = (p: MapLayoutPoi, w: number, side: Side): boolean => {
@@ -2664,11 +3455,14 @@ export function layoutMap(
2664
3455
  // colX; a left column anchors its end at colX (text spans colX-maxW..colX).
2665
3456
  const colX =
2666
3457
  side === 'right'
2667
- ? Math.min(right + COL_GAP, width - 2 - maxW)
2668
- : Math.max(left - COL_GAP, 2 + maxW);
3458
+ ? Math.min(right + COL_GAP, width - POI_EDGE_CLEAR - maxW)
3459
+ : Math.max(left - COL_GAP, POI_EDGE_CLEAR + maxW);
2669
3460
  const totalH = items.length * step;
2670
3461
  let startY = cyMid - totalH / 2;
2671
- startY = Math.max(2, Math.min(startY, height - totalH - 2));
3462
+ startY = Math.max(
3463
+ POI_EDGE_CLEAR,
3464
+ Math.min(startY, height - totalH - POI_EDGE_CLEAR)
3465
+ );
2672
3466
  return items.map((o, i) => {
2673
3467
  const rowCy = startY + i * step + step / 2;
2674
3468
  return {
@@ -2698,12 +3492,50 @@ export function layoutMap(
2698
3492
  rect.y + rect.h <= height &&
2699
3493
  !collides(rect)
2700
3494
  );
2701
- // Today's side heuristic used only for ungated singleton callouts.
2702
- const defaultColumnSide = (items: ColItem[]): 'right' | 'left' => {
2703
- const right = Math.max(...items.map((o) => o.p.cx + o.p.r));
2704
- const maxW = Math.max(...items.map((o) => o.w));
2705
- return right + COL_GAP + maxW <= width - 2 ? 'right' : 'left';
3495
+ // Open-space score for a candidate label rect (higher = better). Cartographic
3496
+ // convention: a coastal point throws its label out over the water, never back
3497
+ // across the land it sits on. So a side whose label footprint lands over open
3498
+ // water dominates; among equally-wet (or equally-dry) sides, the one with more
3499
+ // clearance to the canvas edge wins. Sampled at a fixed 3×2 grid deterministic.
3500
+ const WATER_PREF = 1000; // a water-facing side beats any land-facing side
3501
+ const openness = (rect: LabelRect): number => {
3502
+ const xs = [
3503
+ rect.x + rect.w * 0.15,
3504
+ rect.x + rect.w * 0.5,
3505
+ rect.x + rect.w * 0.85,
3506
+ ];
3507
+ const ys = [rect.y + rect.h * 0.25, rect.y + rect.h * 0.75];
3508
+ let waterHits = 0;
3509
+ for (const x of xs)
3510
+ for (const y of ys) if (fillAt(x, y) === water) waterHits++;
3511
+ const waterFrac = waterHits / (xs.length * ys.length);
3512
+ const edgeClear = Math.max(
3513
+ 0,
3514
+ Math.min(
3515
+ rect.x,
3516
+ width - (rect.x + rect.w),
3517
+ rect.y,
3518
+ height - (rect.y + rect.h)
3519
+ )
3520
+ );
3521
+ // edgeClear scaled to ~0..30 so it only breaks ties, never overrides water.
3522
+ return WATER_PREF * waterFrac + edgeClear * 0.1;
2706
3523
  };
3524
+ // A column side's openness = mean openness over its rows' label rects.
3525
+ const columnSideScore = (
3526
+ items: ColItem[],
3527
+ side: 'right' | 'left'
3528
+ ): number => {
3529
+ const rows = columnRows(items, side);
3530
+ if (rows.length === 0) return -Infinity;
3531
+ return rows.reduce((s, { rect }) => s + openness(rect), 0) / rows.length;
3532
+ };
3533
+ // Side heuristic for ungated callouts: prefer the more open (water-facing,
3534
+ // then roomier) flank rather than blindly seating the column on the right.
3535
+ const defaultColumnSide = (items: ColItem[]): 'right' | 'left' =>
3536
+ columnSideScore(items, 'right') >= columnSideScore(items, 'left')
3537
+ ? 'right'
3538
+ : 'left';
2707
3539
  // Commit a visible callout column on the GIVEN side (no re-deriving the
2708
3540
  // side — the caller has already validated it). When `clusterId` is set the
2709
3541
  // rows are tagged `clusterMember` so the app shows/hides them (text AND
@@ -2763,22 +3595,83 @@ export function layoutMap(
2763
3595
  });
2764
3596
  };
2765
3597
 
2766
- // Spiderfy clusters: label every member in a tidy leader-lined column beside
2767
- // the ring (collision-free by row spacing), tagged `clusterMember` so the app
2768
- // toggles them with the badge. Committed FIRST so the singleton/group passes
2769
- // route around the column. The dots/legs/badge keep their true location only
2770
- // the labels move out to the column, which the startY-clamp keeps on-canvas.
3598
+ // A small coincident stack reads best with each member's label hugging its
3599
+ // OWN fanned dot on the side it fans toward the fan already seats the dots
3600
+ // radially (member 0 due North, the next due South for a pair, …), so a top
3601
+ // dot takes its label ABOVE and a bottom dot takes it BELOW. Compact and
3602
+ // symmetric, and unlike a one-sided leader column it never overruns the
3603
+ // frame when the stack sits hard against a coast (the San Jose case). The
3604
+ // labels carry `clusterMember` so the app still toggles them with the badge.
3605
+ const STACK_RADIAL_MAX = 4; // above/below/left/right — one slot per member
3606
+ const radialSide = (p: MapLayoutPoi, cx: number, cy: number): Side => {
3607
+ const dx = p.cx - cx;
3608
+ const dy = p.cy - cy;
3609
+ return Math.abs(dy) >= Math.abs(dx)
3610
+ ? dy <= 0
3611
+ ? 'above'
3612
+ : 'below'
3613
+ : dx < 0
3614
+ ? 'left'
3615
+ : 'right';
3616
+ };
3617
+ // Seat every member radially (preferred side first, then the rest), each new
3618
+ // label blocking the next. All-or-nothing: if any member can't seat on-canvas
3619
+ // and clean, bail so the caller falls back to the leader-lined column.
3620
+ const tryStackRadial = (items: ColItem[], clusterId: string): boolean => {
3621
+ const cluster = clusters.find((c) => c.id === clusterId);
3622
+ if (!cluster || items.length > STACK_RADIAL_MAX) return false;
3623
+ const temp: LabelRect[] = [];
3624
+ const seated: Array<{
3625
+ p: MapLayoutPoi;
3626
+ text: string;
3627
+ w: number;
3628
+ side: Side;
3629
+ }> = [];
3630
+ for (const { p, text, w } of items) {
3631
+ const pref = radialSide(p, cluster.cx, cluster.cy);
3632
+ const order: Side[] = [
3633
+ pref,
3634
+ ...(['above', 'below', 'right', 'left'] as Side[]).filter(
3635
+ (s) => s !== pref
3636
+ ),
3637
+ ];
3638
+ const side = order.find((s) => {
3639
+ const rect = inlineRect(p, w, s);
3640
+ return (
3641
+ rect.x >= 0 &&
3642
+ rect.x + rect.w <= width &&
3643
+ rect.y >= 0 &&
3644
+ rect.y + rect.h <= height &&
3645
+ !collides(rect) &&
3646
+ !temp.some((t) => rectsOverlap(t, rect))
3647
+ );
3648
+ });
3649
+ if (side === undefined) return false;
3650
+ temp.push(inlineRect(p, w, side));
3651
+ seated.push({ p, text, w, side });
3652
+ }
3653
+ for (const { p, text, w, side } of seated)
3654
+ pushInline(p, text, w, side, clusterId);
3655
+ return true;
3656
+ };
3657
+ // Spiderfy clusters: committed FIRST so the singleton/group passes route
3658
+ // around them. Try the compact radial layout; only a stack too big (or too
3659
+ // boxed-in) for cardinal slots falls back to a tidy leader-lined column,
3660
+ // thrown to the cleaner/seaward flank.
2771
3661
  for (const [clusterId, members] of clusterMembersById) {
2772
3662
  if (members.length === 0) continue;
2773
3663
  const items = makeItems(members);
2774
- // Prefer a clean (on-canvas, collision-free) side; fall back to the side
2775
- // with more horizontal room. Cluster labels are always placed (never
2776
- // hover-only) readability beats the odd overlap with a faint basemap.
2777
- const side = wouldColumnBeClean(items, 'right')
2778
- ? 'right'
2779
- : wouldColumnBeClean(items, 'left')
2780
- ? 'left'
2781
- : defaultColumnSide(items);
3664
+ if (tryStackRadial(items, clusterId)) continue;
3665
+ const cleanR = wouldColumnBeClean(items, 'right');
3666
+ const cleanL = wouldColumnBeClean(items, 'left');
3667
+ const side =
3668
+ cleanR && cleanL
3669
+ ? defaultColumnSide(items)
3670
+ : cleanR
3671
+ ? 'right'
3672
+ : cleanL
3673
+ ? 'left'
3674
+ : defaultColumnSide(items);
2782
3675
  commitColumn(items, side, clusterId);
2783
3676
  }
2784
3677
 
@@ -2795,11 +3688,25 @@ export function layoutMap(
2795
3688
  // Singleton: inline if it fits, else today's single-row callout —
2796
3689
  // always placed, never hover-only (Decision #2 / AC9).
2797
3690
  const { p, text, w } = items[0]!;
2798
- const side = (['right', 'left', 'above', 'below'] as const).find((s) =>
2799
- inlineFits(p, w, s)
3691
+ const fits = (['right', 'left', 'above', 'below'] as const).filter(
3692
+ (s) => inlineFits(p, w, s)
2800
3693
  );
2801
- if (side) pushInline(p, text, w, side);
2802
- else commitColumn(items, defaultColumnSide(items));
3694
+ if (fits.length === 0) {
3695
+ commitColumn(items, defaultColumnSide(items));
3696
+ continue;
3697
+ }
3698
+ // Horizontal sides read best; fall to vertical only if neither flank
3699
+ // fits. Among the pool, divert to a water-facing side when one exists
3700
+ // (seaward coastal label); otherwise keep the right-first reading order.
3701
+ const horiz = fits.filter((s) => s === 'right' || s === 'left');
3702
+ const pool = horiz.length > 0 ? horiz : fits;
3703
+ const score = (s: Side): number => openness(inlineRect(p, w, s));
3704
+ const wet = pool.filter((s) => score(s) >= WATER_PREF * 0.5);
3705
+ const side =
3706
+ wet.length > 0
3707
+ ? wet.reduce((b, s) => (score(s) > score(b) ? s : b))
3708
+ : pool[0]!;
3709
+ pushInline(p, text, w, side);
2803
3710
  continue;
2804
3711
  }
2805
3712
  // Gate (a): bounding-box diagonal over marker extents — a sprawling chain
@@ -2820,12 +3727,154 @@ export function layoutMap(
2820
3727
  // or left-side column places fully clean; commit on that exact side, else
2821
3728
  // the whole cluster goes hover-only.
2822
3729
  for (const items of clusterPending) {
2823
- const side = (['right', 'left'] as const).find((s) =>
3730
+ const cleanSides = (['right', 'left'] as const).filter((s) =>
2824
3731
  wouldColumnBeClean(items, s)
2825
3732
  );
3733
+ const side =
3734
+ cleanSides.length > 1
3735
+ ? defaultColumnSide(items) // both clean → most open flank
3736
+ : cleanSides[0];
2826
3737
  if (side) commitColumn(items, side);
2827
3738
  else items.forEach((o) => pushHidden(o.p));
2828
3739
  }
3740
+
3741
+ // ── Edge clearance (re-fit, first pass → re-run) ──
3742
+ // The tight fit (FIT_PAD = 24px) can seat a POI — or its label (inline OR
3743
+ // leader column) — hard against a side, off-canvas, or demoted to hover-only.
3744
+ // Measure how far every POI dot AND every POI label crosses a comfort
3745
+ // clearance line on each of the four sides, reserve the deepest intrusion per
3746
+ // side as a band, and re-fit the whole map into the canvas MINUS those bands —
3747
+ // the data (dots and labels together) slides inward so nothing hugs the frame.
3748
+ // The clearance scales with the canvas (≈3% of the shorter axis, floored) so a
3749
+ // big preview pane gets proportionally more breathing room than a thumbnail.
3750
+ // Asymmetric and "just enough": only the crowded sides zoom out, the rest stay
3751
+ // tight. A committed label's box is reconstructed from its baseline/anchor; a
3752
+ // still-hidden (hover-only) label is measured at its IDEAL seaward position
3753
+ // (its stored rect is clamped on-canvas and would read as no intrusion).
3754
+ // Re-measured and accumulated each pass until nothing intrudes, capped at
3755
+ // `MAX_CLEARANCE_PASSES` so a pathologically small canvas can't loop forever.
3756
+ const clearancePass = opts._poiClearancePass ?? 0;
3757
+ const MAX_CLEARANCE_PASSES = 4;
3758
+ if (clearancePass < MAX_CLEARANCE_PASSES && pois.length > 0) {
3759
+ const EDGE_CLEAR = POI_EDGE_CLEAR; // shared with the leader-column clamp
3760
+ const capH = Math.floor(width * 0.3); // never starve the map for one wide name
3761
+ const capV = Math.floor(height * 0.3);
3762
+ const poiById2 = new Map(pois.map((p) => [p.id, p]));
3763
+ let needLeft = 0;
3764
+ let needRight = 0;
3765
+ let needTop = 0;
3766
+ let needBottom = 0;
3767
+ // Dots first: a marker itself must clear every edge by the buffer, so a
3768
+ // corner cluster is pulled bodily inward (its labels ride along).
3769
+ // Top is measured against the canvas edge (y=0), NOT topPad: the title band
3770
+ // (topPad) already separates content from the top, so a dot/label just under
3771
+ // it is not "hugging the edge" — referencing topPad would shove every POI map
3772
+ // down by the buffer for no reason.
3773
+ for (const p of pois) {
3774
+ needLeft = Math.max(needLeft, EDGE_CLEAR - (p.cx - p.r));
3775
+ needRight = Math.max(needRight, p.cx + p.r + EDGE_CLEAR - width);
3776
+ needTop = Math.max(needTop, EDGE_CLEAR - (p.cy - p.r));
3777
+ needBottom = Math.max(needBottom, p.cy + p.r + EDGE_CLEAR - height);
3778
+ }
3779
+ for (const l of labels) {
3780
+ if (l.poiId === undefined) continue;
3781
+ const p = poiById2.get(l.poiId);
3782
+ if (!p) continue;
3783
+ // A leader-lined COLUMN (visible) or a hover-only HIDDEN label both want a
3784
+ // seaward column beside the dot. Measuring their CLAMPED rect is useless —
3785
+ // a column self-clamps to the edge (so it reads as no intrusion yet sits on
3786
+ // the dots), and a hidden label's stored rect is clamped too. Instead
3787
+ // reserve from the DOT so the column fits at its NATURAL seat (dot edge +
3788
+ // COL_GAP + label width + buffer). This is dot-based, so it CONVERGES as
3789
+ // the data slides in — unlike measuring the self-clamped label, which would
3790
+ // never move off the edge. The column then seats beside the dots (no clamp,
3791
+ // no overlap) and shows. COL_GAP matches the column layout's own gap.
3792
+ if (l.hidden || l.leader) {
3793
+ const lw = l.hidden
3794
+ ? labelInfo(p).w
3795
+ : measureLegendText(l.text, FONT);
3796
+ const reach = p.r + COL_GAP + lw + EDGE_CLEAR;
3797
+ if (p.cx >= width / 2)
3798
+ needRight = Math.max(needRight, p.cx + reach - width);
3799
+ else needLeft = Math.max(needLeft, reach - p.cx);
3800
+ continue;
3801
+ }
3802
+ // Visible inline label: reconstruct its box from baseline + anchor and
3803
+ // measure how far it crosses each clearance line (negative = inside).
3804
+ const w = measureLegendText(l.text, FONT);
3805
+ const boxLeft =
3806
+ l.anchor === 'start'
3807
+ ? l.x
3808
+ : l.anchor === 'end'
3809
+ ? l.x - w
3810
+ : l.x - w / 2;
3811
+ const boxTop = l.y - FONT / 3 - poiLabH / 2;
3812
+ const boxRight = boxLeft + w;
3813
+ const boxBottom = boxTop + poiLabH;
3814
+ needLeft = Math.max(needLeft, EDGE_CLEAR - boxLeft);
3815
+ needRight = Math.max(needRight, boxRight + EDGE_CLEAR - width);
3816
+ needTop = Math.max(needTop, EDGE_CLEAR - boxTop);
3817
+ needBottom = Math.max(needBottom, boxBottom + EDGE_CLEAR - height);
3818
+ }
3819
+ needLeft = Math.min(Math.max(0, Math.ceil(needLeft)), capH);
3820
+ needRight = Math.min(Math.max(0, Math.ceil(needRight)), capH);
3821
+ needTop = Math.min(Math.max(0, Math.ceil(needTop)), capV);
3822
+ needBottom = Math.min(Math.max(0, Math.ceil(needBottom)), capV);
3823
+ if (needLeft >= 1 || needRight >= 1 || needTop >= 1 || needBottom >= 1) {
3824
+ // ADD the residual intrusion to the band already reserved (the measured
3825
+ // positions already reflect prior bands, so `need` is what's still over the
3826
+ // line) and re-fit. Accumulating — not max — is what makes a too-tight
3827
+ // first shift converge on the next pass instead of stalling.
3828
+ const prev = opts._calloutReserve;
3829
+ const left = Math.min((prev?.left ?? 0) + needLeft, capH);
3830
+ const right = Math.min((prev?.right ?? 0) + needRight, capH);
3831
+ const top = Math.min((prev?.top ?? 0) + needTop, capV);
3832
+ const bottom = Math.min((prev?.bottom ?? 0) + needBottom, capV);
3833
+ return layoutMap(resolved, data, size, {
3834
+ ...opts,
3835
+ _poiClearancePass: clearancePass + 1,
3836
+ _calloutReserve: {
3837
+ ...(left > 0 && { left }),
3838
+ ...(right > 0 && { right }),
3839
+ ...(top > 0 && { top }),
3840
+ ...(bottom > 0 && { bottom }),
3841
+ },
3842
+ });
3843
+ }
3844
+ }
3845
+ }
3846
+
3847
+ // Region/orientation labels yield to POI labels (the subject). A region label
3848
+ // whose footprint a visible POI label now overlaps is removed — the POI data
3849
+ // owns that spot, and the region label is orientation that reads fine absent
3850
+ // here (vs. crammed atop a dot). Done after POI placement because the region
3851
+ // pass runs first and couldn't see where the POI labels would land. POI label
3852
+ // rects are padded a touch so a near-touch also triggers the yield.
3853
+ if (regionLabelGuards.length > 0) {
3854
+ const PAD = 2;
3855
+ const poiRects = labels
3856
+ .filter((l) => l.poiId !== undefined && l.hidden !== true)
3857
+ .map((l) => {
3858
+ const w = measureLegendText(l.text, FONT);
3859
+ const x =
3860
+ l.anchor === 'start'
3861
+ ? l.x
3862
+ : l.anchor === 'end'
3863
+ ? l.x - w
3864
+ : l.x - w / 2;
3865
+ return {
3866
+ x: x - PAD,
3867
+ y: l.y - FONT,
3868
+ w: w + 2 * PAD,
3869
+ h: FONT * 1.4 + 2 * PAD,
3870
+ };
3871
+ });
3872
+ for (const g of regionLabelGuards) {
3873
+ if (poiRects.some((pr) => rectsOverlap(pr, g.rect))) {
3874
+ const i = labels.indexOf(g.label);
3875
+ if (i >= 0) labels.splice(i, 1);
3876
+ }
3877
+ }
2829
3878
  }
2830
3879
 
2831
3880
  // -- Context labels (orientation backdrop, §24B). Placed DEAD LAST so they
@@ -2883,22 +3932,25 @@ export function layoutMap(
2883
3932
  name: (f.properties as { name?: string } | undefined)?.name ?? iso,
2884
3933
  bbox: [x0, y0, x1, y1],
2885
3934
  anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
3935
+ curatedAnchor: !!anchorLngLat,
2886
3936
  });
2887
3937
  }
2888
- // Neighbour US states (POI-only region framing): when the frame is snapped to
2889
- // a US-state container (e.g. California), label the surrounding in-frame states
2890
- // (Nevada, Oregon, Arizona…) in the muted context style for orientation. They
2891
- // are NOT containers and NOT data, so the region-label pass skipped them.
3938
+ // Framed US states (POI-only region framing): when the frame is snapped to a
3939
+ // US-state container (e.g. California), label the focus state AND the
3940
+ // surrounding in-frame states (Nevada, Oregon, Arizona…) in the muted context
3941
+ // style for orientation. None are data (the region-label pass skipped them).
2892
3942
  // Anchor each to the centroid of its VISIBLE (culled) geometry so a state only
2893
3943
  // partly in frame (a sliver of Oregon at the top) still anchors on-screen
2894
3944
  // rather than at an off-frame centroid that `insideViewport` would reject.
3945
+ // The focus container IS included (gives the map its headline name) — only a
3946
+ // data-referenced state is skipped, to avoid double-labelling what
3947
+ // region-labels already named.
2895
3948
  const framedStateContainers = (resolved.poiFrameContainers ?? []).some(
2896
3949
  (id) => id.startsWith('US-')
2897
3950
  );
2898
3951
  if (usLayer && framedStateContainers) {
2899
- const containerSet = new Set(resolved.poiFrameContainers);
2900
3952
  for (const [iso, f] of usLayer) {
2901
- if (containerSet.has(iso) || regionById.has(iso)) continue;
3953
+ if (regionById.has(iso)) continue;
2902
3954
  const viewF = cullFeatureToView(f);
2903
3955
  if (!viewF) continue; // not in frame
2904
3956
  const b = path.bounds(viewF as never) as [
@@ -2935,6 +3987,69 @@ export function layoutMap(
2935
3987
  labels.push(...contextLabels);
2936
3988
  }
2937
3989
 
3990
+ // ── Subtle city dots (basemap orientation, §24B `no-cities`) ──
3991
+ // A faint scatter of gazetteer cities for geographic context. Population-ranked
3992
+ // and spacing-thinned: the min-pixel gap makes density adapt to zoom for free —
3993
+ // at world scale only the biggest of a dense cluster (Europe) survive; zoomed
3994
+ // into one country the same cities spread apart and more local ones fill in.
3995
+ // Explicit POIs always win — a city dot never sits under a referenced marker.
3996
+ //
3997
+ // The ON-CANVAS projected-pixel test is the ONLY cull — NOT a lon/lat extent
3998
+ // box. `resolved.extent` wraps the antimeridian for albers-usa whenever AK/HI
3999
+ // are referenced (west lon > east lon), which a naive `lon<w||lon>e` box reads
4000
+ // as "reject every mainland city" → an all-blank US map. The pixel test is
4001
+ // projection-agnostic and antimeridian-safe, and it naturally includes the
4002
+ // near-border neighbour cities the viewport actually shows.
4003
+ const cityDots: MapLayoutCityDot[] = [];
4004
+ if (resolved.directives.noCities !== true) {
4005
+ const CITY_DOT_SPACING = 12; // min px between two dots (and dot↔POI)
4006
+ const CITY_DOT_CAP = 220;
4007
+ const SPACING_SQ = CITY_DOT_SPACING * CITY_DOT_SPACING;
4008
+ // Radius scales with population on a log axis (pop spans ~50k → 37M, so a
4009
+ // linear map would collapse everything but the megacities to one size). A
4010
+ // metropolis reads as a slightly fatter dot; a small town stays a faint
4011
+ // speck. Still decorative — the range is deliberately tight so the layer
4012
+ // never competes with POIs.
4013
+ const CITY_DOT_R_MIN = 0.7;
4014
+ const CITY_DOT_R_MAX = 2.6;
4015
+ const CITY_POP_MIN = 50_000; // ≤ this → R_MIN
4016
+ const CITY_POP_MAX = 15_000_000; // ≥ this → R_MAX
4017
+ const LOG_MIN = Math.log10(CITY_POP_MIN);
4018
+ const LOG_SPAN = Math.log10(CITY_POP_MAX) - LOG_MIN;
4019
+ const cityDotRadius = (pop: number): number => {
4020
+ if (!(pop > CITY_POP_MIN)) return CITY_DOT_R_MIN;
4021
+ const t = Math.min(1, (Math.log10(pop) - LOG_MIN) / LOG_SPAN);
4022
+ return CITY_DOT_R_MIN + t * (CITY_DOT_R_MAX - CITY_DOT_R_MIN);
4023
+ };
4024
+ // Seed the occupancy set with explicit POI positions so dots dodge markers.
4025
+ const placed: { x: number; y: number }[] = pois.map((p) => ({
4026
+ x: p.cx,
4027
+ y: p.cy,
4028
+ }));
4029
+ const sorted = [...data.gazetteer.cities].sort((a, b) => b[3] - a[3]);
4030
+ for (const c of sorted) {
4031
+ if (cityDots.length >= CITY_DOT_CAP) break;
4032
+ const lat = c[0];
4033
+ const lon = c[1];
4034
+ const p = project(lon, lat);
4035
+ if (!p) continue;
4036
+ const [px, py] = p;
4037
+ if (px < 0 || px > width || py < 0 || py > height) continue;
4038
+ let tooClose = false;
4039
+ for (const q of placed) {
4040
+ const dx = q.x - px;
4041
+ const dy = q.y - py;
4042
+ if (dx * dx + dy * dy < SPACING_SQ) {
4043
+ tooClose = true;
4044
+ break;
4045
+ }
4046
+ }
4047
+ if (tooClose) continue;
4048
+ placed.push({ x: px, y: py });
4049
+ cityDots.push({ cx: px, cy: py, r: cityDotRadius(c[3]) });
4050
+ }
4051
+ }
4052
+
2938
4053
  return {
2939
4054
  width,
2940
4055
  height,
@@ -2949,6 +4064,7 @@ export function layoutMap(
2949
4064
  coastlineStyle,
2950
4065
  legs,
2951
4066
  pois,
4067
+ cityDots,
2952
4068
  clusters,
2953
4069
  labels,
2954
4070
  legend,