@diagrammo/dgmo 0.20.3 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advanced.cjs +867 -286
- package/dist/advanced.js +866 -286
- package/dist/auto.cjs +635 -284
- package/dist/auto.js +113 -113
- package/dist/auto.mjs +635 -284
- package/dist/cli.cjs +156 -156
- package/dist/editor.cjs +6 -2
- package/dist/editor.js +6 -2
- package/dist/highlight.cjs +6 -2
- package/dist/highlight.js +6 -2
- package/dist/index.cjs +628 -281
- package/dist/index.js +628 -281
- package/dist/internal.cjs +867 -286
- package/dist/internal.js +866 -286
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -0
- package/docs/language-reference.md +27 -25
- package/gallery/fixtures/map-choropleth.dgmo +7 -7
- package/gallery/fixtures/map-direct-color.dgmo +10 -0
- package/gallery/fixtures/map-pois.dgmo +4 -4
- package/gallery/fixtures/map-region-scope.dgmo +8 -8
- package/gallery/fixtures/map-route.dgmo +5 -6
- package/package.json +1 -1
- package/src/advanced.ts +14 -0
- package/src/completion.ts +10 -4
- package/src/d3.ts +15 -9
- package/src/editor/keywords.ts +6 -2
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -0
- package/src/map/geo-query.ts +277 -0
- package/src/map/geo.ts +258 -1
- package/src/map/invert.ts +111 -0
- package/src/map/layout.ts +333 -139
- package/src/map/load-data.ts +7 -1
- package/src/map/parser.ts +142 -33
- package/src/map/renderer.ts +57 -6
- package/src/map/resolved-types.ts +21 -2
- package/src/map/resolver.ts +219 -53
- package/src/map/types.ts +57 -14
- package/src/utils/reserved-key-registry.ts +7 -7
- package/dist/advanced.d.cts +0 -5290
- package/dist/advanced.d.ts +0 -5290
- package/dist/auto.d.cts +0 -39
- package/dist/auto.d.ts +0 -39
- package/dist/index.d.cts +0 -336
- package/dist/index.d.ts +0 -336
- package/dist/internal.d.cts +0 -5290
- package/dist/internal.d.ts +0 -5290
package/src/map/layout.ts
CHANGED
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
type GeoPath,
|
|
18
18
|
} from 'd3-geo';
|
|
19
19
|
import { feature } from 'topojson-client';
|
|
20
|
-
import { mix, contrastText } from '../palettes/color-utils';
|
|
20
|
+
import { mix, contrastText, relativeLuminance } from '../palettes/color-utils';
|
|
21
|
+
import { resolveColor } from '../colors';
|
|
21
22
|
import type { PaletteColors } from '../palettes/types';
|
|
22
23
|
import {
|
|
23
24
|
rectsOverlap,
|
|
@@ -58,21 +59,51 @@ const W_MIN = 1.25; // edge stroke width
|
|
|
58
59
|
const W_MAX = 8;
|
|
59
60
|
const FONT = 11; // on-map label font px
|
|
60
61
|
const COLO_EPS = 1.5; // px: POIs closer than this are "co-located"
|
|
61
|
-
// % palette-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// % palette-green of bg for unscored land — a VERY faded green so every map
|
|
63
|
+
// (plain reference OR data-coloured) wears the same subtle dress and the green
|
|
64
|
+
// never competes with saturated tag/score tints. Dark lifts a touch off the
|
|
65
|
+
// near-black surface so the faint green stays legible.
|
|
66
|
+
const LAND_TINT_LIGHT = 12;
|
|
67
|
+
const LAND_TINT_DARK = 24;
|
|
65
68
|
// Categorical (tag) region fill: a flat, fairly saturated tint of the tag
|
|
66
69
|
// colour so a tagged region reads as its CATEGORY against the tinted land base
|
|
67
70
|
// — the generic 25% shape tint washes out and lets the olive land dominate.
|
|
68
71
|
const TAG_TINT_LIGHT = 60;
|
|
69
72
|
const TAG_TINT_DARK = 68;
|
|
70
|
-
|
|
73
|
+
// % palette-blue of bg for the ocean / backdrop — a VERY faded blue, matching
|
|
74
|
+
// the land's subtlety so the whole basemap reads as a quiet dress under the data.
|
|
75
|
+
const WATER_TINT_LIGHT = 13;
|
|
76
|
+
const WATER_TINT_DARK = 14;
|
|
71
77
|
const RIVER_WIDTH = 1.3; // px stroke width for river lines
|
|
78
|
+
// Relief (mountain-range shading). A projected range below this px² area is
|
|
79
|
+
// dropped (no confetti slivers at world zoom).
|
|
80
|
+
const RELIEF_MIN_AREA = 12; // px²
|
|
81
|
+
// Each projected bbox side must clear this — drop near-degenerate slivers.
|
|
82
|
+
const RELIEF_MIN_DIM = 2; // px
|
|
83
|
+
// Relief = horizontal hachure lines clipped to each range: a subtle
|
|
84
|
+
// dark-on-light / light-on-dark texture that reads as "mountains here". Spacing
|
|
85
|
+
// is SCREEN-space so density is constant regardless of zoom (geo-space spacing
|
|
86
|
+
// would collapse a small range to 1–2 lines and read as a glitch). Kept FAINT:
|
|
87
|
+
// thin sub-pixel lines drawn with a non-scaling stroke (constant device width at
|
|
88
|
+
// any zoom/DPR) and low-contrast colour. NOT crispEdges — that snaps the stroke
|
|
89
|
+
// to a solid ~1px in WebKit and reads far too heavy; plain AA keeps them whisper-thin.
|
|
90
|
+
const RELIEF_HATCH_SPACING = 3; // px between lines
|
|
91
|
+
const RELIEF_HATCH_WIDTH = 0.25; // px stroke
|
|
92
|
+
// % of the DARK reference (palette.bg on dark themes, palette.text on light)
|
|
93
|
+
// blended into the land colour — so the lines read DARKER than the land in both
|
|
94
|
+
// themes (palette.text alone flips to light on dark themes).
|
|
95
|
+
const RELIEF_HATCH_STRENGTH = 32;
|
|
72
96
|
// % palette-gray of bg for non-US neighbour land. Higher on dark so it reads as
|
|
73
97
|
// a clear gray rather than sinking into the dark background.
|
|
74
98
|
const FOREIGN_TINT_LIGHT = 30;
|
|
75
99
|
const FOREIGN_TINT_DARK = 62;
|
|
100
|
+
// MUTED basemap — used when a colouring dimension (score ramp or a tag group) is
|
|
101
|
+
// active. The subject water + land are ALWAYS the same faded blue/green dress
|
|
102
|
+
// (WATER_TINT_* / LAND_TINT_*); muted only pushes NEIGHBOUR land to a recessive
|
|
103
|
+
// gray so the subject country reads as the subject and the data fills own the
|
|
104
|
+
// saturation. Plain reference maps keep neighbour land at the fuller gray tint.
|
|
105
|
+
const MUTED_FOREIGN_LIGHT = 28; // neighbour land — recessive gray, not green
|
|
106
|
+
const MUTED_FOREIGN_DARK = 16;
|
|
76
107
|
const COLO_R = 9; // spiderfy radius
|
|
77
108
|
const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
|
|
78
109
|
const FAN_STEP = 16; // px perpendicular offset between parallel edges
|
|
@@ -86,9 +117,9 @@ export interface MapLayoutRegion {
|
|
|
86
117
|
readonly label?: string;
|
|
87
118
|
readonly lineNumber: number;
|
|
88
119
|
readonly layer: 'base' | 'country' | 'us-state';
|
|
89
|
-
/** The region's
|
|
120
|
+
/** The region's value (if any) — emitted as `data-value` so the app can
|
|
90
121
|
* highlight by gradient-scrub proximity. */
|
|
91
|
-
readonly
|
|
122
|
+
readonly value?: number;
|
|
92
123
|
/** The region's tag values keyed by group (lowercased) — emitted as
|
|
93
124
|
* `data-tag-<group>` so the app can highlight on legend-entry hover. */
|
|
94
125
|
readonly tags?: Readonly<Record<string, string>>;
|
|
@@ -106,6 +137,24 @@ export interface MapLayoutInset {
|
|
|
106
137
|
readonly w: number;
|
|
107
138
|
readonly h: number;
|
|
108
139
|
readonly points: ReadonlyArray<readonly [number, number]>;
|
|
140
|
+
/** The FITTED inset projection (fit to this frame's screen box inside
|
|
141
|
+
* `placeInset`). Load-bearing for pixel↔lonLat over the AK/HI insets: the
|
|
142
|
+
* un-fitted `alaskaProjection()`/`hawaiiProjection()` factories would invert
|
|
143
|
+
* to garbage, so the geo-query inverts against THIS instance. */
|
|
144
|
+
readonly projection: GeoProjection;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Post-projection non-uniform stretch applied to GLOBAL fits (fill-the-canvas).
|
|
148
|
+
* `null` for regional fits. The geo-query applies the forward form when
|
|
149
|
+
* projecting and the inverse before `projection.invert`. Mirrors the `stretch`
|
|
150
|
+
* closure used for the path stream: px = ox + (x - bx0) * sx. */
|
|
151
|
+
export interface MapLayoutStretch {
|
|
152
|
+
readonly sx: number;
|
|
153
|
+
readonly sy: number;
|
|
154
|
+
readonly ox: number;
|
|
155
|
+
readonly oy: number;
|
|
156
|
+
readonly bx0: number;
|
|
157
|
+
readonly by0: number;
|
|
109
158
|
}
|
|
110
159
|
|
|
111
160
|
export interface MapLayoutPoi {
|
|
@@ -119,6 +168,9 @@ export interface MapLayoutPoi {
|
|
|
119
168
|
readonly implicit: boolean;
|
|
120
169
|
readonly isOrigin: boolean; // route origin -> distinct marker
|
|
121
170
|
readonly routeNumber?: number; // route stop badge
|
|
171
|
+
/** Tag values keyed by lowercased group name — emitted as `data-tag-<group>`
|
|
172
|
+
* so the app can spotlight markers on legend-entry hover (mirrors regions). */
|
|
173
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
122
174
|
}
|
|
123
175
|
|
|
124
176
|
/** A drawn connector -- an edge or a route leg (same geometry contract). */
|
|
@@ -176,6 +228,23 @@ export interface MapLayoutRiver {
|
|
|
176
228
|
readonly width: number;
|
|
177
229
|
}
|
|
178
230
|
|
|
231
|
+
/** A drawn mountain-range relief shape — a projected polygon path. The renderer
|
|
232
|
+
* unions these into one clip and rules horizontal hachure lines through them. */
|
|
233
|
+
export interface MapLayoutRelief {
|
|
234
|
+
readonly d: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The shared hachure style for the relief lines. `null` when relief is off or
|
|
238
|
+
* no range survives the gates. */
|
|
239
|
+
export interface MapLayoutReliefHatch {
|
|
240
|
+
/** Line stroke — palette.text mixed into the land colour (so it's dark-on-
|
|
241
|
+
* light and light-on-dark automatically as palette.text flips with theme). */
|
|
242
|
+
readonly color: string;
|
|
243
|
+
/** Vertical gap between lines in SCREEN px (constant density, zoom-stable). */
|
|
244
|
+
readonly spacing: number;
|
|
245
|
+
readonly width: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
179
248
|
export interface MapLayout {
|
|
180
249
|
readonly width: number;
|
|
181
250
|
readonly height: number;
|
|
@@ -186,6 +255,12 @@ export interface MapLayout {
|
|
|
186
255
|
readonly regions: readonly MapLayoutRegion[];
|
|
187
256
|
/** Major river centerlines, drawn over land/lakes and under POIs/edges. */
|
|
188
257
|
readonly rivers: readonly MapLayoutRiver[];
|
|
258
|
+
/** Mountain-range relief shapes (empty unless `relief` is on + the asset is
|
|
259
|
+
* present); the renderer clips horizontal hachure lines to their union,
|
|
260
|
+
* drawn over base land, under rivers/POIs/data fills. */
|
|
261
|
+
readonly relief: readonly MapLayoutRelief[];
|
|
262
|
+
/** Hachure style for the relief lines (null = relief off / none survived). */
|
|
263
|
+
readonly reliefHatch: MapLayoutReliefHatch | null;
|
|
189
264
|
readonly legs: readonly MapLayoutLeg[];
|
|
190
265
|
readonly pois: readonly MapLayoutPoi[];
|
|
191
266
|
readonly labels: readonly PlacedLabel[];
|
|
@@ -195,6 +270,12 @@ export interface MapLayout {
|
|
|
195
270
|
/** AK/HI region paths drawn inside the inset boxes (foreground, over an
|
|
196
271
|
* opaque ocean fill). Paired positionally with `insets`. */
|
|
197
272
|
readonly insetRegions: readonly MapLayoutRegion[];
|
|
273
|
+
/** The fitted MAIN projection (the conus conic for albers-usa). Exposed for
|
|
274
|
+
* the geo-query's pixel↔lonLat inversion — the app NEVER reconstructs it from
|
|
275
|
+
* metadata; it binds to this exact instance. */
|
|
276
|
+
readonly projection: GeoProjection;
|
|
277
|
+
/** Non-uniform stretch applied for GLOBAL fits (null for regional fits). */
|
|
278
|
+
readonly stretch: MapLayoutStretch | null;
|
|
198
279
|
}
|
|
199
280
|
|
|
200
281
|
export interface LayoutOptions {
|
|
@@ -276,18 +357,33 @@ const US_NON_CONUS = new Set([
|
|
|
276
357
|
|
|
277
358
|
/** The map's water / backdrop colour for a palette — the single source of truth
|
|
278
359
|
* 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
|
-
|
|
281
|
-
|
|
360
|
+
* match it (so letterbox gaps around the SVG don't show a stray band). Always a
|
|
361
|
+
* VERY faded blue — uniform whether or not a colouring dimension is active — so
|
|
362
|
+
* it reads as water without competing with saturated blue/green data hues.
|
|
363
|
+
* `_dataActive` is retained for signature stability (the sea no longer changes
|
|
364
|
+
* with data; only neighbour land recedes — see layout's `foreignFill`). */
|
|
365
|
+
export function mapBackgroundColor(
|
|
366
|
+
palette: PaletteColors,
|
|
367
|
+
isDark = false,
|
|
368
|
+
_dataActive = false
|
|
369
|
+
): string {
|
|
370
|
+
return mix(
|
|
371
|
+
palette.colors.blue,
|
|
372
|
+
palette.bg,
|
|
373
|
+
isDark ? WATER_TINT_DARK : WATER_TINT_LIGHT
|
|
374
|
+
);
|
|
282
375
|
}
|
|
283
376
|
|
|
284
|
-
/** The map's neutral (unscored/untagged) LAND colour — the
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
377
|
+
/** The map's neutral (unscored/untagged) LAND colour — the base every region
|
|
378
|
+
* blends from. Exported so a host can DIM a region to plain land (rather than
|
|
379
|
+
* lowering opacity, which would let the water show through and make the shape
|
|
380
|
+
* read as ocean). Matches the layout's `neutralFill`. Always a VERY faded green
|
|
381
|
+
* — uniform whether or not data is active — so saturated tag/score tints read
|
|
382
|
+
* clearly against it. `_dataActive` is retained for signature stability. */
|
|
288
383
|
export function mapNeutralLandColor(
|
|
289
384
|
palette: PaletteColors,
|
|
290
|
-
isDark: boolean
|
|
385
|
+
isDark: boolean,
|
|
386
|
+
_dataActive = false
|
|
291
387
|
): string {
|
|
292
388
|
return mix(
|
|
293
389
|
palette.colors.green,
|
|
@@ -344,52 +440,52 @@ export function layoutMap(
|
|
|
344
440
|
}
|
|
345
441
|
const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
|
|
346
442
|
|
|
347
|
-
// Land is a muted green; the ocean/backdrop is blue. Scored/tagged regions
|
|
348
|
-
// paint over the land base, and the score ramp blends FROM the land colour so
|
|
349
|
-
// low scores stay land-toned rather than fading out. In a US view the world
|
|
350
|
-
// layer is just neighbour context (Mexico/Canada at the frame edge) — fill it
|
|
351
|
-
// gray so the green US reads as the subject; world maps (no us-states layer)
|
|
352
|
-
// keep green land for every country.
|
|
353
|
-
const landTint = isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT;
|
|
354
|
-
const neutralFill = mix(palette.colors.green, palette.bg, landTint);
|
|
355
|
-
const water = mapBackgroundColor(palette);
|
|
356
443
|
const usContext = usLayer !== null;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
palette.bg,
|
|
360
|
-
isDark ? FOREIGN_TINT_DARK : FOREIGN_TINT_LIGHT
|
|
361
|
-
);
|
|
444
|
+
// Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
|
|
445
|
+
// colouring dimension is active — defined below, once `activeGroup` is known.
|
|
362
446
|
// Region borders: a clearly dark outline in BOTH themes. palette.text flips
|
|
363
447
|
// (dark on light, light on dark), so mix toward whichever of text/bg is the
|
|
364
448
|
// dark one — never a light hairline over the land fills.
|
|
365
449
|
const regionStroke = isDark
|
|
366
450
|
? mix(palette.bg, palette.text, 78) // dark theme: near-bg dark outline
|
|
367
451
|
: mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
|
|
452
|
+
// Lake shoreline. Lakes are painted as water OVER the land and the region
|
|
453
|
+
// borders, so without an edge they read as a featureless patch that simply
|
|
454
|
+
// erases whatever state/country border ran beneath them (worst in muted/data
|
|
455
|
+
// mode, where the water is a pale gray barely distinct from the land). A soft
|
|
456
|
+
// coastline — between the border colour and the water, not a hard black line —
|
|
457
|
+
// gives the lake a defined edge; that edge legitimately REPLACES the border
|
|
458
|
+
// running through it (real choropleths carve lakes out of the land, so the
|
|
459
|
+
// shoreline IS the boundary at the water). Defined here; `water` is below.
|
|
368
460
|
|
|
369
461
|
// -- Region fill model (choropleth + categorical; AR4/AR6) --
|
|
370
|
-
const
|
|
371
|
-
.filter((r) => r.
|
|
372
|
-
.map((r) => r.
|
|
462
|
+
const values = resolved.regions
|
|
463
|
+
.filter((r) => r.value !== undefined)
|
|
464
|
+
.map((r) => r.value!);
|
|
373
465
|
const scaleOverride = resolved.directives.scale;
|
|
374
|
-
const rampMin = scaleOverride ? scaleOverride.min : Math.min(...
|
|
375
|
-
const rampMax = scaleOverride ? scaleOverride.max : Math.max(...
|
|
376
|
-
//
|
|
377
|
-
// (palette.primary is a blue in most palettes and would blend in).
|
|
378
|
-
|
|
379
|
-
const
|
|
466
|
+
const rampMin = scaleOverride ? scaleOverride.min : Math.min(...values);
|
|
467
|
+
const rampMax = scaleOverride ? scaleOverride.max : Math.max(...values);
|
|
468
|
+
// Value ramp defaults to red so valued regions stand out against the blue
|
|
469
|
+
// water (palette.primary is a blue in most palettes and would blend in). A
|
|
470
|
+
// trailing color on `region-metric` (§24B.3) overrides the hue idiomatically.
|
|
471
|
+
const rampHue =
|
|
472
|
+
resolveColor(resolved.directives.regionMetricColor ?? '', palette) ??
|
|
473
|
+
palette.colors.red;
|
|
474
|
+
const hasRamp = values.length > 0;
|
|
380
475
|
|
|
381
|
-
// Colouring dimension (AR4, bivariate): the
|
|
382
|
-
// mutually-exclusive selectable groups. `
|
|
383
|
-
// (the metric label, or "
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
476
|
+
// Colouring dimension (AR4, bivariate): the value ramp and each tag group are
|
|
477
|
+
// mutually-exclusive selectable groups. `VALUE_NAME` is the ramp's group name
|
|
478
|
+
// (the region-metric label, or "Value"). Exactly one dimension is active and
|
|
479
|
+
// drives every region's fill. The value ramp is the default-active dimension
|
|
480
|
+
// whenever any region has a value (the old `active-tag score` token is gone —
|
|
481
|
+
// there is nothing to force; selecting a tag group is what `active-tag` does).
|
|
482
|
+
const VALUE_NAME = hasRamp
|
|
483
|
+
? resolved.directives.regionMetric?.trim() || 'Value'
|
|
387
484
|
: null;
|
|
388
485
|
const matchColorGroup = (v: string): string | null => {
|
|
389
486
|
const lv = v.trim().toLowerCase();
|
|
390
487
|
if (lv === 'none') return null;
|
|
391
|
-
if (
|
|
392
|
-
return SCORE_NAME;
|
|
488
|
+
if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
|
|
393
489
|
const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
394
490
|
return tg ? tg.name : v; // unknown name passes through → renders neutral
|
|
395
491
|
};
|
|
@@ -400,13 +496,42 @@ export function layoutMap(
|
|
|
400
496
|
} else if (resolved.directives.activeTag !== undefined) {
|
|
401
497
|
activeGroup = matchColorGroup(resolved.directives.activeTag);
|
|
402
498
|
} else {
|
|
403
|
-
// Default: colour by
|
|
404
|
-
//
|
|
499
|
+
// Default: colour by the value ramp when values exist, else the first
|
|
500
|
+
// declared tag group.
|
|
405
501
|
activeGroup =
|
|
406
|
-
|
|
502
|
+
VALUE_NAME ??
|
|
407
503
|
(resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
|
|
408
504
|
}
|
|
409
|
-
const activeIsScore =
|
|
505
|
+
const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
506
|
+
|
|
507
|
+
// Basemap dress. Subject water + land always wear the SAME faded blue/green
|
|
508
|
+
// dress (subtle enough that saturated tag/score tints never blend into it), so
|
|
509
|
+
// every map looks consistent. `mutedBasemap` now governs only the NEIGHBOUR
|
|
510
|
+
// land: when a colouring dimension is active (or `muted` is forced) the
|
|
511
|
+
// surrounding world recedes to a paler gray so the subject + its data fills
|
|
512
|
+
// dominate; a plain reference map keeps neighbour land at the fuller gray. The
|
|
513
|
+
// bare `muted` / `natural` flags force either neighbour treatment regardless
|
|
514
|
+
// (so two maps in a deck can match); absent → this auto rule.
|
|
515
|
+
const mutedBasemap =
|
|
516
|
+
resolved.directives.basemapStyle === 'muted'
|
|
517
|
+
? true
|
|
518
|
+
: resolved.directives.basemapStyle === 'natural'
|
|
519
|
+
? false
|
|
520
|
+
: activeGroup !== null;
|
|
521
|
+
const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
|
|
522
|
+
const water = mapBackgroundColor(palette, isDark, mutedBasemap);
|
|
523
|
+
const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
|
|
524
|
+
const foreignFill = mix(
|
|
525
|
+
palette.colors.gray,
|
|
526
|
+
palette.bg,
|
|
527
|
+
mutedBasemap
|
|
528
|
+
? isDark
|
|
529
|
+
? MUTED_FOREIGN_DARK
|
|
530
|
+
: MUTED_FOREIGN_LIGHT
|
|
531
|
+
: isDark
|
|
532
|
+
? FOREIGN_TINT_DARK
|
|
533
|
+
: FOREIGN_TINT_LIGHT
|
|
534
|
+
);
|
|
410
535
|
|
|
411
536
|
// Score ramp base: a NEUTRAL tint of the page, NOT the (green) land colour —
|
|
412
537
|
// blending red toward green produced muddy brown mid-tones that blurred into
|
|
@@ -415,7 +540,7 @@ export function layoutMap(
|
|
|
415
540
|
// off the near-black surface so the lowest scores read as a clear muted red
|
|
416
541
|
// rather than sinking to maroon-black.
|
|
417
542
|
const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
|
|
418
|
-
const
|
|
543
|
+
const fillForValue = (s: number): string => {
|
|
419
544
|
const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
|
|
420
545
|
const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
|
|
421
546
|
return mix(rampHue, rampBase, pct);
|
|
@@ -450,15 +575,29 @@ export function layoutMap(
|
|
|
450
575
|
);
|
|
451
576
|
};
|
|
452
577
|
|
|
453
|
-
/** A
|
|
454
|
-
*
|
|
455
|
-
*
|
|
578
|
+
/** A §1.5 trailing-token color on a region/POI → flat categorical fill, the
|
|
579
|
+
* same saturated tint a tag entry gets (so direct colors and tag colors read
|
|
580
|
+
* alike). Resolves the NAME against the active palette; null if unrecognized. */
|
|
581
|
+
const directFill = (name: string | undefined): string | null => {
|
|
582
|
+
const hex = name ? resolveColor(name, palette) : null;
|
|
583
|
+
if (!hex) return null;
|
|
584
|
+
return mix(hex, palette.bg, isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** A region's fill. A direct trailing color (§24B.4) is a flat override that
|
|
588
|
+
* paints regardless of the active dimension (no legend entry). Otherwise the
|
|
589
|
+
* ACTIVE colouring dimension (AR4, bivariate): value-active → ramp for valued
|
|
590
|
+
* regions, neutral otherwise; a tag group active → that group's tag colour,
|
|
591
|
+
* neutral otherwise (value ignored). */
|
|
456
592
|
const regionFill = (r: {
|
|
457
|
-
|
|
593
|
+
value?: number;
|
|
594
|
+
color?: string;
|
|
458
595
|
tags: Readonly<Record<string, string>>;
|
|
459
596
|
}): string => {
|
|
597
|
+
const direct = directFill(r.color);
|
|
598
|
+
if (direct) return direct;
|
|
460
599
|
if (activeIsScore) {
|
|
461
|
-
return r.
|
|
600
|
+
return r.value !== undefined ? fillForValue(r.value) : neutralFill;
|
|
462
601
|
}
|
|
463
602
|
return tagFill(r.tags, activeGroup) ?? neutralFill;
|
|
464
603
|
};
|
|
@@ -558,6 +697,8 @@ export function layoutMap(
|
|
|
558
697
|
fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
|
|
559
698
|
let path: GeoPath;
|
|
560
699
|
let project: (lon: number, lat: number) => [number, number] | null;
|
|
700
|
+
// Captured for the geo-query (null unless this is a global stretch fit).
|
|
701
|
+
let stretchParams: MapLayoutStretch | null = null;
|
|
561
702
|
if (fitIsGlobal) {
|
|
562
703
|
const cb = geoPath(projection).bounds(fitTarget as never);
|
|
563
704
|
const bx0 = cb[0][0];
|
|
@@ -568,6 +709,7 @@ export function layoutMap(
|
|
|
568
709
|
const oy = fitBox[0][1];
|
|
569
710
|
const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
|
|
570
711
|
const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
|
|
712
|
+
stretchParams = { sx, sy, ox, oy, bx0, by0 };
|
|
571
713
|
const stretch = (x: number, y: number): [number, number] => [
|
|
572
714
|
ox + (x - bx0) * sx,
|
|
573
715
|
oy + (y - by0) * sy,
|
|
@@ -628,7 +770,11 @@ export function layoutMap(
|
|
|
628
770
|
name: string;
|
|
629
771
|
lineNumber: number;
|
|
630
772
|
}[] = [];
|
|
631
|
-
if (
|
|
773
|
+
if (
|
|
774
|
+
resolved.projection === 'albers-usa' &&
|
|
775
|
+
usLayer &&
|
|
776
|
+
!resolved.directives.noInsets
|
|
777
|
+
) {
|
|
632
778
|
const PAD = 8;
|
|
633
779
|
const GAP = 12; // px the top edge rides below the coast
|
|
634
780
|
const yB = height - FIT_PAD; // lowest a box may reach (canvas bottom pad)
|
|
@@ -666,53 +812,20 @@ export function layoutMap(
|
|
|
666
812
|
}
|
|
667
813
|
return y;
|
|
668
814
|
};
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
// land sample by GAP. Parallel → uniform, maximal clearance for how close it
|
|
672
|
-
// sits, tilting the way the coast tilts. Open-ocean samples are skipped, so a
|
|
673
|
-
// box reaching past the coast isn't dragged down by water. Falls back to a
|
|
674
|
-
// flat line just under the lowest land if the fit is underdetermined.
|
|
675
|
-
const coastTop = (x0: number, xr: number): ((x: number) => number) => {
|
|
815
|
+
// Lowest the coast reaches across [x0, xr], or -Infinity over open ocean.
|
|
816
|
+
const coastFloor = (x0: number, xr: number): number => {
|
|
676
817
|
const n = 24;
|
|
677
|
-
const pts: Array<[number, number]> = [];
|
|
678
818
|
let maxY = -Infinity;
|
|
679
819
|
for (let i = 0; i <= n; i++) {
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
if (y > -Infinity) {
|
|
683
|
-
pts.push([x, y]);
|
|
684
|
-
if (y > maxY) maxY = y;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
if (pts.length === 0) return () => yB - height * 0.42; // all ocean
|
|
688
|
-
let m = 0;
|
|
689
|
-
if (pts.length >= 2) {
|
|
690
|
-
let sx = 0,
|
|
691
|
-
sy = 0,
|
|
692
|
-
sxx = 0,
|
|
693
|
-
sxy = 0;
|
|
694
|
-
for (const [x, y] of pts) {
|
|
695
|
-
sx += x;
|
|
696
|
-
sy += y;
|
|
697
|
-
sxx += x * x;
|
|
698
|
-
sxy += x * y;
|
|
699
|
-
}
|
|
700
|
-
const den = pts.length * sxx - sx * sx;
|
|
701
|
-
if (den !== 0) m = (pts.length * sxy - sx * sy) / den;
|
|
702
|
-
}
|
|
703
|
-
// Cap the tilt so a steep coast (e.g. California's) doesn't turn the box
|
|
704
|
-
// into a tall triangle — keep it a compact, gently-angled quad.
|
|
705
|
-
m = Math.max(-0.35, Math.min(0.35, m));
|
|
706
|
-
let c = -Infinity; // raise the line until it clears every land sample + GAP
|
|
707
|
-
for (const [x, y] of pts) {
|
|
708
|
-
const need = y - m * x + GAP;
|
|
709
|
-
if (need > c) c = need;
|
|
820
|
+
const y = at(x0 + ((xr - x0) * i) / n);
|
|
821
|
+
if (y > maxY) maxY = y;
|
|
710
822
|
}
|
|
711
|
-
return
|
|
823
|
+
return maxY;
|
|
712
824
|
};
|
|
713
825
|
// A snug floating box that just contains the state, tucked up under the coast
|
|
714
|
-
// with a
|
|
715
|
-
// Returns the box's right edge so
|
|
826
|
+
// with a flat top sitting GAP below the lowest the coast reaches over its
|
|
827
|
+
// span. `iwReq` is the requested inner width. Returns the box's right edge so
|
|
828
|
+
// the next inset can sit beside it.
|
|
716
829
|
const placeInset = (
|
|
717
830
|
iso: string,
|
|
718
831
|
proj: GeoProjection,
|
|
@@ -726,23 +839,19 @@ export function layoutMap(
|
|
|
726
839
|
const iw = Math.min(iwReq, width - FIT_PAD - x0 - 2 * PAD);
|
|
727
840
|
if (iw < 24) return boxX; // canvas truly too narrow for another inset
|
|
728
841
|
const xr = x0 + iw + 2 * PAD;
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
const yR = top(xr);
|
|
842
|
+
const floor = coastFloor(x0, xr);
|
|
843
|
+
const topGuess = floor > -Infinity ? floor + GAP : yB - height * 0.42;
|
|
732
844
|
// Learn the state's height at this width, then size the box to just hold it.
|
|
733
845
|
proj.fitWidth(iw, f as never);
|
|
734
846
|
const bb = geoPath(proj).bounds(f as never);
|
|
735
847
|
const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
|
|
736
|
-
//
|
|
737
|
-
// wouldn't fit above yB, raise the top (
|
|
738
|
-
//
|
|
848
|
+
// Flat top sits just under the coast. If the coast runs so low the state
|
|
849
|
+
// wouldn't fit above yB, raise the top (it stays over ocean) — the box must
|
|
850
|
+
// never collapse and vanish.
|
|
739
851
|
const needH = sh + 2 * PAD;
|
|
740
|
-
let topFit =
|
|
852
|
+
let topFit = topGuess;
|
|
741
853
|
const bottom = Math.min(topFit + needH, yB);
|
|
742
854
|
if (bottom - topFit < needH) topFit = bottom - needH;
|
|
743
|
-
const lift = topFit - Math.max(yL, yR); // keep the slanted top straight
|
|
744
|
-
const topL = yL + lift;
|
|
745
|
-
const topR = yR + lift;
|
|
746
855
|
proj.fitExtent(
|
|
747
856
|
[
|
|
748
857
|
[x0 + PAD, topFit + PAD],
|
|
@@ -761,15 +870,18 @@ export function layoutMap(
|
|
|
761
870
|
}
|
|
762
871
|
insets.push({
|
|
763
872
|
x: x0,
|
|
764
|
-
y:
|
|
873
|
+
y: topFit,
|
|
765
874
|
w: xr - x0,
|
|
766
|
-
h: bottom -
|
|
875
|
+
h: bottom - topFit,
|
|
767
876
|
points: [
|
|
768
|
-
[x0,
|
|
769
|
-
[xr,
|
|
877
|
+
[x0, topFit],
|
|
878
|
+
[xr, topFit],
|
|
770
879
|
[xr, bottom],
|
|
771
880
|
[x0, bottom],
|
|
772
881
|
],
|
|
882
|
+
// The FITTED inset projection (just fit to this box) — captured so the
|
|
883
|
+
// geo-query can invert pixels inside the frame back to AK/HI coords.
|
|
884
|
+
projection: proj,
|
|
773
885
|
});
|
|
774
886
|
insetRegions.push({
|
|
775
887
|
id: iso,
|
|
@@ -778,7 +890,7 @@ export function layoutMap(
|
|
|
778
890
|
stroke: regionStroke,
|
|
779
891
|
lineNumber,
|
|
780
892
|
layer: 'us-state',
|
|
781
|
-
...(r?.
|
|
893
|
+
...(r?.value !== undefined && { value: r.value }),
|
|
782
894
|
...(r && Object.keys(r.tags).length > 0 && { tags: r.tags }),
|
|
783
895
|
});
|
|
784
896
|
const ctr = geoPath(proj).centroid(f as never);
|
|
@@ -1012,7 +1124,7 @@ export function layoutMap(
|
|
|
1012
1124
|
lineNumber,
|
|
1013
1125
|
layer,
|
|
1014
1126
|
...(label !== undefined && { label }),
|
|
1015
|
-
...(isThisLayer && r.
|
|
1127
|
+
...(isThisLayer && r.value !== undefined && { value: r.value }),
|
|
1016
1128
|
...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
|
|
1017
1129
|
});
|
|
1018
1130
|
}
|
|
@@ -1045,17 +1157,70 @@ export function layoutMap(
|
|
|
1045
1157
|
id: 'lake',
|
|
1046
1158
|
d,
|
|
1047
1159
|
fill: water,
|
|
1048
|
-
stroke:
|
|
1160
|
+
stroke: lakeStroke,
|
|
1049
1161
|
lineNumber: -1,
|
|
1050
1162
|
layer: 'base',
|
|
1051
1163
|
});
|
|
1052
1164
|
}
|
|
1053
1165
|
}
|
|
1054
1166
|
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1167
|
+
// Relief (notable mountain ranges) — horizontal hachure lines clipped to each
|
|
1168
|
+
// range, drawn over the base land and under rivers/POIs/data fills. Opt-in via
|
|
1169
|
+
// the `relief` flag; needs the optional `mountainRanges` asset. Each surviving
|
|
1170
|
+
// range is projected to a polygon path; the renderer unions them into a clip
|
|
1171
|
+
// and rules screen-spaced horizontal lines through it — a distinct texture
|
|
1172
|
+
// that reads as "mountains here" without elevation data. Ranges below a min
|
|
1173
|
+
// projected area/dimension are dropped (no slivers). Data-region suppression
|
|
1174
|
+
// (ADR-2) is handled at the RENDER clip — relief is clipped to land MINUS the
|
|
1175
|
+
// data-coloured regions, so a range that crosses a valued state still shows on
|
|
1176
|
+
// the un-valued land around it (a bbox drop here would nuke the whole range).
|
|
1177
|
+
const relief: MapLayoutRelief[] = [];
|
|
1178
|
+
let reliefHatch: MapLayoutReliefHatch | null = null;
|
|
1179
|
+
if (resolved.directives.relief === true && data.mountainRanges) {
|
|
1180
|
+
for (const [, f] of decodeLayer(data.mountainRanges)) {
|
|
1181
|
+
const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
|
|
1182
|
+
if (!viewF) continue;
|
|
1183
|
+
const area = path.area(viewF as never);
|
|
1184
|
+
if (!Number.isFinite(area) || area < RELIEF_MIN_AREA) continue;
|
|
1185
|
+
const box = path.bounds(viewF as never) as [
|
|
1186
|
+
[number, number],
|
|
1187
|
+
[number, number],
|
|
1188
|
+
];
|
|
1189
|
+
if (
|
|
1190
|
+
box[1][0] - box[0][0] < RELIEF_MIN_DIM ||
|
|
1191
|
+
box[1][1] - box[0][1] < RELIEF_MIN_DIM
|
|
1192
|
+
)
|
|
1193
|
+
continue;
|
|
1194
|
+
const d = path(viewF as never) ?? '';
|
|
1195
|
+
if (!d) continue;
|
|
1196
|
+
relief.push({ d });
|
|
1197
|
+
}
|
|
1198
|
+
if (relief.length) {
|
|
1199
|
+
// Prefer DARK hachure (blend land toward the dark tone — bg on dark
|
|
1200
|
+
// themes, text on light). But on a muted/data map the un-valued land is
|
|
1201
|
+
// already near-black, so darkness can't show: if the dark tone barely
|
|
1202
|
+
// differs from the land, flip to the light tone so the lines stay visible.
|
|
1203
|
+
const darkTone = isDark ? palette.bg : palette.text;
|
|
1204
|
+
const lightTone = isDark ? palette.text : palette.bg;
|
|
1205
|
+
const landLum = relativeLuminance(neutralFill);
|
|
1206
|
+
const tone =
|
|
1207
|
+
Math.abs(landLum - relativeLuminance(darkTone)) > 0.04
|
|
1208
|
+
? darkTone
|
|
1209
|
+
: lightTone;
|
|
1210
|
+
reliefHatch = {
|
|
1211
|
+
color: mix(tone, neutralFill, RELIEF_HATCH_STRENGTH),
|
|
1212
|
+
spacing: RELIEF_HATCH_SPACING,
|
|
1213
|
+
width: RELIEF_HATCH_WIDTH,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land.
|
|
1219
|
+
// Nudged slightly toward the border tone (off flat `water`) so the line reads
|
|
1220
|
+
// as a deliberate water course rather than a gap where it crosses a border —
|
|
1221
|
+
// in muted/data mode flat water is a pale gray that just looks like a broken
|
|
1222
|
+
// boundary. Open paths: stroked, no fill; under POIs/edges/labels.
|
|
1223
|
+
const riverColor = mix(water, regionStroke, 16);
|
|
1059
1224
|
const rivers: MapLayoutRiver[] = [];
|
|
1060
1225
|
if (data.rivers) {
|
|
1061
1226
|
for (const [, f] of decodeLayer(data.rivers)) {
|
|
@@ -1067,14 +1232,14 @@ export function layoutMap(
|
|
|
1067
1232
|
}
|
|
1068
1233
|
}
|
|
1069
1234
|
|
|
1070
|
-
// -- POIs: project, size-scale, co-located spiderfy --
|
|
1235
|
+
// -- POIs: project, value→size-scale, co-located spiderfy --
|
|
1071
1236
|
const sizeVals = resolved.pois
|
|
1072
|
-
.map((p) => Number(p.meta['
|
|
1237
|
+
.map((p) => Number(p.meta['value']))
|
|
1073
1238
|
.filter((n) => Number.isFinite(n) && n > 0);
|
|
1074
1239
|
const sizeMin = sizeVals.length ? Math.min(...sizeVals) : 0;
|
|
1075
1240
|
const sizeMax = sizeVals.length ? Math.max(...sizeVals) : 0;
|
|
1076
1241
|
const radiusFor = (p: ResolvedPoi): number => {
|
|
1077
|
-
const v = Number(p.meta['
|
|
1242
|
+
const v = Number(p.meta['value']);
|
|
1078
1243
|
if (!Number.isFinite(v) || v <= 0 || sizeMax <= 0) return R_DEFAULT;
|
|
1079
1244
|
// sqrt so AREA encodes the value
|
|
1080
1245
|
const t =
|
|
@@ -1085,8 +1250,12 @@ export function layoutMap(
|
|
|
1085
1250
|
return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
|
|
1086
1251
|
};
|
|
1087
1252
|
|
|
1088
|
-
// POI
|
|
1253
|
+
// POI fill precedence (§24B.5): a direct §1.5 trailing color wins, then the
|
|
1254
|
+
// FIRST declared tag group for which the POI has a value (AR4), then orange.
|
|
1089
1255
|
const poiFill = (p: ResolvedPoi): { fill: string; stroke: string } => {
|
|
1256
|
+
const directHex = p.color ? resolveColor(p.color, palette) : null;
|
|
1257
|
+
if (directHex)
|
|
1258
|
+
return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
|
|
1090
1259
|
for (const group of resolved.tagGroups) {
|
|
1091
1260
|
const val = p.tags[group.name.toLowerCase()];
|
|
1092
1261
|
if (!val) continue;
|
|
@@ -1160,6 +1329,7 @@ export function layoutMap(
|
|
|
1160
1329
|
implicit: !!e.p.implicit,
|
|
1161
1330
|
isOrigin: originIds.has(e.p.id),
|
|
1162
1331
|
...(num !== undefined && { routeNumber: num }),
|
|
1332
|
+
...(Object.keys(e.p.tags).length > 0 && { tags: e.p.tags }),
|
|
1163
1333
|
});
|
|
1164
1334
|
});
|
|
1165
1335
|
}
|
|
@@ -1208,31 +1378,50 @@ export function layoutMap(
|
|
|
1208
1378
|
return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
|
|
1209
1379
|
};
|
|
1210
1380
|
|
|
1211
|
-
// Routes:
|
|
1381
|
+
// Routes: each leg is an edge (fromId → toId) carrying its own label,
|
|
1382
|
+
// value→thickness, and arc shape. Loop-closing legs are explicit in `rt.legs`;
|
|
1383
|
+
// the origin is never double-marked because `stopIds` is unique.
|
|
1384
|
+
const routeLegVals = resolved.routes
|
|
1385
|
+
.flatMap((rt) => rt.legs)
|
|
1386
|
+
.map((l) => Number(l.value))
|
|
1387
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
1388
|
+
const rlMin = routeLegVals.length ? Math.min(...routeLegVals) : 0;
|
|
1389
|
+
const rlMax = routeLegVals.length ? Math.max(...routeLegVals) : 0;
|
|
1390
|
+
const routeWidthFor = (v: number): number => {
|
|
1391
|
+
if (!Number.isFinite(v) || v <= 0 || rlMax <= 0) return W_MIN;
|
|
1392
|
+
const t = rlMax > rlMin ? (v - rlMin) / (rlMax - rlMin) : 1;
|
|
1393
|
+
return W_MIN + t * (W_MAX - W_MIN);
|
|
1394
|
+
};
|
|
1212
1395
|
for (const rt of resolved.routes) {
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1215
|
-
const
|
|
1216
|
-
const b = poiScreen.get(rt.stopIds[i]!);
|
|
1396
|
+
for (const leg of rt.legs) {
|
|
1397
|
+
const a = poiScreen.get(leg.fromId);
|
|
1398
|
+
const b = poiScreen.get(leg.toId);
|
|
1217
1399
|
if (!a || !b) continue;
|
|
1400
|
+
const mx = (a.cx + b.cx) / 2;
|
|
1401
|
+
const my = (a.cy + b.cy) / 2;
|
|
1218
1402
|
legs.push({
|
|
1219
|
-
d: legPath(a, b,
|
|
1220
|
-
width:
|
|
1403
|
+
d: legPath(a, b, leg.style === 'arc', 0),
|
|
1404
|
+
width: routeWidthFor(Number(leg.value)),
|
|
1221
1405
|
color: mix(palette.text, palette.bg, 72),
|
|
1222
1406
|
arrow: true,
|
|
1223
|
-
lineNumber:
|
|
1407
|
+
lineNumber: leg.lineNumber,
|
|
1408
|
+
...(leg.label !== undefined && {
|
|
1409
|
+
label: leg.label,
|
|
1410
|
+
labelX: mx,
|
|
1411
|
+
labelY: my - 4,
|
|
1412
|
+
}),
|
|
1224
1413
|
});
|
|
1225
1414
|
}
|
|
1226
1415
|
}
|
|
1227
1416
|
|
|
1228
1417
|
// Edges: group by unordered endpoint pair for deterministic fan-out (AR9).
|
|
1229
1418
|
const weightVals = resolved.edges
|
|
1230
|
-
.map((e) => Number(e.meta['
|
|
1419
|
+
.map((e) => Number(e.meta['value']))
|
|
1231
1420
|
.filter((n) => Number.isFinite(n) && n > 0);
|
|
1232
1421
|
const wMin = weightVals.length ? Math.min(...weightVals) : 0;
|
|
1233
1422
|
const wMax = weightVals.length ? Math.max(...weightVals) : 0;
|
|
1234
1423
|
const widthFor = (e: ResolvedEdge): number => {
|
|
1235
|
-
const v = Number(e.meta['
|
|
1424
|
+
const v = Number(e.meta['value']);
|
|
1236
1425
|
if (!Number.isFinite(v) || v <= 0 || wMax <= 0) return W_MIN;
|
|
1237
1426
|
const t = wMax > wMin ? (v - wMin) / (wMax - wMin) : 1;
|
|
1238
1427
|
return W_MIN + t * (W_MAX - W_MIN);
|
|
@@ -1566,17 +1755,18 @@ export function layoutMap(
|
|
|
1566
1755
|
name: g.name,
|
|
1567
1756
|
entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
1568
1757
|
}));
|
|
1569
|
-
// Only the colouring dimensions (
|
|
1570
|
-
// POI size and edge
|
|
1571
|
-
// intentionally carry no key
|
|
1758
|
+
// Only the colouring dimensions (value ramp + tag groups) get a legend.
|
|
1759
|
+
// POI size and edge thickness are self-evident from the marker/line scale and
|
|
1760
|
+
// intentionally carry no key (the poi-metric/flow-metric labels are captured
|
|
1761
|
+
// for future use but not rendered as legend keys in v1).
|
|
1572
1762
|
if (tagGroups.length > 0 || hasRamp) {
|
|
1573
1763
|
legend = {
|
|
1574
1764
|
tagGroups,
|
|
1575
1765
|
activeGroup,
|
|
1576
1766
|
...(hasRamp && {
|
|
1577
1767
|
ramp: {
|
|
1578
|
-
...(resolved.directives.
|
|
1579
|
-
metric: resolved.directives.
|
|
1768
|
+
...(resolved.directives.regionMetric !== undefined && {
|
|
1769
|
+
metric: resolved.directives.regionMetric,
|
|
1580
1770
|
}),
|
|
1581
1771
|
min: rampMin,
|
|
1582
1772
|
max: rampMax,
|
|
@@ -1597,11 +1787,15 @@ export function layoutMap(
|
|
|
1597
1787
|
...(resolved.caption !== undefined && { caption: resolved.caption }),
|
|
1598
1788
|
regions,
|
|
1599
1789
|
rivers,
|
|
1790
|
+
relief,
|
|
1791
|
+
reliefHatch,
|
|
1600
1792
|
legs,
|
|
1601
1793
|
pois,
|
|
1602
1794
|
labels,
|
|
1603
1795
|
legend,
|
|
1604
1796
|
insets,
|
|
1605
1797
|
insetRegions,
|
|
1798
|
+
projection,
|
|
1799
|
+
stretch: stretchParams,
|
|
1606
1800
|
};
|
|
1607
1801
|
}
|