@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.
Files changed (48) hide show
  1. package/dist/advanced.cjs +867 -286
  2. package/dist/advanced.js +866 -286
  3. package/dist/auto.cjs +635 -284
  4. package/dist/auto.js +113 -113
  5. package/dist/auto.mjs +635 -284
  6. package/dist/cli.cjs +156 -156
  7. package/dist/editor.cjs +6 -2
  8. package/dist/editor.js +6 -2
  9. package/dist/highlight.cjs +6 -2
  10. package/dist/highlight.js +6 -2
  11. package/dist/index.cjs +628 -281
  12. package/dist/index.js +628 -281
  13. package/dist/internal.cjs +867 -286
  14. package/dist/internal.js +866 -286
  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-choropleth.dgmo +7 -7
  19. package/gallery/fixtures/map-direct-color.dgmo +10 -0
  20. package/gallery/fixtures/map-pois.dgmo +4 -4
  21. package/gallery/fixtures/map-region-scope.dgmo +8 -8
  22. package/gallery/fixtures/map-route.dgmo +5 -6
  23. package/package.json +1 -1
  24. package/src/advanced.ts +14 -0
  25. package/src/completion.ts +10 -4
  26. package/src/d3.ts +15 -9
  27. package/src/editor/keywords.ts +6 -2
  28. package/src/map/data/PROVENANCE.json +1 -1
  29. package/src/map/data/mountain-ranges.json +1 -0
  30. package/src/map/geo-query.ts +277 -0
  31. package/src/map/geo.ts +258 -1
  32. package/src/map/invert.ts +111 -0
  33. package/src/map/layout.ts +333 -139
  34. package/src/map/load-data.ts +7 -1
  35. package/src/map/parser.ts +142 -33
  36. package/src/map/renderer.ts +57 -6
  37. package/src/map/resolved-types.ts +21 -2
  38. package/src/map/resolver.ts +219 -53
  39. package/src/map/types.ts +57 -14
  40. package/src/utils/reserved-key-registry.ts +7 -7
  41. package/dist/advanced.d.cts +0 -5290
  42. package/dist/advanced.d.ts +0 -5290
  43. package/dist/auto.d.cts +0 -39
  44. package/dist/auto.d.ts +0 -39
  45. package/dist/index.d.cts +0 -336
  46. package/dist/index.d.ts +0 -336
  47. package/dist/internal.d.cts +0 -5290
  48. 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-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;
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 score (if any) — emitted as `data-score` so the app can
120
+ /** The region's value (if any) — emitted as `data-value` so the app can
90
121
  * highlight by gradient-scrub proximity. */
91
- readonly score?: number;
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
- export function mapBackgroundColor(palette: PaletteColors): string {
281
- return mix(palette.colors.blue, palette.bg, WATER_TINT);
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 green base every
285
- * region blends from. Exported so a host can DIM a region to plain land
286
- * (rather than lowering opacity, which would let the blue water show through
287
- * and make the shape read as ocean). Matches the layout's `neutralFill`. */
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
- const foreignFill = mix(
358
- palette.colors.gray,
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 scores = resolved.regions
371
- .filter((r) => r.score !== undefined)
372
- .map((r) => r.score!);
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(...scores);
375
- const rampMax = scaleOverride ? scaleOverride.max : Math.max(...scores);
376
- // Score ramp is red so scored regions stand out against the blue water
377
- // (palette.primary is a blue in most palettes and would blend in).
378
- const rampHue = palette.colors.red;
379
- const hasRamp = scores.length > 0;
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 score ramp and each tag group are
382
- // mutually-exclusive selectable groups. `SCORE_NAME` is the ramp's group name
383
- // (the metric label, or "Score"); the reserved token `score` also selects it.
384
- // Exactly one dimension is active and drives every region's fill.
385
- const SCORE_NAME = hasRamp
386
- ? resolved.directives.metric?.trim() || 'Score'
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 (SCORE_NAME && (lv === 'score' || lv === SCORE_NAME.toLowerCase()))
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 score when scores exist (preserves the historical
404
- // "score wins" default), else the first declared tag group.
499
+ // Default: colour by the value ramp when values exist, else the first
500
+ // declared tag group.
405
501
  activeGroup =
406
- SCORE_NAME ??
502
+ VALUE_NAME ??
407
503
  (resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
408
504
  }
409
- const activeIsScore = SCORE_NAME !== null && activeGroup === SCORE_NAME;
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 fillForScore = (s: number): string => {
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 region's fill under the ACTIVE colouring dimension (AR4, bivariate):
454
- * score-active ramp for scored regions, neutral otherwise; a tag group
455
- * active that group's tag colour, neutral otherwise (score 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). */
456
592
  const regionFill = (r: {
457
- score?: number;
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.score !== undefined ? fillForScore(r.score) : neutralFill;
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 (resolved.projection === 'albers-usa' && usLayer) {
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
- // Top edge for a box over [x0, xr]: a straight line PARALLEL to the local
670
- // coast (least-squares over the land samples), pushed down so it clears every
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 x = x0 + ((xr - x0) * i) / n;
681
- const y = at(x);
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 (x: number) => m * x + c;
823
+ return maxY;
712
824
  };
713
825
  // A snug floating box that just contains the state, tucked up under the coast
714
- // with a coast-parallel slanted top. `iwReq` is the requested inner width.
715
- // 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.
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 top = coastTop(x0, xr);
730
- const yL = top(x0);
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
- // State sits below the lower top corner. If the coast runs so low the state
737
- // wouldn't fit above yB, raise the top (the corner stays over ocean) — the
738
- // 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.
739
851
  const needH = sh + 2 * PAD;
740
- let topFit = Math.max(yL, yR);
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: Math.min(topL, topR),
873
+ y: topFit,
765
874
  w: xr - x0,
766
- h: bottom - Math.min(topL, topR),
875
+ h: bottom - topFit,
767
876
  points: [
768
- [x0, topL],
769
- [xr, topR],
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?.score !== undefined && { score: r.score }),
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.score !== undefined && { score: r.score }),
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: 'none',
1160
+ stroke: lakeStroke,
1049
1161
  lineNumber: -1,
1050
1162
  layer: 'base',
1051
1163
  });
1052
1164
  }
1053
1165
  }
1054
1166
 
1055
- // Rivers (Amazon, Nile, Mississippi, …) as thin water lines over the land,
1056
- // the SAME blue as the ocean/lakes so a river reads as continuous with the
1057
- // water it drains into. Open paths: stroked, no fill; under POIs/edges/labels.
1058
- 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);
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['size']))
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['size']);
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 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.
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: legs between consecutive stops (loop closing leg included).
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 curved = rt.meta['style'] === 'arc';
1214
- for (let i = 1; i < rt.stopIds.length; i++) {
1215
- const a = poiScreen.get(rt.stopIds[i - 1]!);
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, curved, 0),
1220
- width: W_MIN,
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: rt.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['weight']))
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['weight']);
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 (score ramp + tag groups) get a legend.
1570
- // POI size and edge weight are self-evident from the marker/line scale and
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.metric !== undefined && {
1579
- metric: resolved.directives.metric,
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
  }