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