@diagrammo/dgmo 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advanced.cjs +556 -195
- package/dist/advanced.js +555 -195
- package/dist/auto.cjs +322 -196
- package/dist/auto.js +113 -113
- package/dist/auto.mjs +322 -196
- package/dist/cli.cjs +156 -156
- package/dist/editor.cjs +1 -0
- package/dist/editor.js +1 -0
- package/dist/highlight.cjs +1 -0
- package/dist/highlight.js +1 -0
- package/dist/index.cjs +320 -195
- package/dist/index.js +320 -195
- package/dist/internal.cjs +556 -195
- package/dist/internal.js +555 -195
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -0
- package/docs/language-reference.md +27 -25
- package/gallery/fixtures/map-direct-color.dgmo +10 -0
- package/package.json +1 -1
- package/src/advanced.ts +14 -0
- package/src/completion.ts +1 -0
- package/src/d3.ts +15 -9
- package/src/editor/keywords.ts +1 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -0
- package/src/map/geo-query.ts +277 -0
- package/src/map/geo.ts +258 -1
- package/src/map/invert.ts +111 -0
- package/src/map/layout.ts +233 -113
- package/src/map/load-data.ts +7 -1
- package/src/map/parser.ts +22 -2
- package/src/map/renderer.ts +44 -0
- package/src/map/resolved-types.ts +8 -0
- package/src/map/resolver.ts +40 -19
- package/src/map/types.ts +18 -0
- package/dist/advanced.d.cts +0 -5331
- package/dist/advanced.d.ts +0 -5331
- package/dist/auto.d.cts +0 -39
- package/dist/auto.d.ts +0 -39
- package/dist/index.d.cts +0 -336
- package/dist/index.d.ts +0 -336
- package/dist/internal.d.cts +0 -5331
- package/dist/internal.d.ts +0 -5331
|
@@ -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
|
+
}
|