@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
@@ -0,0 +1,277 @@
1
+ // Map geo-query (step-5 inspector backend). A SEPARATE entry from the renderer:
2
+ // `renderMap` returns void + mutates the DOM, so it cannot hand back a query
3
+ // handle. `createMapGeoQuery` re-runs the deterministic parse→resolve→layout
4
+ // pipeline (cheap, lazy — only when the app arms Inspect) and wraps the fitted
5
+ // projection captured on the layout, exposing:
6
+ // - invert(px,py) — composite/stretch-aware pixel → [lon,lat]
7
+ // - project(lonLat) — [lon,lat] → pixel (re-project pins/markers each render)
8
+ // - locate(px,py) — ONE unified result card (coords + reverse-geocode + tokens)
9
+ // - cities(extent?) — culled + projected gazetteer cities for the all-cities layer
10
+ //
11
+ // DI: `data: MapData` is injected by the caller (the Node-only `loadMapData` is
12
+ // never called here), so this is browser-safe — no `node:fs`, no boundary
13
+ // TopoJSON pulled into the synchronous render bundle (F7/AC15).
14
+ import type { PaletteColors } from '../palettes/types';
15
+ import { parseMap } from './parser';
16
+ import { resolveMap } from './resolver';
17
+ import { layoutMap } from './layout';
18
+ import type { MapLayout } from './layout';
19
+ import { decodeFeatures, regionAt } from './geo';
20
+ import type { DecodedFeature } from './geo';
21
+ import { pixelToLonLat, lonLatToPixel } from './invert';
22
+ import type { MapData, GeoExtent } from './resolved-types';
23
+ import type { Gazetteer } from './data/types';
24
+
25
+ /** Nearest gazetteer city to a point: the real haversine distance, plus the
26
+ * canonical name + ISO + (US-only) subdivision for token shaping. */
27
+ export interface NearestCity {
28
+ readonly name: string;
29
+ readonly iso: string;
30
+ readonly sub?: string;
31
+ readonly distanceKm: number;
32
+ }
33
+
34
+ /** A region declaration with its canonical/primary form plus bare alternates
35
+ * (behind the card's "other forms" expander). */
36
+ export interface RegionToken {
37
+ /** Explicit scoped form, shown first (`Florida US-FL` / `France FR`). */
38
+ readonly primary: string;
39
+ /** Bare forms (bare ISO, bare code, bare name). */
40
+ readonly alternates: string[];
41
+ }
42
+
43
+ /** Paste-ready DGMO tokens for one inspected point — each round-trips through the
44
+ * map parser with zero diagnostics (the app inserts verbatim, never synthesizes
45
+ * syntax). */
46
+ export interface ResultTokens {
47
+ /** Positional POI line, e.g. `poi 40.7608 -111.891` (NEVER `@lat,lon`). */
48
+ readonly coordPoiLine: string;
49
+ /** US-state region tokens — null when the click isn't in a US state. */
50
+ readonly state: RegionToken | null;
51
+ /** Country region tokens — null over open ocean (no country). */
52
+ readonly country: RegionToken | null;
53
+ /** Scoped city token (`New York US-NY` / `Paris FR`), or a bare ambiguous name. */
54
+ readonly city: { readonly token: string; readonly ambiguous: boolean } | null;
55
+ }
56
+
57
+ /** The single unified Inspect result. */
58
+ export interface ResultCard {
59
+ readonly lonLat: [number, number];
60
+ readonly country: { iso: string; name: string } | null;
61
+ readonly state: { iso: string; name: string } | null;
62
+ readonly nearestCity: NearestCity | null;
63
+ readonly tokens: ResultTokens;
64
+ }
65
+
66
+ /** A gazetteer city projected to screen pixels for the all-cities overlay. */
67
+ export interface ProjectedCity {
68
+ readonly name: string;
69
+ readonly iso: string;
70
+ readonly sub?: string;
71
+ readonly lon: number;
72
+ readonly lat: number;
73
+ readonly px: number;
74
+ readonly py: number;
75
+ readonly pop: number;
76
+ }
77
+
78
+ export interface MapGeoQuery {
79
+ /** Pixel → `[lon,lat]`, or null for an out-of-domain pixel. */
80
+ invert(px: number, py: number): [number, number] | null;
81
+ /** `[lon,lat]` → pixel, or null if it projects nowhere. */
82
+ project(lonLat: readonly [number, number]): [number, number] | null;
83
+ /** One click → the unified result card, or null if the pixel inverts to
84
+ * nothing (graceful "no location"). */
85
+ locate(px: number, py: number): ResultCard | null;
86
+ /** Culled + projected cities for the all-cities layer (population-primary). */
87
+ cities(extent?: GeoExtent): ProjectedCity[];
88
+ }
89
+
90
+ export interface CreateMapGeoQueryOptions {
91
+ readonly content: string;
92
+ readonly width: number;
93
+ readonly height: number;
94
+ /** Injected map assets — same `MapData` the app passes to `renderMap`. */
95
+ readonly data: MapData;
96
+ /** Same palette/isDark the app renders with (geometry is palette-independent,
97
+ * but `layoutMap` mandates them). */
98
+ readonly palette: PaletteColors;
99
+ readonly isDark: boolean;
100
+ }
101
+
102
+ const EARTH_R_KM = 6371;
103
+ const DEG = Math.PI / 180;
104
+
105
+ /** Great-circle distance in km (haversine; no d3 dependency). */
106
+ function haversineKm(
107
+ lat1: number,
108
+ lon1: number,
109
+ lat2: number,
110
+ lon2: number
111
+ ): number {
112
+ const dLat = (lat2 - lat1) * DEG;
113
+ const dLon = (lon2 - lon1) * DEG;
114
+ const a =
115
+ Math.sin(dLat / 2) ** 2 +
116
+ Math.cos(lat1 * DEG) * Math.cos(lat2 * DEG) * Math.sin(dLon / 2) ** 2;
117
+ return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
118
+ }
119
+
120
+ // Each decade of population is worth this many km of "pull" — so a notable city
121
+ // a little farther beats a tiny suburb that's technically closer (A4). Tuned so
122
+ // a ~200k-pop city (log10≈5.3) outranks a ~5k hamlet (log10≈3.7) within ~20 km.
123
+ const POP_PULL_KM = 12;
124
+
125
+ /** Nearest gazetteer city, blending true distance with notability (A4). Returns
126
+ * the chosen city's REAL `distanceKm` regardless of the ranking blend (F4). */
127
+ function nearestCity(
128
+ lonLat: readonly [number, number],
129
+ gazetteer: Gazetteer
130
+ ): NearestCity | null {
131
+ const [lon, lat] = lonLat;
132
+ let best: { score: number; idx: number; dist: number } | null = null;
133
+ const cities = gazetteer.cities;
134
+ for (let i = 0; i < cities.length; i++) {
135
+ const c = cities[i]!;
136
+ const dist = haversineKm(lat, lon, c[0], c[1]);
137
+ const score = dist - POP_PULL_KM * Math.log10((c[3] || 0) + 1);
138
+ if (!best || score < best.score) best = { score, idx: i, dist };
139
+ }
140
+ if (!best) return null;
141
+ const c = cities[best.idx]!;
142
+ return {
143
+ name: c[4],
144
+ iso: c[2],
145
+ ...(c[5] !== undefined && { sub: c[5] }),
146
+ distanceKm: best.dist,
147
+ };
148
+ }
149
+
150
+ /** Round a coordinate to 2 dp (≈1 km — within a pixel even at single-state zoom;
151
+ * a POI dot is 6+ px wide, so more decimals are false precision invisible on the
152
+ * rendered map). */
153
+ function roundCoord(n: number): number {
154
+ return Number(n.toFixed(2));
155
+ }
156
+
157
+ /** Build the paste-ready tokens for one inspected point. Display name + ISO come
158
+ * from the matched boundary feature itself (no `region-names.json` dependency).*/
159
+ function buildTokens(
160
+ lonLat: readonly [number, number],
161
+ region: {
162
+ country: { iso: string; name: string } | null;
163
+ state: { iso: string; name: string } | null;
164
+ },
165
+ city: NearestCity | null
166
+ ): ResultTokens {
167
+ const coordPoiLine = `poi ${roundCoord(lonLat[1])} ${roundCoord(lonLat[0])}`;
168
+
169
+ // Region token forms are validated against the RESOLVER, not just the parser
170
+ // (a token can parse yet fail to resolve). Verified resolving forms:
171
+ // - US state: `Florida US-FL` (scoped) and bare `US-FL` / `Florida`. NOTE a
172
+ // bare 2-letter code (`FL`) is REJECTED ("Unknown subdivision") — only the
173
+ // `US-FL` form resolves — so it is intentionally NOT offered.
174
+ // - Country: the BARE name (`United States of America`) or bare ISO (`US`).
175
+ // The scoped `<name> <iso>` form does NOT resolve for a country whose
176
+ // subdivisions are loaded (the scope makes the resolver hunt for a
177
+ // SUBDIVISION named after the country) — so a country leads with its name.
178
+ let stateTok: RegionToken | null = null;
179
+ if (region.state) {
180
+ const { iso, name } = region.state; // iso like `US-FL`
181
+ stateTok = { primary: `${name} ${iso}`, alternates: [iso, name] };
182
+ }
183
+
184
+ let countryTok: RegionToken | null = null;
185
+ if (region.country) {
186
+ const { iso, name } = region.country; // iso like `FR` / `US`
187
+ countryTok = { primary: name, alternates: [iso] };
188
+ }
189
+
190
+ // The nearest-city row inserts a POI for that city, so the token is a POSITIONAL
191
+ // `poi <City> <scope>` line — a bare `<City> <scope>` would parse as a REGION
192
+ // declaration and fail to resolve. Scope = the US subdivision (US-only), else
193
+ // the country ISO; bare `poi <City>` when neither disambiguates.
194
+ let cityTok: ResultTokens['city'] = null;
195
+ if (city) {
196
+ const scope = city.sub ?? (city.iso || '');
197
+ cityTok = scope
198
+ ? { token: `poi ${city.name} ${scope}`, ambiguous: false }
199
+ : { token: `poi ${city.name}`, ambiguous: true };
200
+ }
201
+
202
+ return { coordPoiLine, state: stateTok, country: countryTok, city: cityTok };
203
+ }
204
+
205
+ const MAX_CITY_DOTS = 250;
206
+
207
+ /** Construct a geo-query handle bound to the layout for `(content, width,
208
+ * height, data, palette, isDark)`. Deterministic: identical inputs ⇒ the same
209
+ * fitted projection the rendered SVG used, so inverted clicks align. */
210
+ export function createMapGeoQuery(opts: CreateMapGeoQueryOptions): MapGeoQuery {
211
+ const { content, width, height, data, palette, isDark } = opts;
212
+ const resolved = resolveMap(parseMap(content), data);
213
+ const layout: MapLayout = layoutMap(
214
+ resolved,
215
+ data,
216
+ { width, height },
217
+ { palette, isDark }
218
+ );
219
+
220
+ // Decode the boundary features ONCE (review L3) — country containment against
221
+ // world-detail (50m); US-state against us-states (10m).
222
+ const countries: DecodedFeature[] = decodeFeatures(data.worldDetail);
223
+ const states: DecodedFeature[] = decodeFeatures(data.usStates);
224
+ const gazetteer = data.gazetteer;
225
+
226
+ const invert = (px: number, py: number): [number, number] | null =>
227
+ pixelToLonLat(layout, px, py);
228
+ const project = (
229
+ lonLat: readonly [number, number]
230
+ ): [number, number] | null => lonLatToPixel(layout, lonLat);
231
+
232
+ const locate = (px: number, py: number): ResultCard | null => {
233
+ const lonLat = invert(px, py);
234
+ if (!lonLat) return null;
235
+ const region = regionAt(lonLat, countries, states);
236
+ const city = nearestCity(lonLat, gazetteer);
237
+ return {
238
+ lonLat,
239
+ country: region.country,
240
+ state: region.state,
241
+ nearestCity: city,
242
+ tokens: buildTokens(lonLat, region, city),
243
+ };
244
+ };
245
+
246
+ const cities = (extent?: GeoExtent): ProjectedCity[] => {
247
+ // Population-primary cull (extent only a coarse secondary filter — review
248
+ // L-NEW-3): the data extent is NOT the visible viewport when a wide map is
249
+ // scrolled, so rank by population and cap, keeping only on-canvas dots.
250
+ const sorted = [...gazetteer.cities].sort((a, b) => b[3] - a[3]);
251
+ const out: ProjectedCity[] = [];
252
+ for (const c of sorted) {
253
+ const [lat, lon, iso, pop, name, sub] = c;
254
+ if (extent) {
255
+ const [[w, s], [e, n]] = extent;
256
+ if (lon < w || lon > e || lat < s || lat > n) continue;
257
+ }
258
+ const p = project([lon, lat]);
259
+ if (!p) continue;
260
+ if (p[0] < 0 || p[0] > width || p[1] < 0 || p[1] > height) continue;
261
+ out.push({
262
+ name,
263
+ iso,
264
+ ...(sub !== undefined && { sub }),
265
+ lon,
266
+ lat,
267
+ px: p[0],
268
+ py: p[1],
269
+ pop,
270
+ });
271
+ if (out.length >= MAX_CITY_DOTS) break;
272
+ }
273
+ return out;
274
+ };
275
+
276
+ return { invert, project, locate, cities };
277
+ }
package/src/map/geo.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // feature bounds (via d3-geo geoBounds — NOT naive min/max, which breaks on the
3
3
  // antimeridian and on multi-part features like US Alaska/Hawaii; R5/R6).
