@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.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/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/package.json +168 -82
- package/poi-lookup.ts +319 -0
- package/poi-schema.ts +147 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +429 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- package/unified-schema.ts +171 -0
package/reverse.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Reverse geocoding (#484): `(lat, lon)` → the containing admin hierarchy. Assembly over existing
|
|
7
|
+
* machinery, per the 2026-06-11 scoping notes:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Candidate fetch** — the admin DB's `place_bbox` R*Tree (built by `fts.ts`) for places whose
|
|
10
|
+
* bbox contains the point, smallest-area-first (so the FIRST polygon confirmation is the
|
|
11
|
+
* deepest).
|
|
12
|
+
* 2. **PIP confirmation** — ray-cast (geo.ts, the canonical TS port of
|
|
13
|
+
* `scripts/eval/pip-containment.py`) against the polygon sidecar DB (`wof-polygons.db`,
|
|
14
|
+
* `polygons(id, geom)` with GeoJSON text — built by `scripts/build-wof-polygons.mjs` for the
|
|
15
|
+
* demo map). A candidate whose polygon EXISTS but rejects the point is a bbox false positive
|
|
16
|
+
* and is dropped entirely; a candidate with no polygon row stays eligible for the
|
|
17
|
+
* approximate fallback.
|
|
18
|
+
* 3. **Approximate descent** — WOF carries point geometry for most localities (#292: ~99% of JP
|
|
19
|
+
* municipalities; ~half of US localities have degenerate bboxes too), so the polygon walk
|
|
20
|
+
* usually bottoms out at county level. We then descend tier-by-tier (county → localadmin →
|
|
21
|
+
* locality → …) through the winner's DESCENDANTS (the `ancestors` table, reversed), taking
|
|
22
|
+
* the PIP-confirmed child when a polygon exists and the nearest-centroid child otherwise —
|
|
23
|
+
* the latter flagged `containment: "approximate"`, the demo's honesty convention.
|
|
24
|
+
* 4. **Hierarchy assembly** — the deepest place's ancestor chain via the SAME walk forward resolution
|
|
25
|
+
* uses (`ancestry.ts`, #404), so consumers get a symmetric tree.
|
|
26
|
+
*
|
|
27
|
+
* Reverse quality is country-dependent (polygon coverage: see the #292 JP finding); `containment`
|
|
28
|
+
* says so per result rather than pretending.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { DatabaseSync } from "node:sqlite"
|
|
32
|
+
|
|
33
|
+
import { ancestorLineage, placetypeDepth } from "./ancestry.ts"
|
|
34
|
+
import { PLACE_BBOX_TABLE } from "./fts.ts"
|
|
35
|
+
import { geometryContains, haversineKm, type GeojsonGeometry } from "./geo.ts"
|
|
36
|
+
import type { PlaceCandidate, WOFPlacetype } from "./types.ts"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How the deepest returned place was confirmed:
|
|
40
|
+
*
|
|
41
|
+
* - `"polygon"` — the point ray-cast INSIDE the place's real (DP-simplified) admin boundary.
|
|
42
|
+
* - `"approximate"` — the place has no polygon on record; it won by nearest-centroid among the candidates whose bbox (or
|
|
43
|
+
* parent) contains the point. The same honesty convention as the demo's approximate circles — country-dependent data
|
|
44
|
+
* reality, surfaced instead of hidden.
|
|
45
|
+
*/
|
|
46
|
+
export type ContainmentKind = "polygon" | "approximate"
|
|
47
|
+
|
|
48
|
+
export interface ReverseGeocodeResult {
|
|
49
|
+
/**
|
|
50
|
+
* The containment chain, DEEPEST-FIRST (`[0]` is the winning place, then its ancestors up to country) — the same tree
|
|
51
|
+
* shape forward resolution attaches via `includeAncestors`. Empty when no candidate's bbox contains the point (open
|
|
52
|
+
* ocean, or outside the gazetteer's coverage).
|
|
53
|
+
*/
|
|
54
|
+
hierarchy: PlaceCandidate[]
|
|
55
|
+
/** Containment kind of the DEEPEST place in `hierarchy` (see {@link ContainmentKind}). */
|
|
56
|
+
containment: ContainmentKind
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WOFReverseGeocoderOpts {
|
|
60
|
+
/**
|
|
61
|
+
* Path to the admin gazetteer DB (e.g. `admin-global-priority.db`) — must carry `spr`, `ancestors`, and the
|
|
62
|
+
* package-built `place_bbox` R*Tree (`mailwoman gazetteer build fts`). Mutually exclusive with `adminDatabase`.
|
|
63
|
+
*/
|
|
64
|
+
adminDBPath?: string
|
|
65
|
+
/** Pre-opened admin DB — primarily for tests against an inline fixture. */
|
|
66
|
+
adminDatabase?: DatabaseSync
|
|
67
|
+
/**
|
|
68
|
+
* Path to the polygon sidecar DB (`wof-polygons.db`, table `polygons(id, geom)`). OPTIONAL — without it every result
|
|
69
|
+
* is `containment: "approximate"` (centroid-only mode). Mutually exclusive with `polygonDatabase`.
|
|
70
|
+
*/
|
|
71
|
+
polygonDBPath?: string
|
|
72
|
+
/** Pre-opened polygon DB — primarily for tests. */
|
|
73
|
+
polygonDatabase?: DatabaseSync
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReverseGeocodeOpts {
|
|
77
|
+
/**
|
|
78
|
+
* Restrict the hierarchy to these placetypes (both the bbox candidates and the descent tiers). Default: every admin
|
|
79
|
+
* placetype the gazetteer carries. E.g. `["region", "county", "locality"]` to skip the neighbourhood grain.
|
|
80
|
+
*/
|
|
81
|
+
placetypes?: WOFPlacetype[]
|
|
82
|
+
/**
|
|
83
|
+
* Cap on the bbox candidate fetch. Default 128 — comfortably covers a dense metro (the most bbox-overlapping point
|
|
84
|
+
* we've measured is a few dozen neighbourhoods + the admin chain).
|
|
85
|
+
*/
|
|
86
|
+
maxCandidates?: number
|
|
87
|
+
/**
|
|
88
|
+
* Approximate (nearest-centroid) steps further than this from the query point are not taken — keeps a sparse
|
|
89
|
+
* gazetteer from "refining" to a far-away sibling. Polygon-confirmed steps ignore it (containment is exact regardless
|
|
90
|
+
* of centroid distance). Default 25 km.
|
|
91
|
+
*/
|
|
92
|
+
maxApproximateKm?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DEFAULT_MAX_CANDIDATES = 128
|
|
96
|
+
const DEFAULT_MAX_APPROXIMATE_KM = 25
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tier ladder for the approximate descent, coarsest-first. Each tier is attempted among the CURRENT winner's
|
|
100
|
+
* descendants; a tier with no rows is skipped (e.g. counties without localadmins jump straight to locality).
|
|
101
|
+
*/
|
|
102
|
+
const DESCENT_TIERS: readonly WOFPlacetype[] = [
|
|
103
|
+
"county",
|
|
104
|
+
"localadmin",
|
|
105
|
+
"locality",
|
|
106
|
+
"borough",
|
|
107
|
+
"neighbourhood",
|
|
108
|
+
"microhood",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
/** Internal candidate row off `spr` (+ optional bbox area / centroid distance bookkeeping). */
|
|
112
|
+
interface CandidateRow {
|
|
113
|
+
id: number
|
|
114
|
+
name: string
|
|
115
|
+
placetype: string
|
|
116
|
+
country: string | null
|
|
117
|
+
parent_id: number | null
|
|
118
|
+
lat: number
|
|
119
|
+
lon: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toPlaceCandidate(row: CandidateRow, distanceKm?: number): PlaceCandidate {
|
|
123
|
+
const c: PlaceCandidate = {
|
|
124
|
+
id: row.id,
|
|
125
|
+
name: row.name,
|
|
126
|
+
placetype: row.placetype as WOFPlacetype,
|
|
127
|
+
country: row.country ?? "",
|
|
128
|
+
lat: row.lat,
|
|
129
|
+
lon: row.lon,
|
|
130
|
+
parent_id: row.parent_id ?? undefined,
|
|
131
|
+
score: 0,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (distanceKm !== undefined) {
|
|
135
|
+
c.distanceKm = distanceKm
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return c
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class WOFReverseGeocoder implements Disposable {
|
|
142
|
+
readonly #admin: DatabaseSync
|
|
143
|
+
readonly #ownsAdmin: boolean
|
|
144
|
+
readonly #polygons: DatabaseSync | null
|
|
145
|
+
readonly #ownsPolygons: boolean
|
|
146
|
+
/**
|
|
147
|
+
* Parsed-geometry cache. Reverse queries cluster geographically (an eval run hits the same ~15 county polygons 1400
|
|
148
|
+
* times), so caching the JSON.parse pays for itself immediately. Bounded — cleared wholesale at the cap rather than
|
|
149
|
+
* LRU-tracked; the polygons are DP-simplified and small, the cap exists only to keep a long-lived server process
|
|
150
|
+
* honest.
|
|
151
|
+
*/
|
|
152
|
+
readonly #geometryCache = new Map<number, GeojsonGeometry | null>()
|
|
153
|
+
static readonly #GEOMETRY_CACHE_CAP = 4096
|
|
154
|
+
|
|
155
|
+
constructor(opts: WOFReverseGeocoderOpts) {
|
|
156
|
+
if (opts.adminDatabase && opts.adminDBPath) {
|
|
157
|
+
throw new Error("WOFReverseGeocoder: pass either `adminDatabase` or `adminDBPath`, not both")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!opts.adminDatabase && !opts.adminDBPath) {
|
|
161
|
+
throw new Error("WOFReverseGeocoder: one of `adminDatabase` or `adminDBPath` is required")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (opts.polygonDatabase && opts.polygonDBPath) {
|
|
165
|
+
throw new Error("WOFReverseGeocoder: pass either `polygonDatabase` or `polygonDBPath`, not both")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.#admin = opts.adminDatabase ?? new DatabaseSync(opts.adminDBPath!, { readOnly: true })
|
|
169
|
+
this.#ownsAdmin = !opts.adminDatabase
|
|
170
|
+
this.#polygons =
|
|
171
|
+
opts.polygonDatabase ?? (opts.polygonDBPath ? new DatabaseSync(opts.polygonDBPath, { readOnly: true }) : null)
|
|
172
|
+
this.#ownsPolygons = !opts.polygonDatabase && Boolean(opts.polygonDBPath)
|
|
173
|
+
|
|
174
|
+
// Fail loudly up front — the R*Tree is a build artifact, not part of the upstream WOF
|
|
175
|
+
// distribution, and a missing index would otherwise surface as an opaque SQL error per query.
|
|
176
|
+
const hasBbox = this.#admin
|
|
177
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
178
|
+
.get(PLACE_BBOX_TABLE)
|
|
179
|
+
|
|
180
|
+
if (!hasBbox) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`WOFReverseGeocoder: the admin DB has no \`${PLACE_BBOX_TABLE}\` R*Tree. Build it with ` +
|
|
183
|
+
"`mailwoman gazetteer build fts <path-to-wof.db>` (see resolver-wof-sqlite/README.md)."
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.#polygons) {
|
|
188
|
+
const hasPolygons = this.#polygons
|
|
189
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'polygons'`)
|
|
190
|
+
.get()
|
|
191
|
+
|
|
192
|
+
if (!hasPolygons) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"WOFReverseGeocoder: the polygon DB has no `polygons` table. Expected a `wof-polygons.db` " +
|
|
195
|
+
"built by scripts/build-wof-polygons.mjs."
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a WGS-84 point to its containing admin hierarchy. Async for symmetry with `PlaceLookup.findPlace` (the work
|
|
203
|
+
* is sync `node:sqlite` underneath — same convention).
|
|
204
|
+
*/
|
|
205
|
+
async reverseGeocode(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): Promise<ReverseGeocodeResult> {
|
|
206
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
|
|
207
|
+
throw new RangeError(`WOFReverseGeocoder.reverseGeocode: (${lat}, ${lon}) is not a WGS-84 coordinate`)
|
|
208
|
+
}
|
|
209
|
+
const maxApproximateKm = opts.maxApproximateKm ?? DEFAULT_MAX_APPROXIMATE_KM
|
|
210
|
+
const candidates = this.#bboxCandidates(lat, lon, opts)
|
|
211
|
+
|
|
212
|
+
// PIP walk, smallest-bbox-first: the first polygon that contains the point is the deepest
|
|
213
|
+
// polygon-confirmable place. Polygon-rejected candidates are bbox false positives — dropped.
|
|
214
|
+
let winner: CandidateRow | null = null
|
|
215
|
+
let winnerConfirmed = false
|
|
216
|
+
const pointOnly: CandidateRow[] = []
|
|
217
|
+
|
|
218
|
+
for (const c of candidates) {
|
|
219
|
+
const contains = geometryContains(this.#geometry(c.id), lon, lat)
|
|
220
|
+
|
|
221
|
+
if (contains === true) {
|
|
222
|
+
winner = c
|
|
223
|
+
winnerConfirmed = true
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (contains === null) {
|
|
228
|
+
pointOnly.push(c)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!winner) {
|
|
233
|
+
// No polygon confirmed anywhere — nearest centroid among the polygon-less bbox candidates.
|
|
234
|
+
let bestKm = Infinity
|
|
235
|
+
|
|
236
|
+
for (const c of pointOnly) {
|
|
237
|
+
const km = haversineKm(lat, lon, c.lat, c.lon)
|
|
238
|
+
|
|
239
|
+
if (km < bestKm) {
|
|
240
|
+
bestKm = km
|
|
241
|
+
winner = c
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!winner) return { hierarchy: [], containment: "approximate" }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Approximate descent into finer tiers than the winner.
|
|
249
|
+
let current = winner
|
|
250
|
+
let currentConfirmed = winnerConfirmed
|
|
251
|
+
let currentDistanceKm = currentConfirmed ? undefined : haversineKm(lat, lon, current.lat, current.lon)
|
|
252
|
+
|
|
253
|
+
for (const tier of DESCENT_TIERS) {
|
|
254
|
+
if (placetypeDepth(tier) <= placetypeDepth(current.placetype)) continue
|
|
255
|
+
|
|
256
|
+
if (opts.placetypes && !opts.placetypes.includes(tier)) continue
|
|
257
|
+
const kids = this.#descendants(current.id, tier, lat, lon, maxApproximateKm)
|
|
258
|
+
let next: CandidateRow | null = null
|
|
259
|
+
let nextConfirmed = false
|
|
260
|
+
let nextKm: number | undefined
|
|
261
|
+
let bestKm = Infinity
|
|
262
|
+
|
|
263
|
+
for (const k of kids) {
|
|
264
|
+
const contains = geometryContains(this.#geometry(k.id), lon, lat)
|
|
265
|
+
|
|
266
|
+
if (contains === true) {
|
|
267
|
+
next = k
|
|
268
|
+
nextConfirmed = true
|
|
269
|
+
nextKm = undefined
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (contains === false) continue // known not-here — polygon rejected
|
|
274
|
+
const km = haversineKm(lat, lon, k.lat, k.lon)
|
|
275
|
+
|
|
276
|
+
if (km <= maxApproximateKm && km < bestKm) {
|
|
277
|
+
bestKm = km
|
|
278
|
+
next = k
|
|
279
|
+
nextConfirmed = false
|
|
280
|
+
nextKm = km
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (next) {
|
|
285
|
+
current = next
|
|
286
|
+
currentConfirmed = nextConfirmed
|
|
287
|
+
currentDistanceKm = nextKm
|
|
288
|
+
}
|
|
289
|
+
// An empty tier is NOT terminal — counties without localadmins jump straight to locality.
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Hierarchy assembly via the shared ancestor walk. If the descent crossed an ancestry gap
|
|
293
|
+
// (the deepest place's recorded lineage misses the PIP root), merge the root's own chain so
|
|
294
|
+
// region/country are always present when a polygon confirmed them.
|
|
295
|
+
const byID = new Map<number, PlaceCandidate>()
|
|
296
|
+
byID.set(current.id, toPlaceCandidate(current, currentDistanceKm))
|
|
297
|
+
|
|
298
|
+
for (const a of ancestorLineage(this.#admin, current.id)) {
|
|
299
|
+
if (!byID.has(a.id)) {
|
|
300
|
+
byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!byID.has(winner.id)) {
|
|
305
|
+
byID.set(winner.id, toPlaceCandidate(winner))
|
|
306
|
+
|
|
307
|
+
for (const a of ancestorLineage(this.#admin, winner.id)) {
|
|
308
|
+
if (!byID.has(a.id)) {
|
|
309
|
+
byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const hierarchy = [...byID.values()]
|
|
314
|
+
|
|
315
|
+
if (opts.placetypes) {
|
|
316
|
+
const allowed = new Set<string>(opts.placetypes)
|
|
317
|
+
|
|
318
|
+
for (let i = hierarchy.length - 1; i >= 0; i--) {
|
|
319
|
+
if (!allowed.has(hierarchy[i]!.placetype)) {
|
|
320
|
+
hierarchy.splice(i, 1)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
hierarchy.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
|
|
325
|
+
|
|
326
|
+
return { hierarchy, containment: currentConfirmed ? "polygon" : "approximate" }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree. */
|
|
330
|
+
#bboxCandidates(lat: number, lon: number, opts: ReverseGeocodeOpts): CandidateRow[] {
|
|
331
|
+
const where: string[] = [
|
|
332
|
+
"bbox.min_lat <= ?",
|
|
333
|
+
"bbox.max_lat >= ?",
|
|
334
|
+
"bbox.min_lon <= ?",
|
|
335
|
+
"bbox.max_lon >= ?",
|
|
336
|
+
"spr.is_current != 0",
|
|
337
|
+
"spr.is_deprecated = 0",
|
|
338
|
+
]
|
|
339
|
+
const params: Array<number | string> = [lat, lat, lon, lon]
|
|
340
|
+
|
|
341
|
+
if (opts.placetypes && opts.placetypes.length > 0) {
|
|
342
|
+
where.push(`spr.placetype IN (${opts.placetypes.map(() => "?").join(", ")})`)
|
|
343
|
+
params.push(...opts.placetypes)
|
|
344
|
+
}
|
|
345
|
+
params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
|
|
346
|
+
|
|
347
|
+
return this.#admin
|
|
348
|
+
.prepare(
|
|
349
|
+
`SELECT spr.id AS id, spr.name AS name, spr.placetype AS placetype, spr.country AS country,
|
|
350
|
+
spr.parent_id AS parent_id, spr.latitude AS lat, spr.longitude AS lon
|
|
351
|
+
FROM ${PLACE_BBOX_TABLE} bbox JOIN spr ON spr.id = bbox.id
|
|
352
|
+
WHERE ${where.join(" AND ")}
|
|
353
|
+
ORDER BY (bbox.max_lat - bbox.min_lat) * (bbox.max_lon - bbox.min_lon) ASC
|
|
354
|
+
LIMIT ?`
|
|
355
|
+
)
|
|
356
|
+
.all(...params) as unknown as CandidateRow[]
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Descendants of `parentID` at one placetype tier, pre-filtered to a centroid window around the query point (a
|
|
361
|
+
* generous 4× the approximate cap — polygon-holding children may legitimately have far centroids, e.g. a sprawling
|
|
362
|
+
* consolidated city; the precise cap is applied per-candidate in the caller, and only to centroid-fallback steps).
|
|
363
|
+
*/
|
|
364
|
+
#descendants(
|
|
365
|
+
parentID: number,
|
|
366
|
+
placetype: string,
|
|
367
|
+
lat: number,
|
|
368
|
+
lon: number,
|
|
369
|
+
maxApproximateKm: number
|
|
370
|
+
): CandidateRow[] {
|
|
371
|
+
const windowDeg = (maxApproximateKm * 4) / 111
|
|
372
|
+
|
|
373
|
+
return this.#admin
|
|
374
|
+
.prepare(
|
|
375
|
+
`SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
|
|
376
|
+
s.parent_id AS parent_id, s.latitude AS lat, s.longitude AS lon
|
|
377
|
+
FROM ancestors a JOIN spr s ON s.id = a.id
|
|
378
|
+
WHERE a.ancestor_id = ? AND s.placetype = ? AND s.is_current != 0 AND s.is_deprecated = 0
|
|
379
|
+
AND s.latitude BETWEEN ? AND ? AND s.longitude BETWEEN ? AND ?`
|
|
380
|
+
)
|
|
381
|
+
.all(
|
|
382
|
+
parentID,
|
|
383
|
+
placetype,
|
|
384
|
+
lat - windowDeg,
|
|
385
|
+
lat + windowDeg,
|
|
386
|
+
lon - windowDeg,
|
|
387
|
+
lon + windowDeg
|
|
388
|
+
) as unknown as CandidateRow[]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB. */
|
|
392
|
+
#geometry(id: number): GeojsonGeometry | null {
|
|
393
|
+
if (!this.#polygons) return null
|
|
394
|
+
const cached = this.#geometryCache.get(id)
|
|
395
|
+
|
|
396
|
+
if (cached !== undefined) return cached
|
|
397
|
+
|
|
398
|
+
if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
|
|
399
|
+
this.#geometryCache.clear()
|
|
400
|
+
}
|
|
401
|
+
const row = this.#polygons.prepare(`SELECT geom FROM polygons WHERE id = ?`).get(id) as { geom: string } | undefined
|
|
402
|
+
let geometry: GeojsonGeometry | null = null
|
|
403
|
+
|
|
404
|
+
if (row) {
|
|
405
|
+
try {
|
|
406
|
+
geometry = JSON.parse(row.geom) as GeojsonGeometry
|
|
407
|
+
} catch {
|
|
408
|
+
geometry = null // malformed row — treat as no-polygon rather than failing the query
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.#geometryCache.set(id, geometry)
|
|
412
|
+
|
|
413
|
+
return geometry
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
close(): void {
|
|
417
|
+
if (this.#ownsAdmin) {
|
|
418
|
+
this.#admin.close()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (this.#ownsPolygons) {
|
|
422
|
+
this.#polygons?.close()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
[Symbol.dispose](): void {
|
|
427
|
+
this.close()
|
|
428
|
+
}
|
|
429
|
+
}
|
package/schema.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Kysely table types for the subset of the Who's On First SQLite schema we touch in Phase 4.2.
|
|
7
|
+
*
|
|
8
|
+
* The full upstream distribution at data.geocode.earth/wof/dist/sqlite/ ships ~7 tables; this file
|
|
9
|
+
* models only the ones we read. Pretending to model the others would be misleading — we haven't
|
|
10
|
+
* verified their shapes and they're not part of the resolver's contract.
|
|
11
|
+
*
|
|
12
|
+
* Authoritative schema docs:
|
|
13
|
+
*
|
|
14
|
+
* - Whosonfirst SQLite README: https://github.com/whosonfirst/go-whosonfirst-sqlite
|
|
15
|
+
* - Per-table sources under https://github.com/whosonfirst/go-whosonfirst-sqlite-features
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The FTS5 virtual table built by this package on first open (NOT shipped by upstream WOF).
|
|
20
|
+
*
|
|
21
|
+
* `content` is unindexed — it's there so we can roundtrip the original name back to the caller without a second SELECT.
|
|
22
|
+
* The actual FTS rebuild happens in `fts.ts::buildPlaceSearchFTS`.
|
|
23
|
+
*/
|
|
24
|
+
export interface PlaceSearchTable {
|
|
25
|
+
rowid: number
|
|
26
|
+
wof_id: number
|
|
27
|
+
name: string
|
|
28
|
+
alt_names: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `spr` — the Who's On First "Standard Places Response": a denormalized lightweight summary of one row per place. The
|
|
33
|
+
* resolver's main lookup table.
|
|
34
|
+
*
|
|
35
|
+
* Lifecycle flags carry TWO conventions, both meaning "currently valid": `is_current = -1` (modern Who's On First) and
|
|
36
|
+
* `is_current = 1` (legacy Mapzen-era). Only `is_current = 0` means "not current". Filters in `lookup.ts` and `fts.ts`
|
|
37
|
+
* use `is_current != 0 AND is_deprecated = 0` — see #91 for the diagnostic that uncovered the mixed-convention
|
|
38
|
+
* reality.
|
|
39
|
+
*
|
|
40
|
+
* Lat/lon live directly on this row — no GeoJSON extraction needed for centroid resolution. `min_*` / `max_*` form a
|
|
41
|
+
* bounding box if callers want one (Phase 4.3 candidate).
|
|
42
|
+
*/
|
|
43
|
+
export interface SprTable {
|
|
44
|
+
id: number
|
|
45
|
+
parent_id: number | null
|
|
46
|
+
name: string | null
|
|
47
|
+
placetype: string | null
|
|
48
|
+
country: string | null
|
|
49
|
+
latitude: number
|
|
50
|
+
longitude: number
|
|
51
|
+
min_latitude: number
|
|
52
|
+
min_longitude: number
|
|
53
|
+
max_latitude: number
|
|
54
|
+
max_longitude: number
|
|
55
|
+
is_current: number
|
|
56
|
+
is_deprecated: number
|
|
57
|
+
is_ceased: number
|
|
58
|
+
is_superseded: number
|
|
59
|
+
is_superseding: number
|
|
60
|
+
superseded_by: string | null
|
|
61
|
+
supersedes: string | null
|
|
62
|
+
lastmodified: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Alternate names per place, keyed by language tag subfields (BCP-47 components). Joins back to `spr.id` via `id` (NOT
|
|
67
|
+
* `place_id` — the real WOF schema uses the same column name as the spr primary key; this is a normal join across two
|
|
68
|
+
* tables with the same FK column name).
|
|
69
|
+
*
|
|
70
|
+
* No `kind` column in real WOF — the FTS build just concatenates ALL names per id.
|
|
71
|
+
*
|
|
72
|
+
* `official` (#936 ingest bit, our unified builds only; absent in real WOF dumps) marks a PREFERRED-form name in an
|
|
73
|
+
* official language of the place's country — the aliases eligible to join the name-exact tier under the option-3 rule.
|
|
74
|
+
* See `unified-schema.ts` for the full contract.
|
|
75
|
+
*/
|
|
76
|
+
export interface NamesTable {
|
|
77
|
+
id: number
|
|
78
|
+
placetype: string | null
|
|
79
|
+
country: string | null
|
|
80
|
+
language: string | null // ISO-639 alpha-3
|
|
81
|
+
extlang: string | null
|
|
82
|
+
script: string | null
|
|
83
|
+
region: string | null
|
|
84
|
+
variant: string | null
|
|
85
|
+
extension: string | null
|
|
86
|
+
privateuse: string | null
|
|
87
|
+
official: number | null
|
|
88
|
+
name: string
|
|
89
|
+
lastmodified: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-place GeoJSON blob. Centroid lat/lon are already exposed via `spr.{latitude,longitude}` so the resolver doesn't
|
|
94
|
+
* need to parse this; we keep the table modeled in case Phase 4.3 wants the full geometry for bbox / polygon work.
|
|
95
|
+
*/
|
|
96
|
+
export interface GeojsonTable {
|
|
97
|
+
id: number
|
|
98
|
+
body: string
|
|
99
|
+
source: string | null
|
|
100
|
+
alt_label: string | null
|
|
101
|
+
is_alt: number
|
|
102
|
+
lastmodified: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Adjacency table for ancestor relationships. One row per (place, ancestor) pair, including transitive ancestors. Used
|
|
107
|
+
* to implement `FindPlaceQuery.parentID` (descendant lookup).
|
|
108
|
+
*/
|
|
109
|
+
export interface AncestorsTable {
|
|
110
|
+
id: number
|
|
111
|
+
ancestor_id: number
|
|
112
|
+
ancestor_placetype: string | null
|
|
113
|
+
lastmodified: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `place_population` — `id → wof:population`, split off `spr` so a population-rank join is a single indexed probe.
|
|
118
|
+
* Written by the build/augment ingest + the GeoNames backfill; read by the candidate build's `neg_rank`. WOF carries
|
|
119
|
+
* population for ~15% of localities; absent = unknown, not zero.
|
|
120
|
+
*/
|
|
121
|
+
export interface PlacePopulationTable {
|
|
122
|
+
id: number
|
|
123
|
+
population: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `place_abbr` — `id → abbreviation` (e.g. `IL → Illinois`), derived from `names` rows whose `language = 'abbr'`. Lets
|
|
128
|
+
* the resolver accept a 2-letter region abbreviation as an exact match.
|
|
129
|
+
*/
|
|
130
|
+
export interface PlaceAbbrTable {
|
|
131
|
+
id: number
|
|
132
|
+
abbr: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `concordances` — external-id cross-references per place (`id → (other_source, other_id)`), e.g. a GeoNames or
|
|
137
|
+
* Overture GERS id. Metadata only; not part of the resolve path.
|
|
138
|
+
*/
|
|
139
|
+
export interface ConcordancesTable {
|
|
140
|
+
id: number
|
|
141
|
+
other_id: string
|
|
142
|
+
other_source: string
|
|
143
|
+
lastmodified: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `coincident_roles` (#402) — the dual-role relation: a place that is BOTH an admin region AND a locality (Berlin the
|
|
148
|
+
* city-state). One row per (admin, locality) pair the resolver can complete a hierarchy with. Surfaced by
|
|
149
|
+
* {@link MailwomanLookupLike.coincidentRolesFor}.
|
|
150
|
+
*/
|
|
151
|
+
export interface CoincidentRolesTable {
|
|
152
|
+
admin_id: number
|
|
153
|
+
locality_id: number
|
|
154
|
+
relationship_type: string
|
|
155
|
+
admin_placetype: string
|
|
156
|
+
distance_km: number
|
|
157
|
+
locality_population: number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The full schema we hand to `Kysely<WOFDatabase>` / `new DatabaseClient<WOFDatabase>(...)`. Tables not listed here
|
|
162
|
+
* will fail type-checked queries — by design. The reader ({@link WOFSqlitePlaceLookup}) already consumes this; the
|
|
163
|
+
* build/augment WRITERS adopt it so a column rename is a compile error on both sides (the drift that bit the corpus
|
|
164
|
+
* TIGER adapter).
|
|
165
|
+
*/
|
|
166
|
+
export interface WOFDatabase {
|
|
167
|
+
place_search: PlaceSearchTable
|
|
168
|
+
spr: SprTable
|
|
169
|
+
names: NamesTable
|
|
170
|
+
geojson: GeojsonTable
|
|
171
|
+
ancestors: AncestorsTable
|
|
172
|
+
place_population: PlacePopulationTable
|
|
173
|
+
place_abbr: PlaceAbbrTable
|
|
174
|
+
concordances: ConcordancesTable
|
|
175
|
+
coincident_roles: CoincidentRolesTable
|
|
176
|
+
}
|