@diagrammo/dgmo 0.21.0 → 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 +556 -195
- package/dist/advanced.js +555 -195
- package/dist/auto.cjs +322 -196
- package/dist/auto.js +113 -113
- package/dist/auto.mjs +322 -196
- package/dist/cli.cjs +156 -156
- package/dist/editor.cjs +1 -0
- package/dist/editor.js +1 -0
- package/dist/highlight.cjs +1 -0
- package/dist/highlight.js +1 -0
- package/dist/index.cjs +320 -195
- package/dist/index.js +320 -195
- package/dist/internal.cjs +556 -195
- package/dist/internal.js +555 -195
- 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-direct-color.dgmo +10 -0
- package/package.json +1 -1
- package/src/advanced.ts +14 -0
- package/src/completion.ts +1 -0
- package/src/d3.ts +15 -9
- package/src/editor/keywords.ts +1 -0
- 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 +233 -113
- package/src/map/load-data.ts +7 -1
- package/src/map/parser.ts +22 -2
- package/src/map/renderer.ts +44 -0
- package/src/map/resolved-types.ts +8 -0
- package/src/map/resolver.ts +40 -19
- package/src/map/types.ts +18 -0
- package/dist/advanced.d.cts +0 -5331
- package/dist/advanced.d.ts +0 -5331
- 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 -5331
- package/dist/internal.d.ts +0 -5331
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,36 +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;
|
|
76
100
|
// MUTED basemap — used when a colouring dimension (score ramp or a tag group) is
|
|
77
|
-
// active. The
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
// fills as the only saturated thing on the map (the cartographic norm for a
|
|
83
|
-
// choropleth). Plain reference maps with no data keep the blue/green dress.
|
|
84
|
-
// Light land is left at the page bg (cleanest white ground for the data hues);
|
|
85
|
-
// dark land lifts off the near-black surface so dark-mixed tints stay legible.
|
|
86
|
-
const MUTED_WATER_LIGHT = 14; // % gray of bg — pale sea
|
|
87
|
-
const MUTED_WATER_DARK = 10;
|
|
88
|
-
const MUTED_FOREIGN_LIGHT = 28; // neighbour land — grayer than the sea
|
|
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
|
|
89
106
|
const MUTED_FOREIGN_DARK = 16;
|
|
90
|
-
const MUTED_LAND_DARK = 24; // subject land on dark (light land = palette.bg)
|
|
91
107
|
const COLO_R = 9; // spiderfy radius
|
|
92
108
|
const GOLDEN_ANGLE = 2.399963229728653; // rad (137.5deg) -- even spiral, no random
|
|
93
109
|
const FAN_STEP = 16; // px perpendicular offset between parallel edges
|
|
@@ -121,6 +137,24 @@ export interface MapLayoutInset {
|
|
|
121
137
|
readonly w: number;
|
|
122
138
|
readonly h: number;
|
|
123
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;
|
|
124
158
|
}
|
|
125
159
|
|
|
126
160
|
export interface MapLayoutPoi {
|
|
@@ -194,6 +228,23 @@ export interface MapLayoutRiver {
|
|
|
194
228
|
readonly width: number;
|
|
195
229
|
}
|
|
196
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
|
+
|
|
197
248
|
export interface MapLayout {
|
|
198
249
|
readonly width: number;
|
|
199
250
|
readonly height: number;
|
|
@@ -204,6 +255,12 @@ export interface MapLayout {
|
|
|
204
255
|
readonly regions: readonly MapLayoutRegion[];
|
|
205
256
|
/** Major river centerlines, drawn over land/lakes and under POIs/edges. */
|
|
206
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;
|
|
207
264
|
readonly legs: readonly MapLayoutLeg[];
|
|
208
265
|
readonly pois: readonly MapLayoutPoi[];
|
|
209
266
|
readonly labels: readonly PlacedLabel[];
|
|
@@ -213,6 +270,12 @@ export interface MapLayout {
|
|
|
213
270
|
/** AK/HI region paths drawn inside the inset boxes (foreground, over an
|
|
214
271
|
* opaque ocean fill). Paired positionally with `insets`. */
|
|
215
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;
|
|
216
279
|
}
|
|
217
280
|
|
|
218
281
|
export interface LayoutOptions {
|
|
@@ -294,37 +357,34 @@ const US_NON_CONUS = new Set([
|
|
|
294
357
|
|
|
295
358
|
/** The map's water / backdrop colour for a palette — the single source of truth
|
|
296
359
|
* shared by the renderer's `<rect>` fill and any host wrapper that needs to
|
|
297
|
-
* match it (so letterbox gaps around the SVG don't show a stray band).
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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`). */
|
|
300
365
|
export function mapBackgroundColor(
|
|
301
366
|
palette: PaletteColors,
|
|
302
367
|
isDark = false,
|
|
303
|
-
|
|
368
|
+
_dataActive = false
|
|
304
369
|
): string {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
311
|
-
return mix(palette.colors.blue, palette.bg, WATER_TINT);
|
|
370
|
+
return mix(
|
|
371
|
+
palette.colors.blue,
|
|
372
|
+
palette.bg,
|
|
373
|
+
isDark ? WATER_TINT_DARK : WATER_TINT_LIGHT
|
|
374
|
+
);
|
|
312
375
|
}
|
|
313
376
|
|
|
314
377
|
/** The map's neutral (unscored/untagged) LAND colour — the base every region
|
|
315
378
|
* blends from. Exported so a host can DIM a region to plain land (rather than
|
|
316
379
|
* lowering opacity, which would let the water show through and make the shape
|
|
317
|
-
* read as ocean). Matches the layout's `neutralFill`.
|
|
318
|
-
*
|
|
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. */
|
|
319
383
|
export function mapNeutralLandColor(
|
|
320
384
|
palette: PaletteColors,
|
|
321
385
|
isDark: boolean,
|
|
322
|
-
|
|
386
|
+
_dataActive = false
|
|
323
387
|
): string {
|
|
324
|
-
if (dataActive)
|
|
325
|
-
return isDark
|
|
326
|
-
? mix(palette.colors.gray, palette.bg, MUTED_LAND_DARK)
|
|
327
|
-
: palette.bg;
|
|
328
388
|
return mix(
|
|
329
389
|
palette.colors.green,
|
|
330
390
|
palette.bg,
|
|
@@ -389,6 +449,14 @@ export function layoutMap(
|
|
|
389
449
|
const regionStroke = isDark
|
|
390
450
|
? mix(palette.bg, palette.text, 78) // dark theme: near-bg dark outline
|
|
391
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.
|
|
392
460
|
|
|
393
461
|
// -- Region fill model (choropleth + categorical; AR4/AR6) --
|
|
394
462
|
const values = resolved.regions
|
|
@@ -397,9 +465,12 @@ export function layoutMap(
|
|
|
397
465
|
const scaleOverride = resolved.directives.scale;
|
|
398
466
|
const rampMin = scaleOverride ? scaleOverride.min : Math.min(...values);
|
|
399
467
|
const rampMax = scaleOverride ? scaleOverride.max : Math.max(...values);
|
|
400
|
-
// Value ramp
|
|
401
|
-
// (palette.primary is a blue in most palettes and would blend in).
|
|
402
|
-
|
|
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;
|
|
403
474
|
const hasRamp = values.length > 0;
|
|
404
475
|
|
|
405
476
|
// Colouring dimension (AR4, bivariate): the value ramp and each tag group are
|
|
@@ -433,14 +504,14 @@ export function layoutMap(
|
|
|
433
504
|
}
|
|
434
505
|
const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
435
506
|
|
|
436
|
-
// Basemap dress.
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
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.
|
|
444
515
|
const mutedBasemap =
|
|
445
516
|
resolved.directives.basemapStyle === 'muted'
|
|
446
517
|
? true
|
|
@@ -449,6 +520,7 @@ export function layoutMap(
|
|
|
449
520
|
: activeGroup !== null;
|
|
450
521
|
const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
|
|
451
522
|
const water = mapBackgroundColor(palette, isDark, mutedBasemap);
|
|
523
|
+
const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
|
|
452
524
|
const foreignFill = mix(
|
|
453
525
|
palette.colors.gray,
|
|
454
526
|
palette.bg,
|
|
@@ -503,13 +575,27 @@ export function layoutMap(
|
|
|
503
575
|
);
|
|
504
576
|
};
|
|
505
577
|
|
|
506
|
-
/** A
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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). */
|
|
509
592
|
const regionFill = (r: {
|
|
510
593
|
value?: number;
|
|
594
|
+
color?: string;
|
|
511
595
|
tags: Readonly<Record<string, string>>;
|
|
512
596
|
}): string => {
|
|
597
|
+
const direct = directFill(r.color);
|
|
598
|
+
if (direct) return direct;
|
|
513
599
|
if (activeIsScore) {
|
|
514
600
|
return r.value !== undefined ? fillForValue(r.value) : neutralFill;
|
|
515
601
|
}
|
|
@@ -611,6 +697,8 @@ export function layoutMap(
|
|
|
611
697
|
fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
|
|
612
698
|
let path: GeoPath;
|
|
613
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;
|
|
614
702
|
if (fitIsGlobal) {
|
|
615
703
|
const cb = geoPath(projection).bounds(fitTarget as never);
|
|
616
704
|
const bx0 = cb[0][0];
|
|
@@ -621,6 +709,7 @@ export function layoutMap(
|
|
|
621
709
|
const oy = fitBox[0][1];
|
|
622
710
|
const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
|
|
623
711
|
const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
|
|
712
|
+
stretchParams = { sx, sy, ox, oy, bx0, by0 };
|
|
624
713
|
const stretch = (x: number, y: number): [number, number] => [
|
|
625
714
|
ox + (x - bx0) * sx,
|
|
626
715
|
oy + (y - by0) * sy,
|
|
@@ -681,7 +770,11 @@ export function layoutMap(
|
|
|
681
770
|
name: string;
|
|
682
771
|
lineNumber: number;
|
|
683
772
|
}[] = [];
|
|
684
|
-
if (
|
|
773
|
+
if (
|
|
774
|
+
resolved.projection === 'albers-usa' &&
|
|
775
|
+
usLayer &&
|
|
776
|
+
!resolved.directives.noInsets
|
|
777
|
+
) {
|
|
685
778
|
const PAD = 8;
|
|
686
779
|
const GAP = 12; // px the top edge rides below the coast
|
|
687
780
|
const yB = height - FIT_PAD; // lowest a box may reach (canvas bottom pad)
|
|
@@ -719,53 +812,20 @@ export function layoutMap(
|
|
|
719
812
|
}
|
|
720
813
|
return y;
|
|
721
814
|
};
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
// land sample by GAP. Parallel → uniform, maximal clearance for how close it
|
|
725
|
-
// sits, tilting the way the coast tilts. Open-ocean samples are skipped, so a
|
|
726
|
-
// box reaching past the coast isn't dragged down by water. Falls back to a
|
|
727
|
-
// flat line just under the lowest land if the fit is underdetermined.
|
|
728
|
-
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 => {
|
|
729
817
|
const n = 24;
|
|
730
|
-
const pts: Array<[number, number]> = [];
|
|
731
818
|
let maxY = -Infinity;
|
|
732
819
|
for (let i = 0; i <= n; i++) {
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
if (y > -Infinity) {
|
|
736
|
-
pts.push([x, y]);
|
|
737
|
-
if (y > maxY) maxY = y;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
if (pts.length === 0) return () => yB - height * 0.42; // all ocean
|
|
741
|
-
let m = 0;
|
|
742
|
-
if (pts.length >= 2) {
|
|
743
|
-
let sx = 0,
|
|
744
|
-
sy = 0,
|
|
745
|
-
sxx = 0,
|
|
746
|
-
sxy = 0;
|
|
747
|
-
for (const [x, y] of pts) {
|
|
748
|
-
sx += x;
|
|
749
|
-
sy += y;
|
|
750
|
-
sxx += x * x;
|
|
751
|
-
sxy += x * y;
|
|
752
|
-
}
|
|
753
|
-
const den = pts.length * sxx - sx * sx;
|
|
754
|
-
if (den !== 0) m = (pts.length * sxy - sx * sy) / den;
|
|
755
|
-
}
|
|
756
|
-
// Cap the tilt so a steep coast (e.g. California's) doesn't turn the box
|
|
757
|
-
// into a tall triangle — keep it a compact, gently-angled quad.
|
|
758
|
-
m = Math.max(-0.35, Math.min(0.35, m));
|
|
759
|
-
let c = -Infinity; // raise the line until it clears every land sample + GAP
|
|
760
|
-
for (const [x, y] of pts) {
|
|
761
|
-
const need = y - m * x + GAP;
|
|
762
|
-
if (need > c) c = need;
|
|
820
|
+
const y = at(x0 + ((xr - x0) * i) / n);
|
|
821
|
+
if (y > maxY) maxY = y;
|
|
763
822
|
}
|
|
764
|
-
return
|
|
823
|
+
return maxY;
|
|
765
824
|
};
|
|
766
825
|
// A snug floating box that just contains the state, tucked up under the coast
|
|
767
|
-
// with a
|
|
768
|
-
// 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.
|
|
769
829
|
const placeInset = (
|
|
770
830
|
iso: string,
|
|
771
831
|
proj: GeoProjection,
|
|
@@ -779,23 +839,19 @@ export function layoutMap(
|
|
|
779
839
|
const iw = Math.min(iwReq, width - FIT_PAD - x0 - 2 * PAD);
|
|
780
840
|
if (iw < 24) return boxX; // canvas truly too narrow for another inset
|
|
781
841
|
const xr = x0 + iw + 2 * PAD;
|
|
782
|
-
const
|
|
783
|
-
const
|
|
784
|
-
const yR = top(xr);
|
|
842
|
+
const floor = coastFloor(x0, xr);
|
|
843
|
+
const topGuess = floor > -Infinity ? floor + GAP : yB - height * 0.42;
|
|
785
844
|
// Learn the state's height at this width, then size the box to just hold it.
|
|
786
845
|
proj.fitWidth(iw, f as never);
|
|
787
846
|
const bb = geoPath(proj).bounds(f as never);
|
|
788
847
|
const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
|
|
789
|
-
//
|
|
790
|
-
// wouldn't fit above yB, raise the top (
|
|
791
|
-
//
|
|
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.
|
|
792
851
|
const needH = sh + 2 * PAD;
|
|
793
|
-
let topFit =
|
|
852
|
+
let topFit = topGuess;
|
|
794
853
|
const bottom = Math.min(topFit + needH, yB);
|
|
795
854
|
if (bottom - topFit < needH) topFit = bottom - needH;
|
|
796
|
-
const lift = topFit - Math.max(yL, yR); // keep the slanted top straight
|
|
797
|
-
const topL = yL + lift;
|
|
798
|
-
const topR = yR + lift;
|
|
799
855
|
proj.fitExtent(
|
|
800
856
|
[
|
|
801
857
|
[x0 + PAD, topFit + PAD],
|
|
@@ -814,15 +870,18 @@ export function layoutMap(
|
|
|
814
870
|
}
|
|
815
871
|
insets.push({
|
|
816
872
|
x: x0,
|
|
817
|
-
y:
|
|
873
|
+
y: topFit,
|
|
818
874
|
w: xr - x0,
|
|
819
|
-
h: bottom -
|
|
875
|
+
h: bottom - topFit,
|
|
820
876
|
points: [
|
|
821
|
-
[x0,
|
|
822
|
-
[xr,
|
|
877
|
+
[x0, topFit],
|
|
878
|
+
[xr, topFit],
|
|
823
879
|
[xr, bottom],
|
|
824
880
|
[x0, bottom],
|
|
825
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,
|
|
826
885
|
});
|
|
827
886
|
insetRegions.push({
|
|
828
887
|
id: iso,
|
|
@@ -1098,17 +1157,70 @@ export function layoutMap(
|
|
|
1098
1157
|
id: 'lake',
|
|
1099
1158
|
d,
|
|
1100
1159
|
fill: water,
|
|
1101
|
-
stroke:
|
|
1160
|
+
stroke: lakeStroke,
|
|
1102
1161
|
lineNumber: -1,
|
|
1103
1162
|
layer: 'base',
|
|
1104
1163
|
});
|
|
1105
1164
|
}
|
|
1106
1165
|
}
|
|
1107
1166
|
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
|
|
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);
|
|
1112
1224
|
const rivers: MapLayoutRiver[] = [];
|
|
1113
1225
|
if (data.rivers) {
|
|
1114
1226
|
for (const [, f] of decodeLayer(data.rivers)) {
|
|
@@ -1138,8 +1250,12 @@ export function layoutMap(
|
|
|
1138
1250
|
return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
|
|
1139
1251
|
};
|
|
1140
1252
|
|
|
1141
|
-
// 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.
|
|
1142
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) };
|
|
1143
1259
|
for (const group of resolved.tagGroups) {
|
|
1144
1260
|
const val = p.tags[group.name.toLowerCase()];
|
|
1145
1261
|
if (!val) continue;
|
|
@@ -1671,11 +1787,15 @@ export function layoutMap(
|
|
|
1671
1787
|
...(resolved.caption !== undefined && { caption: resolved.caption }),
|
|
1672
1788
|
regions,
|
|
1673
1789
|
rivers,
|
|
1790
|
+
relief,
|
|
1791
|
+
reliefHatch,
|
|
1674
1792
|
legs,
|
|
1675
1793
|
pois,
|
|
1676
1794
|
labels,
|
|
1677
1795
|
legend,
|
|
1678
1796
|
insets,
|
|
1679
1797
|
insetRegions,
|
|
1798
|
+
projection,
|
|
1799
|
+
stretch: stretchParams,
|
|
1680
1800
|
};
|
|
1681
1801
|
}
|
package/src/map/load-data.ts
CHANGED
|
@@ -43,6 +43,7 @@ const FILES = {
|
|
|
43
43
|
usStates: 'us-states.json',
|
|
44
44
|
lakes: 'lakes.json',
|
|
45
45
|
rivers: 'rivers.json',
|
|
46
|
+
mountainRanges: 'mountain-ranges.json',
|
|
46
47
|
naLand: 'na-land.json',
|
|
47
48
|
naLakes: 'na-lakes.json',
|
|
48
49
|
gazetteer: 'gazetteer.json',
|
|
@@ -131,6 +132,7 @@ export function loadMapData(): Promise<MapData> {
|
|
|
131
132
|
usStates,
|
|
132
133
|
lakes,
|
|
133
134
|
rivers,
|
|
135
|
+
mountainRanges,
|
|
134
136
|
naLand,
|
|
135
137
|
naLakes,
|
|
136
138
|
gazetteer,
|
|
@@ -138,9 +140,12 @@ export function loadMapData(): Promise<MapData> {
|
|
|
138
140
|
readJson<BoundaryTopology>(nb, dir, FILES.worldCoarse),
|
|
139
141
|
readJson<BoundaryTopology>(nb, dir, FILES.worldDetail),
|
|
140
142
|
readJson<BoundaryTopology>(nb, dir, FILES.usStates),
|
|
141
|
-
// Lakes/rivers/NA assets are optional — older bundles may predate them.
|
|
143
|
+
// Lakes/rivers/mountain/NA assets are optional — older bundles may predate them.
|
|
142
144
|
readJson<BoundaryTopology>(nb, dir, FILES.lakes).catch(() => undefined),
|
|
143
145
|
readJson<BoundaryTopology>(nb, dir, FILES.rivers).catch(() => undefined),
|
|
146
|
+
readJson<BoundaryTopology>(nb, dir, FILES.mountainRanges).catch(
|
|
147
|
+
() => undefined
|
|
148
|
+
),
|
|
144
149
|
readJson<BoundaryTopology>(nb, dir, FILES.naLand).catch(() => undefined),
|
|
145
150
|
readJson<BoundaryTopology>(nb, dir, FILES.naLakes).catch(() => undefined),
|
|
146
151
|
readJson<Gazetteer>(nb, dir, FILES.gazetteer),
|
|
@@ -152,6 +157,7 @@ export function loadMapData(): Promise<MapData> {
|
|
|
152
157
|
gazetteer,
|
|
153
158
|
...(lakes && { lakes }),
|
|
154
159
|
...(rivers && { rivers }),
|
|
160
|
+
...(mountainRanges && { mountainRanges }),
|
|
155
161
|
...(naLand && { naLand }),
|
|
156
162
|
...(naLakes && { naLakes }),
|
|
157
163
|
});
|
package/src/map/parser.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
measureIndent,
|
|
10
10
|
splitNameAndMeta,
|
|
11
11
|
extractColor,
|
|
12
|
+
peelTrailingColorName,
|
|
12
13
|
} from '../utils/parsing';
|
|
13
14
|
import {
|
|
14
15
|
MAP_REGISTRY,
|
|
@@ -59,6 +60,8 @@ const DIRECTIVE_SET: ReadonlySet<string> = new Set([
|
|
|
59
60
|
'default-state',
|
|
60
61
|
'active-tag',
|
|
61
62
|
'no-legend',
|
|
63
|
+
'no-insets',
|
|
64
|
+
'relief',
|
|
62
65
|
'subtitle',
|
|
63
66
|
'caption',
|
|
64
67
|
]);
|
|
@@ -310,10 +313,16 @@ export function parseMap(content: string): ParsedMap {
|
|
|
310
313
|
);
|
|
311
314
|
d.projection = value;
|
|
312
315
|
break;
|
|
313
|
-
case 'region-metric':
|
|
316
|
+
case 'region-metric': {
|
|
314
317
|
dup(d.regionMetric);
|
|
315
|
-
|
|
318
|
+
// A trailing color names the choropleth ramp hue (§24B.3): the
|
|
319
|
+
// label keeps the rest. `region-metric Sales ($M) blue` → blue ramp.
|
|
320
|
+
const { label: rmLabel, colorName: rmColor } =
|
|
321
|
+
peelTrailingColorName(value);
|
|
322
|
+
d.regionMetric = rmLabel;
|
|
323
|
+
if (rmColor) d.regionMetricColor = rmColor;
|
|
316
324
|
break;
|
|
325
|
+
}
|
|
317
326
|
case 'poi-metric':
|
|
318
327
|
dup(d.poiMetric);
|
|
319
328
|
d.poiMetric = value;
|
|
@@ -362,6 +371,13 @@ export function parseMap(content: string): ParsedMap {
|
|
|
362
371
|
case 'no-legend':
|
|
363
372
|
d.noLegend = true;
|
|
364
373
|
break;
|
|
374
|
+
case 'no-insets':
|
|
375
|
+
d.noInsets = true;
|
|
376
|
+
break;
|
|
377
|
+
case 'relief':
|
|
378
|
+
// Bare flag (idempotent like no-insets — `relief\nrelief` is no warning).
|
|
379
|
+
d.relief = true;
|
|
380
|
+
break;
|
|
365
381
|
case 'muted':
|
|
366
382
|
case 'natural':
|
|
367
383
|
if (d.basemapStyle !== undefined && d.basemapStyle !== key)
|
|
@@ -488,6 +504,8 @@ export function parseMap(content: string): ParsedMap {
|
|
|
488
504
|
};
|
|
489
505
|
if (regionScope !== undefined) region.scope = regionScope;
|
|
490
506
|
if (valueNum !== undefined) region.value = valueNum;
|
|
507
|
+
// §1.5 trailing color → flat categorical override fill (§24B.4).
|
|
508
|
+
if (split.color) region.color = split.color;
|
|
491
509
|
regions.push(region);
|
|
492
510
|
}
|
|
493
511
|
|
|
@@ -513,6 +531,8 @@ export function parseMap(content: string): ParsedMap {
|
|
|
513
531
|
const poi: Writable<MapPoi> = { pos, tags, meta, lineNumber: line };
|
|
514
532
|
if (split.alias) poi.alias = split.alias;
|
|
515
533
|
if (label !== undefined) poi.label = label;
|
|
534
|
+
// §1.5 trailing color → flat marker fill (§24B.5); wins over a tag color.
|
|
535
|
+
if (split.color) poi.color = split.color;
|
|
516
536
|
pois.push(poi);
|
|
517
537
|
open.poi = { poi, indent };
|
|
518
538
|
}
|