@diagrammo/dgmo 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2230 -503
  3. package/dist/advanced.d.cts +5731 -0
  4. package/dist/advanced.d.ts +5731 -0
  5. package/dist/advanced.js +2226 -503
  6. package/dist/auto.cjs +2272 -479
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +124 -124
  10. package/dist/auto.mjs +2274 -480
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +16 -16
  13. package/dist/editor.js +16 -16
  14. package/dist/highlight.cjs +18 -13
  15. package/dist/highlight.js +18 -13
  16. package/dist/index.cjs +2253 -465
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2255 -466
  20. package/dist/internal.cjs +2230 -503
  21. package/dist/internal.d.cts +5731 -0
  22. package/dist/internal.d.ts +5731 -0
  23. package/dist/internal.js +2226 -503
  24. package/dist/map-data/PROVENANCE.json +1 -1
  25. package/dist/map-data/gazetteer.json +1 -1
  26. package/dist/map-data/mountain-ranges.json +1 -1
  27. package/dist/map-data/water-bodies.json +1 -0
  28. package/dist/map-data/world-coarse.json +1 -1
  29. package/dist/map-data/world-detail.json +1 -1
  30. package/docs/language-reference.md +55 -9
  31. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  32. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  33. package/gallery/fixtures/map-categorical.dgmo +0 -1
  34. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  35. package/gallery/fixtures/map-coastline.dgmo +7 -0
  36. package/gallery/fixtures/map-colorize.dgmo +11 -0
  37. package/gallery/fixtures/map-direct-color.dgmo +0 -1
  38. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  39. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  40. package/gallery/fixtures/map-route.dgmo +0 -1
  41. package/package.json +1 -1
  42. package/src/advanced.ts +12 -1
  43. package/src/boxes-and-lines/parser.ts +39 -0
  44. package/src/boxes-and-lines/renderer.ts +205 -20
  45. package/src/boxes-and-lines/types.ts +9 -0
  46. package/src/cli.ts +1 -1
  47. package/src/completion.ts +36 -30
  48. package/src/cycle/renderer.ts +14 -1
  49. package/src/d3.ts +20 -6
  50. package/src/editor/highlight-api.ts +4 -0
  51. package/src/editor/keywords.ts +16 -16
  52. package/src/infra/renderer.ts +35 -7
  53. package/src/map/colorize.ts +54 -0
  54. package/src/map/context-labels.ts +429 -0
  55. package/src/map/data/PROVENANCE.json +1 -1
  56. package/src/map/data/README.md +6 -0
  57. package/src/map/data/gazetteer.json +1 -1
  58. package/src/map/data/mountain-ranges.json +1 -1
  59. package/src/map/data/types.ts +34 -0
  60. package/src/map/data/water-bodies.json +1 -0
  61. package/src/map/data/world-coarse.json +1 -1
  62. package/src/map/data/world-detail.json +1 -1
  63. package/src/map/dimensions.ts +117 -0
  64. package/src/map/geo-query.ts +21 -3
  65. package/src/map/geo.ts +47 -1
  66. package/src/map/layout.ts +1408 -266
  67. package/src/map/load-data.ts +10 -2
  68. package/src/map/parser.ts +42 -116
  69. package/src/map/renderer.ts +604 -14
  70. package/src/map/resolved-types.ts +16 -2
  71. package/src/map/resolver.ts +208 -59
  72. package/src/map/types.ts +30 -32
  73. package/src/mindmap/renderer.ts +10 -1
  74. package/src/palettes/atlas.ts +77 -0
  75. package/src/palettes/blueprint.ts +73 -0
  76. package/src/palettes/color-utils.ts +58 -1
  77. package/src/palettes/index.ts +12 -3
  78. package/src/palettes/slate.ts +73 -0
  79. package/src/palettes/tidewater.ts +73 -0
  80. package/src/render.ts +8 -1
  81. package/src/tech-radar/renderer.ts +3 -0
  82. package/src/tech-radar/types.ts +3 -0
  83. package/src/utils/d3-types.ts +5 -0
  84. package/src/utils/legend-layout.ts +21 -4
  85. package/src/utils/legend-types.ts +7 -0
  86. package/src/utils/reserved-key-registry.ts +8 -3
  87. package/src/palettes/bold.ts +0 -67
@@ -2080,9 +2080,13 @@ function renderLegend(
2080
2080
  isDark: boolean,
2081
2081
  activeGroup: string | null,
2082
2082
  playback?: InfraPlaybackState,
2083
- exportMode = false
2083
+ exportMode = false,
2084
+ controlsHost?: 'app' | 'inline'
2084
2085
  ) {
2085
2086
  if (legendGroups.length === 0 && !playback) return;
2087
+ // App-hosted playback: the play/pause + speed UI lives in the app overlay
2088
+ // strip, so suppress the in-SVG Playback pill and emit the controls anchor.
2089
+ const appHostedPlayback = controlsHost === 'app' && !!playback;
2086
2090
 
2087
2091
  const legendG = rootSvg
2088
2092
  .append('g')
@@ -2097,8 +2101,9 @@ function renderLegend(
2097
2101
  name: g.name,
2098
2102
  entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2099
2103
  }));