4
4
  import { feature } from 'topojson-client';
5
- import { geoBounds } from 'd3-geo';
5
+ import { geoBounds, geoArea } from 'd3-geo';
6
6
  import type { BoundaryTopology } from './data/types';
7
7
  import type { GeoExtent } from './resolved-types';
8
8
 
@@ -47,6 +47,159 @@ export function idSet(topo: BoundaryTopology): Set<string> {
47
47
  return new Set(geomObject(topo).geometries.map((g) => g.id));
48
48
  }
49
49
 
50
+ /** A decoded boundary feature: the GeoJSON geometry plus its ISO id and display
51
+ * name (carried straight from the topology's `properties.name`). Used for
52
+ * point-in-polygon reverse-geocoding by the geo-query. */
53
+ export interface DecodedFeature {
54
+ readonly type: 'Feature';
55
+ readonly id: string;
56
+ readonly properties: { readonly name: string };
57
+ readonly geometry: unknown;
58
+ }
59
+
60
+ /** Decode every feature of a topology into GeoJSON, keyed nowhere — returned as a
61
+ * flat array so the geo-query can decode ONCE at construction and reuse the
62
+ * result across every `regionAt` call (no per-click re-decode). Uses this
63
+ * module's own `geomObject`/`feature` (never layout.ts's private decoder). */
64
+ export function decodeFeatures(topo: BoundaryTopology): DecodedFeature[] {
65
+ return geomObject(topo).geometries.map((g) => {
66
+ const f = feature(topo as never, g as never) as unknown as {
67
+ geometry: unknown;
68
+ };
69
+ return {
70
+ type: 'Feature',
71
+ id: g.id,
72
+ properties: g.properties,
73
+ geometry: f.geometry,
74
+ };
75
+ });
76
+ }
77
+
78
+ /** Even-odd ray-cast: is `[lon, lat]` inside a single lon/lat ring? Planar (NOT
79
+ * spherical): d3-geo `geoContains` is winding-sensitive and the Natural-Earth
80
+ * rings invert under it (a small country reads as covering the globe). A planar
81
+ * ray-cast on the raw lon/lat coordinates is the correct, robust test at this
82
+ * reverse-geocode resolution (antimeridian crossers ship as seam-split parts, so
83
+ * no ring actually wraps ±180). */
84
+ function pointInRing(
85
+ lon: number,
86
+ lat: number,
87
+ ring: ReadonlyArray<readonly number[]>
88
+ ): boolean {
89
+ let inside = false;
90
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
91
+ const xi = ring[i]![0]!;
92
+ const yi = ring[i]![1]!;
93
+ const xj = ring[j]![0]!;
94
+ const yj = ring[j]![1]!;
95
+ const intersect =
96
+ yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
97
+ if (intersect) inside = !inside;
98
+ }
99
+ return inside;
100
+ }
101
+
102
+ // On-boundary tolerance (degrees). A click landing exactly on a shared border or
103
+ // a ring vertex is otherwise float-dependent (ray-cast vertex double-count → the
104
+ // point reads as inside neither neighbour, returning ocean over land). Treating
105
+ // an on-edge point as INSIDE makes `regionAt` deterministic: the first iterated
106
+ // country whose boundary the point sits on wins, rather than a rounding coin-flip.
107
+ const EDGE_EPS = 1e-9;
108
+
109
+ /** Is `[lon, lat]` on any edge of `ring` (within EDGE_EPS)? */
110
+ function pointOnRingEdge(
111
+ lon: number,
112
+ lat: number,
113
+ ring: ReadonlyArray<readonly number[]>
114
+ ): boolean {
115
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
116
+ const xi = ring[i]![0]!;
117
+ const yi = ring[i]![1]!;
118
+ const xj = ring[j]![0]!;
119
+ const yj = ring[j]![1]!;
120
+ // Bounding-box reject first (cheap).
121
+ if (lon < Math.min(xi, xj) - EDGE_EPS || lon > Math.max(xi, xj) + EDGE_EPS)
122
+ continue;
123
+ if (lat < Math.min(yi, yj) - EDGE_EPS || lat > Math.max(yi, yj) + EDGE_EPS)
124
+ continue;
125
+ // Collinear with the segment ⇒ on it (bbox already bounded the extent).
126
+ const cross = (xj - xi) * (lat - yi) - (yj - yi) * (lon - xi);
127
+ if (Math.abs(cross) <= EDGE_EPS) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /** Point-in-polygon for a Polygon/MultiPolygon geometry (outer ring minus holes).
133
+ * A point on the outer boundary counts as inside (deterministic border handling). */
134
+ function pointInGeometry(geometry: unknown, lon: number, lat: number): boolean {
135
+ const g = geometry as {
136
+ type: string;
137
+ coordinates: number[][][] | number[][][][];
138
+ } | null;
139
+ if (!g) return false;
140
+ const polys: number[][][][] =
141
+ g.type === 'Polygon'
142
+ ? [g.coordinates as number[][][]]
143
+ : g.type === 'MultiPolygon'
144
+ ? (g.coordinates as number[][][][])
145
+ : [];
146
+ for (const rings of polys) {
147
+ if (!rings.length) continue;
148
+ // On the outer boundary ⇒ inside (deterministic; beats the ray-cast coin-flip).
149
+ if (pointOnRingEdge(lon, lat, rings[0]!)) return true;
150
+ if (!pointInRing(lon, lat, rings[0]!)) continue;
151
+ // Inside the outer ring — exclude if it falls strictly within a hole (a point
152
+ // on the hole boundary is still land, so it stays inside).
153
+ let inHole = false;
154
+ for (let h = 1; h < rings.length; h++) {
155
+ if (
156
+ pointInRing(lon, lat, rings[h]!) &&
157
+ !pointOnRingEdge(lon, lat, rings[h]!)
158
+ ) {
159
+ inHole = true;
160
+ break;
161
+ }
162
+ }
163
+ if (!inHole) return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ /** Reverse-geocode a `[lon, lat]` to its containing country and (US-only) state
169
+ * via planar point-in-polygon. Honest about misses: returns `country: null`
170
+ * when no polygon contains the point (open ocean / outside any country) rather
171
+ * than guessing (F4). State is tested only when the country is the US.
172
+ * `countries` should be world-detail (50m) features; `states` the us-states
173
+ * (10m) features. Both are pre-decoded by the caller. */
174
+ export function regionAt(
175
+ lonLat: readonly [number, number],
176
+ countries: readonly DecodedFeature[],
177
+ states: readonly DecodedFeature[] | null
178
+ ): {
179
+ country: { iso: string; name: string } | null;
180
+ state: { iso: string; name: string } | null;
181
+ } {
182
+ const lon = lonLat[0];
183
+ const lat = lonLat[1];
184
+ let country: { iso: string; name: string } | null = null;
185
+ for (const f of countries) {
186
+ if (pointInGeometry(f.geometry, lon, lat)) {
187
+ country = { iso: f.id, name: f.properties.name };
188
+ break;
189
+ }
190
+ }
191
+ let state: { iso: string; name: string } | null = null;
192
+ if (country?.iso === 'US' && states) {
193
+ for (const f of states) {
194
+ if (pointInGeometry(f.geometry, lon, lat)) {
195
+ state = { iso: f.id, name: f.properties.name };
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ return { country, state };
201
+ }
202
+
50
203
  /** Antimeridian-correct geographic bbox of one feature (by id), or null. */
51
204
  export function featureBbox(
52
205
  topo: BoundaryTopology,
@@ -65,6 +218,110 @@ export function featureBbox(
65
218
  ];
66
219
  }
67
220
 
221
+ // Framing-extent thresholds for `featureBboxPrimary` (R5). A detached polygon is
222
+ // kept in the framing bbox only if it is either near the dominant cluster
223
+ // (within GAP degrees in both axes) or large enough to matter (≥ AREA_FRAC of
224
+ // the largest polygon). This drops far-flung minor territories — French Guiana,
225
+ // Hawaii, the Canaries are already absent from coarse Spain — so a "Europe"
226
+ // choropleth that names France frames on metropolitan France, not the Atlantic,
227
+ // while keeping near islands and large detached parts (Alaska) in frame.
228
+ const DETACH_GAP_DEG = 10;
229
+ const DETACH_AREA_FRAC = 0.25;
230
+
231
+ /** Decompose a Polygon/MultiPolygon GeoJSON geometry into per-polygon features. */
232
+ function explodePolygons(gj: {
233
+ type: string;
234
+ geometry?: { type: string; coordinates: unknown };
235
+ }): Array<{
236
+ type: 'Feature';
237
+ geometry: { type: 'Polygon'; coordinates: unknown };
238
+ }> {
239
+ const g = gj.geometry ?? (gj as never);
240
+ const t = (g as { type: string }).type;
241
+ const coords = (g as { coordinates: unknown[] }).coordinates;
242
+ if (t === 'Polygon') {
243
+ return [
244
+ { type: 'Feature', geometry: { type: 'Polygon', coordinates: coords } },
245
+ ];
246
+ }
247
+ if (t === 'MultiPolygon') {
248
+ return (coords as unknown[]).map((rings) => ({
249
+ type: 'Feature' as const,
250
+ geometry: { type: 'Polygon' as const, coordinates: rings },
251
+ }));
252
+ }
253
+ return [];
254
+ }
255
+
256
+ /** Gap (degrees) between two bboxes — 0 if they overlap/touch on an axis. */
257
+ function bboxGap(a: GeoExtent, b: GeoExtent): number {
258
+ const lonGap = Math.max(0, a[0][0] - b[1][0], b[0][0] - a[1][0]);
259
+ const latGap = Math.max(0, a[0][1] - b[1][1], b[0][1] - a[1][1]);
260
+ return Math.max(lonGap, latGap);
261
+ }
262
+
263
+ /** Like `featureBbox`, but for FRAMING: ignores far-detached minor territories
264
+ * (overseas DOM-TOM, distant small islands) so a multi-part country frames on
265
+ * its dominant landmass cluster. Falls back to the full bbox for single-part
266
+ * features or when decomposition fails. Antimeridian-spanning parts (geoBounds
267
+ * west > east) are treated as full-bbox to avoid mis-clustering across the seam. */
268
+ export function featureBboxPrimary(
269
+ topo: BoundaryTopology,
270
+ geomId: string
271
+ ): GeoExtent | null {
272
+ const geom = geomObject(topo).geometries.find((g) => g.id === geomId);
273
+ if (!geom) return null;
274
+ const gj = feature(topo as never, geom as never) as never;
275
+ const parts = explodePolygons(gj);
276
+ if (parts.length <= 1) return featureBbox(topo, geomId);
277
+
278
+ const polys = parts
279
+ .map((p) => {
280
+ const b = geoBounds(p as never);
281
+ if (!b || !Number.isFinite(b[0][0])) return null;
282
+ // Skip antimeridian-wrapping parts for clustering math (handled by full bbox).
283
+ const wraps = b[1][0] < b[0][0];
284
+ const bbox: GeoExtent = [
285
+ [b[0][0], b[0][1]],
286
+ [b[1][0], b[1][1]],
287
+ ];
288
+ return { bbox, area: geoArea(p as never), wraps };
289
+ })
290
+ .filter(
291
+ (p): p is { bbox: GeoExtent; area: number; wraps: boolean } => p !== null
292
+ );
293
+
294
+ if (polys.length <= 1 || polys.some((p) => p.wraps))
295
+ return featureBbox(topo, geomId);
296
+
297
+ const maxArea = Math.max(...polys.map((p) => p.area));
298
+ const anchor = polys.find((p) => p.area === maxArea)!;
299
+ // Grow the cluster: keep a part if it is near the current cluster OR large.
300
+ const cluster: GeoExtent = [
301
+ [anchor.bbox[0][0], anchor.bbox[0][1]],
302
+ [anchor.bbox[1][0], anchor.bbox[1][1]],
303
+ ];
304
+ const remaining = polys.filter((p) => p !== anchor);
305
+ let added = true;
306
+ while (added) {
307
+ added = false;
308
+ for (let i = remaining.length - 1; i >= 0; i--) {
309
+ const p = remaining[i]!;
310
+ const near = bboxGap(p.bbox, cluster) <= DETACH_GAP_DEG;
311
+ const large = p.area >= DETACH_AREA_FRAC * maxArea;
312
+ if (near || large) {
313
+ cluster[0][0] = Math.min(cluster[0][0], p.bbox[0][0]);
314
+ cluster[0][1] = Math.min(cluster[0][1], p.bbox[0][1]);
315
+ cluster[1][0] = Math.max(cluster[1][0], p.bbox[1][0]);
316
+ cluster[1][1] = Math.max(cluster[1][1], p.bbox[1][1]);
317
+ remaining.splice(i, 1);
318
+ added = true;
319
+ }
320
+ }
321
+ }
322
+ return cluster;
323
+ }
324
+
68
325
  /** Union of bboxes + POI points into one extent; null if empty. Longitude union
69
326
  * uses the smaller-arc rule so an antimeridian-crossing union doesn't span the
70
327
  * globe.
@@ -0,0 +1,111 @@
1
+ // Composite- + stretch-aware pixel↔lonLat for the geo-query (step-5 inspector).
2
+ // This is the FIRST map code to call `projection.invert()`. It binds to the
3
+ // REAL fitted projection(s) captured on the MapLayout (layout.ts Task 1) — the
4
+ // caller never reconstructs a projection from metadata, so an inverted pixel
5
+ // lands exactly where the rendered SVG drew the corresponding point.
6
+ //
7
+ // Three cases, in priority order:
8
+ // 1. albers-usa insets — a pixel inside an AK/HI frame rect inverts against
9
+ // that inset's FITTED projection (the un-fitted factory would be garbage).
10
+ // 2. global stretch fit — undo the non-uniform stretch BEFORE the main invert
11
+ // (and apply it AFTER the main project).
12
+ // 3. plain regional/world fit — invert/project the main projection directly
13
+ // (clipExtent set on a regional projection is ignored by `.invert`).
14
+ import type { MapLayout, MapLayoutInset } from './layout';
15
+
16
+ /** True if `(px,py)` is inside an inset frame's bounding box. */
17
+ function inInsetFrame(inset: MapLayoutInset, px: number, py: number): boolean {
18
+ return (
19
+ px >= inset.x &&
20
+ px <= inset.x + inset.w &&
21
+ py >= inset.y &&
22
+ py <= inset.y + inset.h
23
+ );
24
+ }
25
+
26
+ /** Undo the global stretch: screen px → base-projection px. */
27
+ function unstretch(
28
+ layout: MapLayout,
29
+ px: number,
30
+ py: number
31
+ ): [number, number] {
32
+ const s = layout.stretch!;
33
+ return [
34
+ s.bx0 + (s.sx !== 0 ? (px - s.ox) / s.sx : 0),
35
+ s.by0 + (s.sy !== 0 ? (py - s.oy) / s.sy : 0),
36
+ ];
37
+ }
38
+
39
+ /** Apply the global stretch: base-projection px → screen px. */
40
+ function applyStretch(
41
+ layout: MapLayout,
42
+ x: number,
43
+ y: number
44
+ ): [number, number] {
45
+ const s = layout.stretch!;
46
+ return [s.ox + (x - s.bx0) * s.sx, s.oy + (y - s.by0) * s.sy];
47
+ }
48
+
49
+ /** Screen pixel → `[lon, lat]`, or null for an out-of-domain pixel (e.g. deep
50
+ * ocean beyond the projection's clip disc). */
51
+ export function pixelToLonLat(
52
+ layout: MapLayout,
53
+ px: number,
54
+ py: number
55
+ ): [number, number] | null {
56
+ // (1) Inset hit-test first — AK/HI frames sit over the lower-left water of the
57
+ // conus, so their pixels would otherwise invert against the conus conic.
58
+ for (const inset of layout.insets) {
59
+ if (inInsetFrame(inset, px, py)) {
60
+ const ll = inset.projection.invert?.([px, py]);
61
+ return ll && Number.isFinite(ll[0]) && Number.isFinite(ll[1])
62
+ ? [ll[0], ll[1]]
63
+ : null;
64
+ }
65
+ }
66
+ // (2)/(3) main projection (undo the stretch first for a global fit).
67
+ const [x, y] = layout.stretch ? unstretch(layout, px, py) : [px, py];
68
+ const ll = layout.projection.invert?.([x, y]);
69
+ return ll && Number.isFinite(ll[0]) && Number.isFinite(ll[1])
70
+ ? [ll[0], ll[1]]
71
+ : null;
72
+ }
73
+
74
+ /** `[lon, lat]` → screen pixel, or null if it projects nowhere. AK/HI points
75
+ * land in their inset frames; everything else projects via the main projection
76
+ * (with the forward stretch for a global fit). */
77
+ export function lonLatToPixel(
78
+ layout: MapLayout,
79
+ lonLat: readonly [number, number]
80
+ ): [number, number] | null {
81
+ const pt: [number, number] = [lonLat[0], lonLat[1]];
82
+ // Main projection first.
83
+ const main = layout.projection(pt);
84
+ const mainPx: [number, number] | null =
85
+ main && Number.isFinite(main[0]) && Number.isFinite(main[1])
86
+ ? layout.stretch
87
+ ? applyStretch(layout, main[0], main[1])
88
+ : [main[0], main[1]]
89
+ : null;
90
+ // If the main pixel is on-canvas, it's the lower-48/world position — use it.
91
+ const onCanvas =
92
+ !!mainPx &&
93
+ mainPx[0] >= 0 &&
94
+ mainPx[0] <= layout.width &&
95
+ mainPx[1] >= 0 &&
96
+ mainPx[1] <= layout.height;
97
+ if (onCanvas) return mainPx;
98
+ // Off-canvas under the main projection (AK/HI under the conus conic): see if an
99
+ // inset claims it — project via the inset and keep it if it lands in the frame.
100
+ for (const inset of layout.insets) {
101
+ const p = inset.projection(pt);
102
+ if (
103
+ p &&
104
+ Number.isFinite(p[0]) &&
105
+ Number.isFinite(p[1]) &&
106
+ inInsetFrame(inset, p[0], p[1])
107
+ )
108
+ return [p[0], p[1]];
109
+ }
110
+ return mainPx;
111
+ }