@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.
Files changed (43) hide show
  1. package/dist/advanced.cjs +556 -195
  2. package/dist/advanced.js +555 -195
  3. package/dist/auto.cjs +322 -196
  4. package/dist/auto.js +113 -113
  5. package/dist/auto.mjs +322 -196
  6. package/dist/cli.cjs +156 -156
  7. package/dist/editor.cjs +1 -0
  8. package/dist/editor.js +1 -0
  9. package/dist/highlight.cjs +1 -0
  10. package/dist/highlight.js +1 -0
  11. package/dist/index.cjs +320 -195
  12. package/dist/index.js +320 -195
  13. package/dist/internal.cjs +556 -195
  14. package/dist/internal.js +555 -195
  15. package/dist/map-data/PROVENANCE.json +1 -1
  16. package/dist/map-data/mountain-ranges.json +1 -0
  17. package/docs/language-reference.md +27 -25
  18. package/gallery/fixtures/map-direct-color.dgmo +10 -0
  19. package/package.json +1 -1
  20. package/src/advanced.ts +14 -0
  21. package/src/completion.ts +1 -0
  22. package/src/d3.ts +15 -9
  23. package/src/editor/keywords.ts +1 -0
  24. package/src/map/data/PROVENANCE.json +1 -1
  25. package/src/map/data/mountain-ranges.json +1 -0
  26. package/src/map/geo-query.ts +277 -0
  27. package/src/map/geo.ts +258 -1
  28. package/src/map/invert.ts +111 -0
  29. package/src/map/layout.ts +233 -113
  30. package/src/map/load-data.ts +7 -1
  31. package/src/map/parser.ts +22 -2
  32. package/src/map/renderer.ts +44 -0
  33. package/src/map/resolved-types.ts +8 -0
  34. package/src/map/resolver.ts +40 -19
  35. package/src/map/types.ts +18 -0
  36. package/dist/advanced.d.cts +0 -5331
  37. package/dist/advanced.d.ts +0 -5331
  38. package/dist/auto.d.cts +0 -39
  39. package/dist/auto.d.ts +0 -39
  40. package/dist/index.d.cts +0 -336
  41. package/dist/index.d.ts +0 -336
  42. package/dist/internal.d.cts +0 -5331
  43. 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-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;
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
- const WATER_TINT = 55; // % palette-blue of bg for the ocean / backdrop
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 data hues may themselves be blue or green (e.g. `Core blue`,
78
- // `Growth teal`), which collide with the decorative blue-water / green-land
79
- // dress: a blue region vanishes into the ocean, a green one into the land. So
80
- // when regions carry the data signal the basemap RECEDES to neutral grays —
81
- // water and unscored/neighbour land become low-saturation gray, leaving the data
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). When
298
- * `dataActive` (a score ramp or tag group is colouring regions) the sea recedes
299
- * to a pale neutral so blue/green data hues don't blend into it. */
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
- dataActive = false
368
+ _dataActive = false
304
369
  ): string {
305
- if (dataActive)
306
- return mix(
307
- palette.colors.gray,
308
- palette.bg,
309
- isDark ? MUTED_WATER_DARK : MUTED_WATER_LIGHT
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`. Green reference dress by
318
- * default; neutral (page bg on light, lifted gray on dark) when `dataActive`. */
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
- dataActive = false
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 is red so valued regions stand out against the blue water
401
- // (palette.primary is a blue in most palettes and would blend in).
402
- const rampHue = palette.colors.red;
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. When a colouring dimension is active the regions carry the
437
- // signal, so the sea/land recede to neutral grays (the data hues — which may be
438
- // blue or green would otherwise blend into a blue ocean / green land). A
439
- // plain reference map (no score, no tag activeGroup null) keeps the blue
440
- // water + green land. The bare `muted` / `natural` flags force either dress
441
- // regardless (so two maps in a deck can match); absent this auto rule. In a
442
- // US view the surrounding world layer is always recessive gray so the US reads
443
- // as the subject.
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 region's fill under the ACTIVE colouring dimension (AR4, bivariate):
507
- * value-active ramp for valued regions, neutral otherwise; a tag group
508
- * active that group's tag colour, neutral otherwise (value ignored). */
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 (resolved.projection === 'albers-usa' && usLayer) {
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
- // Top edge for a box over [x0, xr]: a straight line PARALLEL to the local
723
- // coast (least-squares over the land samples), pushed down so it clears every
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 x = x0 + ((xr - x0) * i) / n;
734
- const y = at(x);
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 (x: number) => m * x + c;
823
+ return maxY;
765
824
  };
766
825
  // A snug floating box that just contains the state, tucked up under the coast
767
- // with a coast-parallel slanted top. `iwReq` is the requested inner width.
768
- // Returns the box's right edge so the next inset can sit beside it.
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 top = coastTop(x0, xr);
783
- const yL = top(x0);
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
- // State sits below the lower top corner. If the coast runs so low the state
790
- // wouldn't fit above yB, raise the top (the corner stays over ocean) — the
791
- // box must never collapse and vanish.
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 = Math.max(yL, yR);
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: Math.min(topL, topR),
873
+ y: topFit,
818
874
  w: xr - x0,
819
- h: bottom - Math.min(topL, topR),
875
+ h: bottom - topFit,
820
876
  points: [
821
- [x0, topL],
822
- [xr, topR],
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: 'none',
1160
+ stroke: lakeStroke,
1102
1161
  lineNumber: -1,
1103
1162
  layer: 'base',
1104
1163
  });
1105
1164
  }
1106
1165
  }
1107
1166
 
1108
- // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land,
1109
- // the SAME blue as the ocean/lakes so a river reads as continuous with the
1110
- // water it drains into. Open paths: stroked, no fill; under POIs/edges/labels.
1111
- const riverColor = water;
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 tag color: FIRST declared group for which the POI has a value (AR4).
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
  }
@@ -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
- d.regionMetric = value;
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
  }