@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/out/poi-lookup.d.ts +14 -2
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +55 -21
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +9 -0
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +16 -0
- package/out/poi-schema.js.map +1 -1
- package/out/reverse.d.ts +8 -1
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +10 -1
- package/out/reverse.js.map +1 -1
- package/package.json +168 -82
- package/poi-lookup.ts +375 -0
- package/poi-schema.ts +164 -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 +439 -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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for the POSTAL-CITY ALIAS table (`postal-city-alias-<cc>.db`, built by
|
|
7
|
+
* `scripts/build-postal-city-alias.ts`) — the single source of truth for the columns shared by
|
|
8
|
+
* the BUILDER and the READER ({@link WOFPostalCityAliasLookup}). Like {@link CandidateTable}, the
|
|
9
|
+
* contract is a Kysely `Database` interface plus the table DDL as a string, so a column rename in
|
|
10
|
+
* the builder is a compile error in the reader.
|
|
11
|
+
*
|
|
12
|
+
* Provenance discipline (provenance-first): this is a SIBLING table to the PIP-derived
|
|
13
|
+
* `postcode_locality` data, never mixed into it — one table, one provenance class. Each row is an
|
|
14
|
+
* OBSERVED `(postcode, postal_city, geo_locality)` aggregate from Overture's `postal_city` field
|
|
15
|
+
* with a usage count `n`; `divergent = 1` exactly when `postal_city != geo_locality` (the alias
|
|
16
|
+
* signal — the only rows the resolver consumes).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Kysely } from "kysely"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One observed postal-city aggregate. The natural key is `(postcode, postal_city, geo_locality)`; the builder enforces
|
|
23
|
+
* a `MIN_COUNT` floor on `n`, so every row is a non-trivial usage.
|
|
24
|
+
*/
|
|
25
|
+
export interface PostalCityAliasTable {
|
|
26
|
+
/** The postcode the aggregate is scoped to (the resolver probes by this). */
|
|
27
|
+
postcode: string
|
|
28
|
+
/** What the postal system calls the place (the surface a user is likely to type). */
|
|
29
|
+
postal_city: string
|
|
30
|
+
/** The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name). */
|
|
31
|
+
geo_locality: string
|
|
32
|
+
/** Observed row count — the evidence weight behind this alias. */
|
|
33
|
+
n: number
|
|
34
|
+
/** 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree. */
|
|
35
|
+
divergent: number
|
|
36
|
+
/** Provenance: the dataset this aggregate came from (e.g. `overture:US`). */
|
|
37
|
+
source: string
|
|
38
|
+
/** The pinned data release the aggregate was computed from. */
|
|
39
|
+
release: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`. */
|
|
43
|
+
export interface PostalCityAliasDatabase {
|
|
44
|
+
postal_city_alias: PostalCityAliasTable
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `postal_city_alias` column order — the builder's INSERT derives its column list from this. */
|
|
48
|
+
export const POSTAL_CITY_ALIAS_COLUMNS = [
|
|
49
|
+
"postcode",
|
|
50
|
+
"postal_city",
|
|
51
|
+
"geo_locality",
|
|
52
|
+
"n",
|
|
53
|
+
"divergent",
|
|
54
|
+
"source",
|
|
55
|
+
"release",
|
|
56
|
+
] as const
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create the `postal_city_alias` table + its two probe indexes. Kept here (not only in the builder) so tests can stand
|
|
60
|
+
* up a fixture DB with the exact production shape. Pass a {@link DatabaseClient} (or any `Kysely`) over the alias DB.
|
|
61
|
+
*/
|
|
62
|
+
export async function createPostalCityAliasTable(db: Kysely<PostalCityAliasDatabase>): Promise<void> {
|
|
63
|
+
await db.schema
|
|
64
|
+
.createTable("postal_city_alias")
|
|
65
|
+
.addColumn("postcode", "text", (c) => c.notNull())
|
|
66
|
+
.addColumn("postal_city", "text", (c) => c.notNull())
|
|
67
|
+
.addColumn("geo_locality", "text", (c) => c.notNull())
|
|
68
|
+
.addColumn("n", "integer", (c) => c.notNull())
|
|
69
|
+
.addColumn("divergent", "integer", (c) => c.notNull())
|
|
70
|
+
.addColumn("source", "text", (c) => c.notNull())
|
|
71
|
+
.addColumn("release", "text", (c) => c.notNull())
|
|
72
|
+
.execute()
|
|
73
|
+
await db.schema.createIndex("idx_pca_postcode").on("postal_city_alias").column("postcode").execute()
|
|
74
|
+
await db.schema.createIndex("idx_pca_pair").on("postal_city_alias").columns(["postal_city", "geo_locality"]).execute()
|
|
75
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for the POSTAL-CITY CANDIDATE side-index (#741 / #475) — a small `(name_key,
|
|
7
|
+
* postcode) → geo-locality` table that lives alongside the byte-range `candidate` table so the
|
|
8
|
+
* candidate-backend resolver (the demo/CLI default) can do what the FTS coordinate-first scorer
|
|
9
|
+
* does: resolve a user-typed POSTAL city ("Antioch", 37013) to the geographic locality the
|
|
10
|
+
* postcode sits in ("Nashville").
|
|
11
|
+
*
|
|
12
|
+
* Why a SIDE-INDEX, not cloned `candidate` rows: the `candidate` B-tree is keyed `(name_key,
|
|
13
|
+
* country_id, region_id, placetype_id, …)` and ranked population-first — it has no postcode
|
|
14
|
+
* dimension. A cloned alias row was tested (#741) and falsified: a sentinel rank is
|
|
15
|
+
* bare-name-safe but then loses to any in-region homonym, and there is no single rank that is
|
|
16
|
+
* both. The fix is an EXACT `(name_key, postcode)` probe that bypasses population/region ranking
|
|
17
|
+
* entirely — consulted only when the query carries a postcode, so the common no-postcode path is
|
|
18
|
+
* untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { sql, type Kysely } from "kysely"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One postal-city → geo-locality edge, keyed exactly by `(name_key, postcode)`. The probe returns the geographic
|
|
25
|
+
* locality directly; the denormalized name/coord avoid a join back to `candidate`.
|
|
26
|
+
*/
|
|
27
|
+
export interface PostalCityCandidateTable {
|
|
28
|
+
/** {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key. */
|
|
29
|
+
name_key: string
|
|
30
|
+
/** The postcode the alias is scoped to (the second half of the exact key). */
|
|
31
|
+
postcode: string
|
|
32
|
+
/** WOF id of the geographic locality the postcode sits in (the resolve target). */
|
|
33
|
+
spr_id: number
|
|
34
|
+
/** The geographic locality's display name. */
|
|
35
|
+
name: string
|
|
36
|
+
latitude: number
|
|
37
|
+
longitude: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The postal-city-candidate database schema for `new DatabaseClient<PostalCityCandidateDatabase>(...)`.
|
|
42
|
+
*/
|
|
43
|
+
export interface PostalCityCandidateDatabase {
|
|
44
|
+
postal_city_candidate: PostalCityCandidateTable
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The table name the lookup probes (existence-gated, so an old candidate.db without it is byte-stable).
|
|
49
|
+
*/
|
|
50
|
+
export const POSTAL_CITY_CANDIDATE_TABLE = "postal_city_candidate"
|
|
51
|
+
|
|
52
|
+
/** Column order for the builder's positional INSERT. */
|
|
53
|
+
export const POSTAL_CITY_CANDIDATE_COLUMNS = [
|
|
54
|
+
"name_key",
|
|
55
|
+
"postcode",
|
|
56
|
+
"spr_id",
|
|
57
|
+
"name",
|
|
58
|
+
"latitude",
|
|
59
|
+
"longitude",
|
|
60
|
+
] as const
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create the side-index — a clustered `WITHOUT ROWID` B-tree on `(name_key, postcode)` so the resolve is a single exact
|
|
64
|
+
* probe. Idempotent (`IF NOT EXISTS`); pass a {@link DatabaseClient} (or any `Kysely`) over the candidate DB. The
|
|
65
|
+
* Kysely schema-builder is the house idiom for table creation — see `AGENTS.md` (inline-SQL → Kysely).
|
|
66
|
+
*/
|
|
67
|
+
export async function createPostalCityCandidateTable(db: Kysely<PostalCityCandidateDatabase>): Promise<void> {
|
|
68
|
+
await db.schema
|
|
69
|
+
.createTable(POSTAL_CITY_CANDIDATE_TABLE)
|
|
70
|
+
.ifNotExists()
|
|
71
|
+
.addColumn("name_key", "text", (c) => c.notNull())
|
|
72
|
+
.addColumn("postcode", "text", (c) => c.notNull())
|
|
73
|
+
.addColumn("spr_id", "integer", (c) => c.notNull())
|
|
74
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
75
|
+
.addColumn("latitude", "real", (c) => c.notNull())
|
|
76
|
+
.addColumn("longitude", "real", (c) => c.notNull())
|
|
77
|
+
.addPrimaryKeyConstraint("postal_city_candidate_pk", ["name_key", "postcode"])
|
|
78
|
+
// `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
|
|
79
|
+
.modifyEnd(sql`without rowid`)
|
|
80
|
+
.execute()
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* SQLite-backed postcode lookup for the postcode anchor (#240). A thin exact-match resolver over
|
|
7
|
+
* one or more `postalcode-*.db` shards (the `spr` schema built by `build-unified-wof --placetypes
|
|
8
|
+
* postalcode`, then centroid-backfilled by `scripts/backfill-postcode-centroids.ts`).
|
|
9
|
+
*
|
|
10
|
+
* This is the production implementation of the `PostcodeResolver` interface consumed by
|
|
11
|
+
* `@mailwoman/neural`'s `extractPostcodeAnchors`. It is deliberately dumb: an indexed exact-match
|
|
12
|
+
* on the postcode string across every shard, unioned. No FTS, no ranking, no proximity — the
|
|
13
|
+
* anchor only needs "does this string exist as a postcode, in which countries, near where". A
|
|
14
|
+
* future WASM build swaps this for an FST-backed resolver behind the same `lookup()` seam.
|
|
15
|
+
*
|
|
16
|
+
* Why multiple shards instead of the multi-shard `WOFSqlitePlaceLookup`: that resolver routes a
|
|
17
|
+
* query to ONE shard by placetype, but every postcode shard shares `placetype='postalcode'`, so a
|
|
18
|
+
* single query could only ever hit one country's shard. The anchor needs the union across
|
|
19
|
+
* countries to build its country posterior, so it queries each shard directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { DatabaseSync } from "node:sqlite"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A gazetteer hit. `lat`/`lon` of 0 means the postcode is known but has no centroid (no admin parent).
|
|
26
|
+
*/
|
|
27
|
+
export interface PostcodePlace {
|
|
28
|
+
country: string
|
|
29
|
+
lat: number
|
|
30
|
+
lon: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LOOKUP_SQL =
|
|
34
|
+
"SELECT country, latitude AS lat, longitude AS lon FROM spr WHERE name = ? AND placetype = 'postalcode' AND is_current != 0"
|
|
35
|
+
|
|
36
|
+
export class WOFPostcodeLookup {
|
|
37
|
+
readonly #dbs: DatabaseSync[]
|
|
38
|
+
readonly #stmts: ReturnType<DatabaseSync["prepare"]>[]
|
|
39
|
+
|
|
40
|
+
/** Open each shard read-only and prepare its exact-match statement. */
|
|
41
|
+
constructor(dbPaths: readonly string[]) {
|
|
42
|
+
this.#dbs = dbPaths.map((p) => new DatabaseSync(p, { readOnly: true }))
|
|
43
|
+
this.#stmts = this.#dbs.map((db) => db.prepare(LOOKUP_SQL))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Exact-match the postcode across every shard and union the rows. */
|
|
47
|
+
lookup(postcode: string): PostcodePlace[] {
|
|
48
|
+
const out: PostcodePlace[] = []
|
|
49
|
+
|
|
50
|
+
for (const stmt of this.#stmts) {
|
|
51
|
+
for (const row of stmt.all(postcode)) {
|
|
52
|
+
out.push({ country: String(row.country), lat: Number(row.lat), lon: Number(row.lon) })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
close(): void {
|
|
60
|
+
for (const db of this.#dbs) {
|
|
61
|
+
db.close()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/reverse.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
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). Thin wrapper over {@link reverseGeocodeSync}.
|
|
204
|
+
*/
|
|
205
|
+
async reverseGeocode(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): Promise<ReverseGeocodeResult> {
|
|
206
|
+
return this.reverseGeocodeSync(lat, lon, opts)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Synchronous core of {@link reverseGeocode} — every step underneath is already sync `node:sqlite`, so this is the
|
|
211
|
+
* REAL implementation; the async method above exists only for call-site symmetry with `PlaceLookup.findPlace`.
|
|
212
|
+
* Exposed directly for callers that can't await mid-call (e.g. `mailwoman/poi-executor.ts`'s `createPOIExecutor`,
|
|
213
|
+
* whose `POIIntentOutcome` return type is synchronous by contract — see `poi-intent.ts`'s `deps.execute`).
|
|
214
|
+
*/
|
|
215
|
+
reverseGeocodeSync(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): ReverseGeocodeResult {
|
|
216
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
|
|
217
|
+
throw new RangeError(`WOFReverseGeocoder.reverseGeocode: (${lat}, ${lon}) is not a WGS-84 coordinate`)
|
|
218
|
+
}
|
|
219
|
+
const maxApproximateKm = opts.maxApproximateKm ?? DEFAULT_MAX_APPROXIMATE_KM
|
|
220
|
+
const candidates = this.#bboxCandidates(lat, lon, opts)
|
|
221
|
+
|
|
222
|
+
// PIP walk, smallest-bbox-first: the first polygon that contains the point is the deepest
|
|
223
|
+
// polygon-confirmable place. Polygon-rejected candidates are bbox false positives — dropped.
|
|
224
|
+
let winner: CandidateRow | null = null
|
|
225
|
+
let winnerConfirmed = false
|
|
226
|
+
const pointOnly: CandidateRow[] = []
|
|
227
|
+
|
|
228
|
+
for (const c of candidates) {
|
|
229
|
+
const contains = geometryContains(this.#geometry(c.id), lon, lat)
|
|
230
|
+
|
|
231
|
+
if (contains === true) {
|
|
232
|
+
winner = c
|
|
233
|
+
winnerConfirmed = true
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (contains === null) {
|
|
238
|
+
pointOnly.push(c)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!winner) {
|
|
243
|
+
// No polygon confirmed anywhere — nearest centroid among the polygon-less bbox candidates.
|
|
244
|
+
let bestKm = Infinity
|
|
245
|
+
|
|
246
|
+
for (const c of pointOnly) {
|
|
247
|
+
const km = haversineKm(lat, lon, c.lat, c.lon)
|
|
248
|
+
|
|
249
|
+
if (km < bestKm) {
|
|
250
|
+
bestKm = km
|
|
251
|
+
winner = c
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!winner) return { hierarchy: [], containment: "approximate" }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Approximate descent into finer tiers than the winner.
|
|
259
|
+
let current = winner
|
|
260
|
+
let currentConfirmed = winnerConfirmed
|
|
261
|
+
let currentDistanceKm = currentConfirmed ? undefined : haversineKm(lat, lon, current.lat, current.lon)
|
|
262
|
+
|
|
263
|
+
for (const tier of DESCENT_TIERS) {
|
|
264
|
+
if (placetypeDepth(tier) <= placetypeDepth(current.placetype)) continue
|
|
265
|
+
|
|
266
|
+
if (opts.placetypes && !opts.placetypes.includes(tier)) continue
|
|
267
|
+
const kids = this.#descendants(current.id, tier, lat, lon, maxApproximateKm)
|
|
268
|
+
let next: CandidateRow | null = null
|
|
269
|
+
let nextConfirmed = false
|
|
270
|
+
let nextKm: number | undefined
|
|
271
|
+
let bestKm = Infinity
|
|
272
|
+
|
|
273
|
+
for (const k of kids) {
|
|
274
|
+
const contains = geometryContains(this.#geometry(k.id), lon, lat)
|
|
275
|
+
|
|
276
|
+
if (contains === true) {
|
|
277
|
+
next = k
|
|
278
|
+
nextConfirmed = true
|
|
279
|
+
nextKm = undefined
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (contains === false) continue // known not-here — polygon rejected
|
|
284
|
+
const km = haversineKm(lat, lon, k.lat, k.lon)
|
|
285
|
+
|
|
286
|
+
if (km <= maxApproximateKm && km < bestKm) {
|
|
287
|
+
bestKm = km
|
|
288
|
+
next = k
|
|
289
|
+
nextConfirmed = false
|
|
290
|
+
nextKm = km
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (next) {
|
|
295
|
+
current = next
|
|
296
|
+
currentConfirmed = nextConfirmed
|
|
297
|
+
currentDistanceKm = nextKm
|
|
298
|
+
}
|
|
299
|
+
// An empty tier is NOT terminal — counties without localadmins jump straight to locality.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Hierarchy assembly via the shared ancestor walk. If the descent crossed an ancestry gap
|
|
303
|
+
// (the deepest place's recorded lineage misses the PIP root), merge the root's own chain so
|
|
304
|
+
// region/country are always present when a polygon confirmed them.
|
|
305
|
+
const byID = new Map<number, PlaceCandidate>()
|
|
306
|
+
byID.set(current.id, toPlaceCandidate(current, currentDistanceKm))
|
|
307
|
+
|
|
308
|
+
for (const a of ancestorLineage(this.#admin, current.id)) {
|
|
309
|
+
if (!byID.has(a.id)) {
|
|
310
|
+
byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!byID.has(winner.id)) {
|
|
315
|
+
byID.set(winner.id, toPlaceCandidate(winner))
|
|
316
|
+
|
|
317
|
+
for (const a of ancestorLineage(this.#admin, winner.id)) {
|
|
318
|
+
if (!byID.has(a.id)) {
|
|
319
|
+
byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const hierarchy = [...byID.values()]
|
|
324
|
+
|
|
325
|
+
if (opts.placetypes) {
|
|
326
|
+
const allowed = new Set<string>(opts.placetypes)
|
|
327
|
+
|
|
328
|
+
for (let i = hierarchy.length - 1; i >= 0; i--) {
|
|
329
|
+
if (!allowed.has(hierarchy[i]!.placetype)) {
|
|
330
|
+
hierarchy.splice(i, 1)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
hierarchy.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
|
|
335
|
+
|
|
336
|
+
return { hierarchy, containment: currentConfirmed ? "polygon" : "approximate" }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree. */
|
|
340
|
+
#bboxCandidates(lat: number, lon: number, opts: ReverseGeocodeOpts): CandidateRow[] {
|
|
341
|
+
const where: string[] = [
|
|
342
|
+
"bbox.min_lat <= ?",
|
|
343
|
+
"bbox.max_lat >= ?",
|
|
344
|
+
"bbox.min_lon <= ?",
|
|
345
|
+
"bbox.max_lon >= ?",
|
|
346
|
+
"spr.is_current != 0",
|
|
347
|
+
"spr.is_deprecated = 0",
|
|
348
|
+
]
|
|
349
|
+
const params: Array<number | string> = [lat, lat, lon, lon]
|
|
350
|
+
|
|
351
|
+
if (opts.placetypes && opts.placetypes.length > 0) {
|
|
352
|
+
where.push(`spr.placetype IN (${opts.placetypes.map(() => "?").join(", ")})`)
|
|
353
|
+
params.push(...opts.placetypes)
|
|
354
|
+
}
|
|
355
|
+
params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
|
|
356
|
+
|
|
357
|
+
return this.#admin
|
|
358
|
+
.prepare(
|
|
359
|
+
`SELECT spr.id AS id, spr.name AS name, spr.placetype AS placetype, spr.country AS country,
|
|
360
|
+
spr.parent_id AS parent_id, spr.latitude AS lat, spr.longitude AS lon
|
|
361
|
+
FROM ${PLACE_BBOX_TABLE} bbox JOIN spr ON spr.id = bbox.id
|
|
362
|
+
WHERE ${where.join(" AND ")}
|
|
363
|
+
ORDER BY (bbox.max_lat - bbox.min_lat) * (bbox.max_lon - bbox.min_lon) ASC
|
|
364
|
+
LIMIT ?`
|
|
365
|
+
)
|
|
366
|
+
.all(...params) as unknown as CandidateRow[]
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Descendants of `parentID` at one placetype tier, pre-filtered to a centroid window around the query point (a
|
|
371
|
+
* generous 4× the approximate cap — polygon-holding children may legitimately have far centroids, e.g. a sprawling
|
|
372
|
+
* consolidated city; the precise cap is applied per-candidate in the caller, and only to centroid-fallback steps).
|
|
373
|
+
*/
|
|
374
|
+
#descendants(
|
|
375
|
+
parentID: number,
|
|
376
|
+
placetype: string,
|
|
377
|
+
lat: number,
|
|
378
|
+
lon: number,
|
|
379
|
+
maxApproximateKm: number
|
|
380
|
+
): CandidateRow[] {
|
|
381
|
+
const windowDeg = (maxApproximateKm * 4) / 111
|
|
382
|
+
|
|
383
|
+
return this.#admin
|
|
384
|
+
.prepare(
|
|
385
|
+
`SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
|
|
386
|
+
s.parent_id AS parent_id, s.latitude AS lat, s.longitude AS lon
|
|
387
|
+
FROM ancestors a JOIN spr s ON s.id = a.id
|
|
388
|
+
WHERE a.ancestor_id = ? AND s.placetype = ? AND s.is_current != 0 AND s.is_deprecated = 0
|
|
389
|
+
AND s.latitude BETWEEN ? AND ? AND s.longitude BETWEEN ? AND ?`
|
|
390
|
+
)
|
|
391
|
+
.all(
|
|
392
|
+
parentID,
|
|
393
|
+
placetype,
|
|
394
|
+
lat - windowDeg,
|
|
395
|
+
lat + windowDeg,
|
|
396
|
+
lon - windowDeg,
|
|
397
|
+
lon + windowDeg
|
|
398
|
+
) as unknown as CandidateRow[]
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB. */
|
|
402
|
+
#geometry(id: number): GeojsonGeometry | null {
|
|
403
|
+
if (!this.#polygons) return null
|
|
404
|
+
const cached = this.#geometryCache.get(id)
|
|
405
|
+
|
|
406
|
+
if (cached !== undefined) return cached
|
|
407
|
+
|
|
408
|
+
if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
|
|
409
|
+
this.#geometryCache.clear()
|
|
410
|
+
}
|
|
411
|
+
const row = this.#polygons.prepare(`SELECT geom FROM polygons WHERE id = ?`).get(id) as { geom: string } | undefined
|
|
412
|
+
let geometry: GeojsonGeometry | null = null
|
|
413
|
+
|
|
414
|
+
if (row) {
|
|
415
|
+
try {
|
|
416
|
+
geometry = JSON.parse(row.geom) as GeojsonGeometry
|
|
417
|
+
} catch {
|
|
418
|
+
geometry = null // malformed row — treat as no-polygon rather than failing the query
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
this.#geometryCache.set(id, geometry)
|
|
422
|
+
|
|
423
|
+
return geometry
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
close(): void {
|
|
427
|
+
if (this.#ownsAdmin) {
|
|
428
|
+
this.#admin.close()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (this.#ownsPolygons) {
|
|
432
|
+
this.#polygons?.close()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
[Symbol.dispose](): void {
|
|
437
|
+
this.close()
|
|
438
|
+
}
|
|
439
|
+
}
|