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