2100
- // Add Playback as a group with empty entries (collapsed pill) or dummy entries (expanded)
2101
- if (playback) {
2104
+ // Add Playback as a group with empty entries (collapsed pill) or dummy entries
2105
+ // (expanded) — unless the app hosts it, in which case it's suppressed.
2106
+ if (playback && !appHostedPlayback) {
2102
2107
  allGroups.push({ name: 'Playback', entries: [] });
2103
2108
  }
2104
2109
 
@@ -2107,6 +2112,20 @@ function renderLegend(
2107
2112
  position: { placement: 'top-center', titleRelation: 'below-title' },
2108
2113
  mode: exportMode ? 'export' : 'preview',
2109
2114
  showEmptyGroups: true,
2115
+ ...(appHostedPlayback && {
2116
+ controlsHost: 'app' as const,
2117
+ controlsGroup: {
2118
+ toggles: [
2119
+ {
2120
+ id: 'playback',
2121
+ type: 'toggle' as const,
2122
+ label: 'Playback',
2123
+ active: true,
2124
+ onToggle: () => {},
2125
+ },
2126
+ ],
2127
+ },
2128
+ }),
2110
2129
  };
2111
2130
  const legendState: LegendState = { activeGroup };
2112
2131
  renderLegendD3(
@@ -2233,9 +2252,13 @@ export function renderInfra(
2233
2252
  playback?: InfraPlaybackState | null,
2234
2253
  expandedNodeIds?: Set<string> | null,
2235
2254
  exportMode?: boolean,
2236
- collapsedNodes?: Set<string> | null
2255
+ collapsedNodes?: Set<string> | null,
2256
+ /** When 'app', the playback pill is suppressed and a controls row + anchor are
2257
+ * reserved for the app overlay strip (play/pause + speed live there). */
2258
+ controlsHost?: 'app' | 'inline'
2237
2259
  ) {
2238
2260
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2261
+ const appHostedPlayback = controlsHost === 'app' && !!playback;
2239
2262
 
2240
2263
  const ctx = ScaleContext.identity();
2241
2264
  const sc = buildScaledConstants(ctx);
@@ -2246,7 +2269,10 @@ export function renderInfra(
2246
2269
  palette,
2247
2270
  layout.edges
2248
2271
  );
2249
- const hasLegend = legendGroups.length > 0 || !!playback;
2272
+ // App-hosted: the playback pill moves to the app overlay, so a playback-only
2273
+ // legend (no tag groups) has nothing left to render.
2274
+ const hasLegend =
2275
+ legendGroups.length > 0 || (!!playback && !appHostedPlayback);
2250
2276
  const fixedLegend = !exportMode && hasLegend;
2251
2277
  const legendDynamicH = hasLegend
2252
2278
  ? getMaxLegendReservedHeight(
@@ -2461,7 +2487,8 @@ export function renderInfra(
2461
2487
  isDark,
2462
2488
  activeGroup ?? null,
2463
2489
  playback ?? undefined,
2464
- exportMode
2490
+ exportMode,
2491
+ controlsHost
2465
2492
  );
2466
2493
  // Re-enable pointer events on interactive legend elements
2467
2494
  legendSvg
@@ -2478,7 +2505,8 @@ export function renderInfra(
2478
2505
  isDark,
2479
2506
  activeGroup ?? null,
2480
2507
  playback ?? undefined,
2481
- exportMode
2508
+ exportMode,
2509
+ controlsHost
2482
2510
  );
2483
2511
  }
2484
2512
  }
@@ -0,0 +1,54 @@
1
+ // Political fill-assignment pass (§24B colorize). PURE + DETERMINISTIC — no
2
+ // projection, no palette, no DOM. Given the per-topology arc-adjacency graph
3
+ // (from geo.ts buildAdjacency), assign every drawn region a colour INDEX such
4
+ // that no two arc-neighbours share one. The only job of a political fill is
5
+ // boundary disambiguation; "no two neighbours share a hue" is the minimal
6
+ // property — and a planar political map famously needs only a handful of colours.
7
+ //
8
+ // FIRST-FIT greedy: each region takes the LOWEST index not used by an
9
+ // already-coloured neighbour. This is collision-free by construction (a node with
10
+ // k coloured neighbours has at most k forbidden indices, so index ≤ k is always
11
+ // free) AND clusters regions into the FEWEST colours — on the shipped graphs the
12
+ // max index used is 5 (world) / 4 (us-states), i.e. 5–6 colours total. The caller
13
+ // generates exactly that many palette tints, so the fills stay on-palette (no
14
+ // need for the old Δ+1 ≈ 17 wheel hues).
15
+
16
+ /** Result of {@link assignColors}: a colour INDEX per ISO + the number of
17
+ * distinct colours actually used (`= maxIndex + 1`). The index is a stable
18
+ * function of (ISO, global arc-adjacency) — extent-independent (AC10). */
19
+ export interface ColorAssignment {
20
+ readonly byIso: Map<string, number>;
21
+ readonly huesNeeded: number;
22
+ }
23
+
24
+ /** First-fit greedy graph-coloring over `isos` using `adjacency` (ISO → neighbour
25
+ * ISOs). Visits ISOs in stable ascending order; each takes the lowest index not
26
+ * taken by an already-coloured neighbour — collision-free, and minimises the
27
+ * total colour count.
28
+ *
29
+ * EVERY iso in `isos` is assigned an index — including zero-degree nodes
30
+ * (islands, DC, territories with no neighbour entry) — so the caller never needs
31
+ * a fallback fill (F14). */
32
+ export function assignColors(
33
+ isos: readonly string[],
34
+ adjacency: ReadonlyMap<string, readonly string[]>
35
+ ): ColorAssignment {
36
+ const sorted = [...isos].sort();
37
+ const byIso = new Map<string, number>();
38
+ let maxIndex = -1;
39
+
40
+ for (const iso of sorted) {
41
+ const taken = new Set<number>();
42
+ for (const n of adjacency.get(iso) ?? []) {
43
+ const c = byIso.get(n);
44
+ if (c !== undefined) taken.add(c);
45
+ }
46
+ // Lowest index not taken by a neighbour (always exists: ≤ neighbour count).
47
+ let h = 0;
48
+ while (taken.has(h)) h++;
49
+ byIso.set(iso, h);
50
+ if (h > maxIndex) maxIndex = h;
51
+ }
52
+
53
+ return { byIso, huesNeeded: maxIndex + 1 };
54
+ }
@@ -0,0 +1,429 @@
1
+ // Context-label placement (step 4, part of layout). Produces a sparse,
2
+ // density-thinned ORIENTATION layer — water-body names + unreferenced notable
3
+ // country names — distinct from `region-labels`. PURE + SYNC + DETERMINISTIC.
4
+ //
5
+ // Design (tech-spec §map-context-labels): a deliberately LOW per-view label
6
+ // BUDGET is the primary noise lever; a span-derived TIER BAND orders candidates
7
+ // into it; each candidate is committed only if it survives COLLISION against
8
+ // every already-placed data/region/POI/route label (the `collides` closure) AND
9
+ // the other context labels. Context labels place DEAD LAST and never displace
10
+ // data — they only fill leftover space, degrading gracefully to zero. See
11
+ // Decisions 6 (budget), 7 (dead-last), 8 (viewport/projection guards).
12
+ import { mix } from '../palettes/color-utils';
13
+ import type { PaletteColors } from '../palettes/types';
14
+ import type { LabelRect } from '../label-layout';
15
+ import { measureLegendText } from '../utils/legend-constants';
16
+ import type { ProjectionFamily } from './resolved-types';
17
+ import type { WaterBodies, WaterKind } from './data/types';
18
+ import type { PlacedLabel } from './layout';
19
+
20
+ /** A view span band → priority ordering (NOT a hard zoom cutoff, Decision 6). */
21
+ export type TierBand = 'world' | 'continental' | 'regional' | 'local';
22
+
23
+ /** An unreferenced country, pre-projected by layout (geo work stays in layout;
24
+ * area-rank + name-fit + collision live here so the module is unit-testable). */
25
+ export interface CountryCandidate {
26
+ readonly name: string;
27
+ /** Projected screen bbox `[x0, y0, x1, y1]` (from `path.bounds`). */
28
+ readonly bbox: readonly [number, number, number, number];
29
+ /** Projected screen anchor `[x, y]` (mainland anchor or `path.centroid`), or
30
+ * null when the feature doesn't project to a finite point. */
31
+ readonly anchor: readonly [number, number] | null;
32
+ }
33
+
34
+ export interface ContextLabelArgs {
35
+ readonly projection: ProjectionFamily;
36
+ readonly dLonSpan: number;
37
+ readonly dLatSpan: number;
38
+ readonly width: number;
39
+ readonly height: number;
40
+ readonly waterBodies?: WaterBodies | undefined;
41
+ readonly countries: readonly CountryCandidate[];
42
+ readonly palette: PaletteColors;
43
+ readonly project: (lon: number, lat: number) => [number, number] | null;
44
+ /** Collision test against every committed data/region/POI/route obstacle. */
45
+ readonly collides: (rect: LabelRect) => boolean;
46
+ /** True when the screen point sits over LAND (a country/state fill) rather than
47
+ * open water. WATER labels are rejected when their footprint touches land — an
48
+ * ocean name belongs over the ocean (they're optional orientation aids, so drop
49
+ * rather than misplace). Country labels are exempt (they label land). Optional
50
+ * for unit tests; absent ⇒ no land rejection. */
51
+ readonly overLand?: (x: number, y: number) => boolean;
52
+ }
53
+
54
+ const FONT = 11; // matches layout's on-map label font
55
+ const LINE_HEIGHT = FONT + 2; // px per wrapped line — MUST match the renderer's
56
+ const PADX = 4; // half-padding around a context label rect
57
+ const PADY = 3;
58
+ const WATER_LETTER_SPACING = 1.5; // px — cartographic spread for water names
59
+ const CONTEXT_PAD = 4; // extra gap enforced between two context labels
60
+ const EDGE_CLAMP_MARGIN = 8; // px inset for edge-clamped ocean labels
61
+ const EDGE_CLAMP_OVERSHOOT = 0.35; // max off-frame overshoot (× dim) to still clamp
62
+
63
+ // Water-kind priority within a tier (oceans first, then seas, then the rest) so
64
+ // a thin budget always spends on the highest-orientation-value names.
65
+ const KIND_ORDER: Record<WaterKind, number> = {
66
+ ocean: 0,
67
+ sea: 1,
68
+ gulf: 2,
69
+ bay: 3,
70
+ strait: 4,
71
+ channel: 5,
72
+ sound: 6,
73
+ };
74
+
75
+ /** Span band from the larger of the two view spans (Decision 6 — priority, not
76
+ * a hard gate). */
77
+ export function tierBand(maxSpanDeg: number): TierBand {
78
+ if (maxSpanDeg >= 90) return 'world';
79
+ if (maxSpanDeg >= 20) return 'continental';
80
+ if (maxSpanDeg >= 5) return 'regional';
81
+ return 'local';
82
+ }
83
+
84
+ /** Deliberately-LOW combined label budget = f(canvas area, band). Floors to ~1
85
+ * on a thumbnail and 0 on a tiny canvas (Decision 6, ADR-3; AC9). Caps the
86
+ * TOTAL context labels (water + country), so `relief`/data don't get extra
87
+ * headroom (Decision 13). */
88
+ export function labelBudget(
89
+ width: number,
90
+ height: number,
91
+ band: TierBand
92
+ ): number {
93
+ const bandCap: Record<TierBand, number> = {
94
+ world: 6,
95
+ continental: 5,
96
+ regional: 4,
97
+ local: 3,
98
+ };
99
+ const area = Math.floor(Math.sqrt(Math.max(0, width * height)) / 150);
100
+ return Math.max(0, Math.min(area, bandCap[band]));
101
+ }
102
+
103
+ /** Which water tiers/kinds are eligible at a band. World view is oceans + major
104
+ * seas ONLY (never bays/sounds/minor gulfs, AC3); broader views progressively
105
+ * admit smaller features by `scalerank` (AC4). */
106
+ function waterEligible(tier: number, kind: WaterKind, band: TierBand): boolean {
107
+ switch (band) {
108
+ case 'world':
109
+ return tier <= 1 && (kind === 'ocean' || kind === 'sea');
110
+ case 'continental':
111
+ return tier <= 2;
112
+ case 'regional':
113
+ return tier <= 3;
114
+ case 'local':
115
+ return tier <= 4;
116
+ }
117
+ }
118
+
119
+ function insideViewport(
120
+ p: readonly [number, number] | null,
121
+ width: number,
122
+ height: number
123
+ ): p is [number, number] {
124
+ return (
125
+ !!p &&
126
+ Number.isFinite(p[0]) &&
127
+ Number.isFinite(p[1]) &&
128
+ p[0] >= 0 &&
129
+ p[0] <= width &&
130
+ p[1] >= 0 &&
131
+ p[1] <= height
132
+ );
133
+ }
134
+
135
+ /** Rendered label width INCLUDING letter-spacing — `measureLegendText` ignores
136
+ * the per-gap `letter-spacing` the renderer applies to water names, so without
137
+ * this the fit/clamp math under-measures by ~`(len-1)*spacing` and the label
138
+ * clips at the canvas edge. */
139
+ export function labelWidth(text: string, letterSpacing: number): number {
140
+ const spacing =
141
+ letterSpacing > 0 ? Math.max(0, text.length - 1) * letterSpacing : 0;
142
+ return measureLegendText(text, FONT) + spacing + 2 * PADX;
143
+ }
144
+
145
+ /** Wrap a multi-word name into balanced lines, biased to wrap READILY — water
146
+ * names ("North Pacific Ocean") read better stacked, and a narrower footprint
147
+ * is far likelier to clear surrounding land (the hard rule below). Of every
148
+ * contiguous split into ≤`maxLines`, picks the one minimising the widest line
149
+ * (so it shrinks horizontally whenever a break helps); ties break to fewer
150
+ * lines, then top-heavy ("North Pacific" / "Ocean", not "North" / "Pacific
151
+ * Ocean"). Single-word names pass through unwrapped. */
152
+ export function wrapLabel(text: string, letterSpacing: number): string[] {
153
+ const words = text.split(/\s+/).filter(Boolean);
154
+ if (words.length <= 1) return [text];
155
+ const maxLines = words.length >= 4 ? 3 : 2;
156
+ const n = words.length;
157
+ type Split = { lines: string[]; cost: number; head: number };
158
+ let best: Split | null = null;
159
+ for (let mask = 0; mask < 1 << (n - 1); mask++) {
160
+ const lines: string[] = [];
161
+ let cur = [words[0]!];
162
+ for (let i = 1; i < n; i++) {
163
+ if (mask & (1 << (i - 1))) {
164
+ lines.push(cur.join(' '));
165
+ cur = [words[i]!];
166
+ } else cur.push(words[i]!);
167
+ }
168
+ lines.push(cur.join(' '));
169
+ if (lines.length > maxLines) continue;
170
+ const cost = Math.round(
171
+ Math.max(...lines.map((l) => labelWidth(l, letterSpacing)))
172
+ );
173
+ const head = labelWidth(lines[0]!, letterSpacing);
174
+ if (
175
+ !best ||
176
+ cost < best.cost ||
177
+ (cost === best.cost && lines.length < best.lines.length) ||
178
+ (cost === best.cost &&
179
+ lines.length === best.lines.length &&
180
+ head > best.head)
181
+ )
182
+ best = { lines, cost, head };
183
+ }
184
+ return best?.lines ?? [text];
185
+ }
186
+
187
+ function rectAround(
188
+ cx: number,
189
+ cy: number,
190
+ lines: readonly string[],
191
+ letterSpacing: number
192
+ ): LabelRect {
193
+ const w = Math.max(...lines.map((l) => labelWidth(l, letterSpacing)));
194
+ const h = (lines.length - 1) * LINE_HEIGHT + FONT + 2 * PADY;
195
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
196
+ }
197
+
198
+ function rectFits(r: LabelRect, width: number, height: number): boolean {
199
+ return r.x >= 0 && r.y >= 0 && r.x + r.w <= width && r.y + r.h <= height;
200
+ }
201
+
202
+ function overlapsPadded(a: LabelRect, b: LabelRect, pad: number): boolean {
203
+ return (
204
+ a.x - pad < b.x + b.w &&
205
+ a.x + a.w + pad > b.x &&
206
+ a.y - pad < b.y + b.h &&
207
+ a.y + a.h + pad > b.y
208
+ );
209
+ }
210
+
211
+ /** Place the orientation backdrop. Returns committed labels in priority order;
212
+ * caller pushes them onto `labels` LAST so they never displace data. */
213
+ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
214
+ const {
215
+ projection,
216
+ dLonSpan,
217
+ dLatSpan,
218
+ width,
219
+ height,
220
+ waterBodies,
221
+ countries,
222
+ palette,
223
+ project,
224
+ collides,
225
+ overLand,
226
+ } = args;
227
+
228
+ // albers-usa is supported: the CONUS conic projects CONUS-area water (Gulf of
229
+ // America, the Pacific/Atlantic margins) correctly, and the viewport-visibility
230
+ // check below drops the off-frame anchors (Gulf of Alaska, mid-Pacific) that the
231
+ // AK/HI inset relocation would otherwise mislabel. The caller additionally feeds
232
+ // the AK/HI inset frames into `collides` so a label never lands on an inset box.
233
+ // (Supersedes the original blanket albers-usa disable — the US map is the
234
+ // flagship orientation case.)
235
+ void projection;
236
+
237
+ const band = tierBand(Math.max(dLonSpan, dLatSpan));
238
+ const budget = labelBudget(width, height, band);
239
+ if (budget <= 0) return [];
240
+
241
+ // Subordinate cartographic colours (palette-derived, no hex; resvg-safe via
242
+ // pre-computed mix()). Water = muted blue-gray italic; country = muted gray.
243
+ const waterColor = mix(palette.colors.blue, palette.textMuted, 50);
244
+ const countryColor = palette.textMuted;
245
+ const haloColor = palette.bg;
246
+
247
+ type Candidate = {
248
+ text: string;
249
+ lines: string[];
250
+ cx: number;
251
+ cy: number;
252
+ italic: boolean;
253
+ letterSpacing: number;
254
+ color: string;
255
+ sort: number; // priority key (lower first)
256
+ };
257
+ const candidates: Candidate[] = [];
258
+
259
+ // -- Water candidates (priority core: oceans → seas → minor water) --
260
+ const center: [number, number] = [width / 2, height / 2];
261
+ for (const e of waterBodies?.entries ?? []) {
262
+ const [lat, lon, name, tier, kind, alt] = e;
263
+ if (!waterEligible(tier, kind, band)) continue;
264
+ // Wrap eagerly (Decision: water names stack readily) so the clamp/fit math
265
+ // below sees the real, narrower wrapped footprint, not the one-line width.
266
+ const wlines = wrapLabel(name, WATER_LETTER_SPACING);
267
+ // Multi-anchor (Decision 5 / ADR-4): of the anchors that project inside the
268
+ // viewport, pick the one nearest the viewport centre.
269
+ const anchorsLngLat: Array<[number, number]> = [[lon, lat]];
270
+ for (const a of alt ?? []) anchorsLngLat.push([a[1], a[0]]);
271
+ let best: [number, number] | null = null;
272
+ let bestD = Infinity;
273
+ let nearestProj: [number, number] | null = null; // best finite proj (any side)
274
+ let nearestProjD = Infinity;
275
+ for (const [aLon, aLat] of anchorsLngLat) {
276
+ const p = project(aLon, aLat);
277
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
278
+ const d = (p[0] - center[0]) ** 2 + (p[1] - center[1]) ** 2;
279
+ if (d < nearestProjD) {
280
+ nearestProjD = d;
281
+ nearestProj = p;
282
+ }
283
+ if (!insideViewport(p, width, height)) continue;
284
+ if (d < bestD) {
285
+ bestD = d;
286
+ best = p;
287
+ }
288
+ }
289
+ // Oceans (tier 0) are large enough that a frame-edge label still reads
290
+ // correctly, so when their anchor falls off-screen on a zoomed-in view
291
+ // (e.g. the mid-Atlantic/Pacific centroid on a US map) we CLAMP it to the
292
+ // viewport margin rather than drop it — the standard cartographic "ocean
293
+ // name hugs the edge" behaviour. Smaller basins keep the strict drop-if-
294
+ // off-screen rule (AC10) to avoid mislabelling an adjacent basin.
295
+ if (!best && tier === 0 && nearestProj) {
296
+ // Only clamp an ocean ADJACENT to the frame: if its centroid overshoots
297
+ // the viewport by more than ~half a dimension it's a distant ocean (e.g.
298
+ // the South Atlantic / Arctic relative to a US view) and edge-clamping it
299
+ // would mislabel that margin — drop instead. The surviving oceans are the
300
+ // ones the frame actually borders (Pacific to the west, Atlantic east).
301
+ const overX = Math.max(0, -nearestProj[0], nearestProj[0] - width);
302
+ const overY = Math.max(0, -nearestProj[1], nearestProj[1] - height);
303
+ if (
304
+ overX <= width * EDGE_CLAMP_OVERSHOOT &&
305
+ overY <= height * EDGE_CLAMP_OVERSHOOT
306
+ ) {
307
+ // Clamp the CENTRE inward by half the label so the whole (centre-
308
+ // anchored) rect stays on-canvas — clamping to the bare margin would
309
+ // overflow a wide name like "North Atlantic Ocean" off the edge.
310
+ // letter-spacing IS counted (labelWidth) so the clamp matches render.
311
+ const halfW =
312
+ Math.max(...wlines.map((l) => labelWidth(l, WATER_LETTER_SPACING))) /
313
+ 2;
314
+ const halfH = ((wlines.length - 1) * LINE_HEIGHT + FONT + 2 * PADY) / 2;
315
+ const m = EDGE_CLAMP_MARGIN;
316
+ best = [
317
+ Math.min(Math.max(nearestProj[0], halfW + m), width - halfW - m),
318
+ Math.min(Math.max(nearestProj[1], halfH + m), height - halfH - m),
319
+ ];
320
+ }
321
+ }
322
+ if (!best) continue;
323
+ candidates.push({
324
+ text: name,
325
+ lines: wlines,
326
+ cx: best[0],
327
+ cy: best[1],
328
+ italic: true,
329
+ letterSpacing: WATER_LETTER_SPACING,
330
+ color: waterColor,
331
+ // Water before any country (×1000), then by tier, then kind, then name.
332
+ sort: tier * 10 + KIND_ORDER[kind],
333
+ });
334
+ }
335
+
336
+ // -- Country candidates (unreferenced; biggest projected area first) --
337
+ // Rank by screen bbox area; keep only those whose name fits the footprint
338
+ // (width-fit, like region-labels) and whose anchor projects inside the view.
339
+ const ranked = countries
340
+ .map((c) => {
341
+ const [x0, y0, x1, y1] = c.bbox;
342
+ const w = x1 - x0;
343
+ const h = y1 - y0;
344
+ return { c, w, h, area: w * h };
345
+ })
346
+ .filter((r) => Number.isFinite(r.area) && r.area > 0)
347
+ .sort((a, b) => b.area - a.area);
348
+ let ci = 0;
349
+ for (const r of ranked) {
350
+ const { c, w, h } = r;
351
+ // F2: an antimeridian-crossing / global-smear country yields a near-full-
352
+ // canvas bbox while its real landmass is split — the `path.centroid` anchor
353
+ // is then unreliable (mid-map, wrong basin). Drop such over-wide candidates
354
+ // rather than spend a top-priority slot on a mispositioned name.
355
+ if (w > width * 0.66 || h > height * 0.66) continue;
356
+ if (!insideViewport(c.anchor, width, height)) continue;
357
+ // Always the full country name — never an ISO abbreviation. If the name
358
+ // doesn't fit the footprint, drop the label rather than abbreviate.
359
+ const text = c.name;
360
+ const tw = labelWidth(text, 0);
361
+ // Approximate fit (Decision 4): name fits inside the footprint bbox. NOT
362
+ // true point-in-polygon — cartographic labels routinely overrun coastlines.
363
+ if (tw > w || FONT + 2 * PADY > h) continue;
364
+ candidates.push({
365
+ text,
366
+ lines: [text],
367
+ cx: c.anchor[0],
368
+ cy: c.anchor[1],
369
+ italic: false,
370
+ letterSpacing: 0,
371
+ color: countryColor,
372
+ // Always after every water body (+1e6); larger area = earlier.
373
+ sort: 1_000_000 + ci++,
374
+ });
375
+ }
376
+
377
+ // -- Commit dead-last, highest-priority-first, into leftover space only --
378
+ candidates.sort((a, b) => a.sort - b.sort);
379
+ const placed: PlacedLabel[] = [];
380
+ const placedRects: LabelRect[] = [];
381
+ for (const cand of candidates) {
382
+ if (placed.length >= budget) break;
383
+ const rect = rectAround(cand.cx, cand.cy, cand.lines, cand.letterSpacing);
384
+ if (!rectFits(rect, width, height)) continue;
385
+ // Water labels must sit over OPEN WATER and NEVER touch land — sample a grid
386
+ // over every wrapped line (each line's own horizontal extent at five points);
387
+ // drop the whole label if ANY sample hits land (Decision: optional orientation
388
+ // aids, so exclude rather than misplace over a coastline). Country labels are
389
+ // exempt — they belong on their country.
390
+ if (cand.italic && overLand) {
391
+ const inset = 2;
392
+ const top = cand.cy - ((cand.lines.length - 1) / 2) * LINE_HEIGHT;
393
+ const touchesLand = cand.lines.some((line, li) => {
394
+ const lw = labelWidth(line, cand.letterSpacing);
395
+ const x0 = cand.cx - lw / 2 + inset;
396
+ const x1 = cand.cx + lw / 2 - inset;
397
+ const xs = [x0, (x0 + cand.cx) / 2, cand.cx, (cand.cx + x1) / 2, x1];
398
+ const base = top + li * LINE_HEIGHT;
399
+ // Sample the glyph body top→baseline (text rises above the baseline) so a
400
+ // label whose ascenders clip a coastline is rejected, not just one whose
401
+ // baseline sits on land.
402
+ return [base, base - FONT * 0.4, base - FONT * 0.8].some((y) =>
403
+ xs.some((x) => overLand(x, y))
404
+ );
405
+ });
406
+ if (touchesLand) continue;
407
+ }
408
+ if (collides(rect)) continue;
409
+ if (placedRects.some((r) => overlapsPadded(rect, r, CONTEXT_PAD))) continue;
410
+ placedRects.push(rect);
411
+ placed.push({
412
+ x: cand.cx,
413
+ y: cand.cy,
414
+ text: cand.text,
415
+ anchor: 'middle',
416
+ color: cand.color,
417
+ // No halo: the bg-coloured outline reads as a ghost box behind the text
418
+ // over the tinted water/land. Context labels are muted enough to sit
419
+ // cleanly on the basemap without one.
420
+ halo: false,
421
+ haloColor,
422
+ italic: cand.italic,
423
+ letterSpacing: cand.letterSpacing,
424
+ ...(cand.lines.length > 1 ? { lines: cand.lines } : {}),
425
+ lineNumber: 0,
426
+ });
427
+ }
428
+ return placed;
429
+ }
@@ -1 +1 @@
1
- {"assets":{"gazetteer.json":{"bytes":130706,"gzBytes":56251,"sha256":"8cefc36db73c9337429e66c94339a385656de3ec22a67487b0a146aa37dd632a"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":30081,"gzBytes":8971,"sha256":"b0eb87e6b3c514b882245299fbe698b3caaa3f40494e3d220f973caf6a12a16c"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"world-coarse.json":{"bytes":55426,"gzBytes":18390,"sha256":"9ed5a15d775c0a303003dcf096a209dd433383ea52e18014885d482c38bc01a4"},"world-detail.json":{"bytes":163555,"gzBytes":46762,"sha256":"e99dddc3fccd96465ad4e5cd7bcca839095672e9c595d78276d510c44db84fd7"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2118,"mountainRanges":114,"usStates":56},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_regions_polys.geojson":{"bytes":4305815,"sha256":"33396643d8f0eed408e80bf5ca43dcdb7993f790fdc4eadae311660f8b91fe76"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5546862,"sha256":"e898c1399b05bd521540c5fa56ac3db44b4b2029d432b5f40a1e45f66d5a0929"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-05-25 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"mountainRanges":{"license":"public domain (Natural Earth)","url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_regions_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
1
+ {"assets":{"gazetteer.json":{"bytes":130767,"gzBytes":56261,"sha256":"5ad56e5ba0b3a4f9a6dc8bd3bf8b0fda0e7b86cbe4d85d231114f5dd967d65f7"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":90845,"gzBytes":26493,"sha256":"a698b3f296e61712fb39b3d8d42ec7c4699f8aadecb549367feb7d09f7785580"},"na-lakes.json":{"bytes":39387,"gzBytes":11281,"sha256":"2a41c04969209380d544a09efe354277e12d704458af95955201eb4f698d16c6"},"na-land.json":{"bytes":114082,"gzBytes":32375,"sha256":"7b94c9bb4e809c22813da5ae939e1ff6a781fd77a04d9c1585a9a82d2a195388"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"water-bodies.json":{"bytes":4854,"gzBytes":2123,"sha256":"6d1a407a376c63518329c52189e2887053c4b61062af0597e060050ae8469635"},"world-coarse.json":{"bytes":55436,"gzBytes":18397,"sha256":"5cb42e3c8975dde56504ca5c68ece0a1e71d0929680b5fc8cdab758c8666dbf8"},"world-detail.json":{"bytes":163562,"gzBytes":46767,"sha256":"39f1736eaabe9e21190972be3157822be22ee84fdc41751237f2b516f09a7586"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2119,"mountainRanges":205,"usStates":56,"waterBodies":113},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json":{"bytes":6648697,"sha256":"93c8fdf0e591e113f449d0d466e15c7a9841b9b6571c7afe41f95ba51b322452"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson":{"bytes":13287234,"sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson":{"bytes":5583870,"sha256":"b7b26e50ea917d3696aec87f932def2bf5f890f5770e441d59c162c6f4c92a77"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson":{"bytes":534055,"sha256":"b9c3f7f557d0ff5217906adc82b66ecdac14aa7438df7e518cf6675d037bceb8"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson":{"bytes":1163418,"sha256":"6fe58083e0cc5c7fad9e396970e28a8580bbd8770cfa4d1d7b5a34423e912f97"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5549002,"sha256":"d20e28b2f610da34c21fd82ff6a8e4d24ebe67eba2dccf65bd2c4332ff0f380a"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-06-02 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"marineCoarse":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson","version":"natural-earth 110m (nvkelso vector snapshot)"},"marineDetail":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"mountainRanges":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"naLakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json","version":"natural-earth 10m (martynafford snapshot)"},"naLand":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
@@ -10,6 +10,10 @@ hand-edit — regenerate from source.
10
10
  - `us-states.json` — US states + DC + territories (TopoJSON), keyed by ISO 3166-2.
11
11
  - `lakes.json` — major lakes (Natural Earth 110m, TopoJSON), drawn as water over land.
12
12
  - `rivers.json` — major river centerlines (Natural Earth 110m, TopoJSON), drawn as thin water lines.
13
+ - `na-land.json` — NA-clipped 10m country land (TopoJSON, ISO-keyed): crisp neighbour context under the albers-usa US view.
14
+ - `na-lakes.json` — NA-clipped 10m major lakes (TopoJSON): the lakes counterpart to `na-land.json` for the US view.
15
+ - `mountain-ranges.json` — notable mountain ranges (Natural Earth 50m geography regions, FEATURECLA "Range/mtn", TopoJSON), drawn as a subtle gradient relief cue when the `relief` directive is on. Optional; single tier (no elevation).
16
+ - `water-bodies.json` — water-body orientation labels (`{ entries: [lat, lon, name, tier, kind] }`) from Natural Earth 110m+50m geography marine polys (oceans/seas/gulfs/bays/straits/channels/sounds; rivers + reefs excluded). Anchors are mapshaper inner points; `tier` is the NE scalerank. Drawn only when the `context-labels` directive is on. Optional.
13
17
  - `gazetteer.json` — `{ cities, byName, alt }` city index (see `types.ts`).
14
18
  `byName`/`alt` reference `cities` by array index (normalized).
15
19
  - `PROVENANCE.json` — source versions + per-asset sha256/sizes + GeoNames date range.
@@ -18,6 +22,8 @@ hand-edit — regenerate from source.
18
22
  ## Sources & attribution
19
23
  - **Country boundaries:** Natural Earth via `world-atlas@2.0.2` (public domain).
20
24
  - **US states:** US Census via `us-atlas@3.0.1` (public domain).
25
+ - **Mountain ranges:** Natural Earth 50m `geography_regions_polys` via `nvkelso/natural-earth-vector` (public domain).
26
+ - **Water bodies:** Natural Earth 110m+50m `geography_marine_polys` via `nvkelso/natural-earth-vector` (public domain). One editorial override applied (`Gulf of Mexico` → `Gulf of America`).
21
27
  - **Cities:** Data © **GeoNames**, licensed under **CC BY 4.0**
22
28
  (https://creativecommons.org/licenses/by/4.0/) — https://www.geonames.org/.
23
29