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