@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/resolver-wof-sqlite",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "Who's On First SQLite resolver — FTS5-backed PlaceLookup that resolves text queries to WOF place IDs + coordinates. Optional dependency for callers who want geocoding.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -13,145 +13,231 @@
|
|
|
13
13
|
"out/**/*.js.map",
|
|
14
14
|
"out/**/*.d.ts",
|
|
15
15
|
"out/**/*.d.ts.map",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"*.ts",
|
|
18
|
+
"*.tsx",
|
|
19
|
+
"**/*.ts",
|
|
20
|
+
"**/*.tsx",
|
|
21
|
+
"!*.test.ts",
|
|
22
|
+
"!*.test.tsx",
|
|
23
|
+
"!**/*.test.ts",
|
|
24
|
+
"!**/*.test.tsx"
|
|
17
25
|
],
|
|
18
26
|
"type": "module",
|
|
19
27
|
"exports": {
|
|
20
28
|
"./package.json": "./package.json",
|
|
21
29
|
".": {
|
|
22
|
-
"
|
|
23
|
-
"default": "./out/index.js"
|
|
24
|
-
"types": "./out/index.d.ts"
|
|
30
|
+
"types": "./out/index.d.ts",
|
|
31
|
+
"default": "./out/index.js"
|
|
25
32
|
},
|
|
26
33
|
"./fts": {
|
|
27
|
-
"
|
|
28
|
-
"default": "./out/fts.js"
|
|
29
|
-
"types": "./out/fts.d.ts"
|
|
34
|
+
"types": "./out/fts.d.ts",
|
|
35
|
+
"default": "./out/fts.js"
|
|
30
36
|
},
|
|
31
37
|
"./build-slim": {
|
|
32
|
-
"
|
|
33
|
-
"default": "./out/build-slim.js"
|
|
34
|
-
"types": "./out/build-slim.d.ts"
|
|
38
|
+
"types": "./out/build-slim.d.ts",
|
|
39
|
+
"default": "./out/build-slim.js"
|
|
35
40
|
},
|
|
36
41
|
"./fst-builder": {
|
|
37
|
-
"
|
|
38
|
-
"default": "./out/fst-builder.js"
|
|
39
|
-
"types": "./out/fst-builder.d.ts"
|
|
42
|
+
"types": "./out/fst-builder.d.ts",
|
|
43
|
+
"default": "./out/fst-builder.js"
|
|
40
44
|
},
|
|
41
45
|
"./street-morphology-fst-builder": {
|
|
42
|
-
"
|
|
43
|
-
"default": "./out/street-morphology-fst-builder.js"
|
|
44
|
-
"types": "./out/street-morphology-fst-builder.d.ts"
|
|
46
|
+
"types": "./out/street-morphology-fst-builder.d.ts",
|
|
47
|
+
"default": "./out/street-morphology-fst-builder.js"
|
|
45
48
|
},
|
|
46
49
|
"./fst-matcher": {
|
|
47
|
-
"
|
|
48
|
-
"default": "./out/fst-matcher.js"
|
|
49
|
-
"types": "./out/fst-matcher.d.ts"
|
|
50
|
+
"types": "./out/fst-matcher.d.ts",
|
|
51
|
+
"default": "./out/fst-matcher.js"
|
|
50
52
|
},
|
|
51
53
|
"./fst-serialize": {
|
|
52
|
-
"
|
|
53
|
-
"default": "./out/fst-serialize.js"
|
|
54
|
-
"types": "./out/fst-serialize.d.ts"
|
|
54
|
+
"types": "./out/fst-serialize.d.ts",
|
|
55
|
+
"default": "./out/fst-serialize.js"
|
|
55
56
|
},
|
|
56
57
|
"./fst-autocomplete": {
|
|
57
|
-
"
|
|
58
|
-
"default": "./out/fst-autocomplete.js"
|
|
59
|
-
"types": "./out/fst-autocomplete.d.ts"
|
|
58
|
+
"types": "./out/fst-autocomplete.d.ts",
|
|
59
|
+
"default": "./out/fst-autocomplete.js"
|
|
60
60
|
},
|
|
61
61
|
"./fst-types": {
|
|
62
|
-
"
|
|
63
|
-
"default": "./out/fst-types.js"
|
|
64
|
-
"types": "./out/fst-types.d.ts"
|
|
62
|
+
"types": "./out/fst-types.d.ts",
|
|
63
|
+
"default": "./out/fst-types.js"
|
|
65
64
|
},
|
|
66
65
|
"./fst-deserialize-web": {
|
|
67
|
-
"
|
|
68
|
-
"default": "./out/fst-deserialize-web.js"
|
|
69
|
-
"types": "./out/fst-deserialize-web.d.ts"
|
|
66
|
+
"types": "./out/fst-deserialize-web.d.ts",
|
|
67
|
+
"default": "./out/fst-deserialize-web.js"
|
|
70
68
|
},
|
|
71
69
|
"./unified-schema": {
|
|
72
|
-
"
|
|
73
|
-
"default": "./out/unified-schema.js"
|
|
74
|
-
"types": "./out/unified-schema.d.ts"
|
|
70
|
+
"types": "./out/unified-schema.d.ts",
|
|
71
|
+
"default": "./out/unified-schema.js"
|
|
75
72
|
},
|
|
76
73
|
"./ancestry-backfill": {
|
|
77
|
-
"
|
|
78
|
-
"default": "./out/ancestry-backfill.js"
|
|
79
|
-
"types": "./out/ancestry-backfill.d.ts"
|
|
74
|
+
"types": "./out/ancestry-backfill.d.ts",
|
|
75
|
+
"default": "./out/ancestry-backfill.js"
|
|
80
76
|
},
|
|
81
77
|
"./coincident-roles": {
|
|
82
|
-
"
|
|
83
|
-
"default": "./out/coincident-roles.js"
|
|
84
|
-
"types": "./out/coincident-roles.d.ts"
|
|
78
|
+
"types": "./out/coincident-roles.d.ts",
|
|
79
|
+
"default": "./out/coincident-roles.js"
|
|
85
80
|
},
|
|
86
81
|
"./geonames-aliases": {
|
|
87
|
-
"
|
|
88
|
-
"default": "./out/geonames-aliases.js"
|
|
89
|
-
"types": "./out/geonames-aliases.d.ts"
|
|
82
|
+
"types": "./out/geonames-aliases.d.ts",
|
|
83
|
+
"default": "./out/geonames-aliases.js"
|
|
90
84
|
},
|
|
91
85
|
"./geonames-postal": {
|
|
92
|
-
"
|
|
93
|
-
"default": "./out/geonames-postal.js"
|
|
94
|
-
"types": "./out/geonames-postal.d.ts"
|
|
86
|
+
"types": "./out/geonames-postal.d.ts",
|
|
87
|
+
"default": "./out/geonames-postal.js"
|
|
95
88
|
},
|
|
96
89
|
"./geo": {
|
|
97
|
-
"
|
|
98
|
-
"default": "./out/geo.js"
|
|
99
|
-
"types": "./out/geo.d.ts"
|
|
90
|
+
"types": "./out/geo.d.ts",
|
|
91
|
+
"default": "./out/geo.js"
|
|
100
92
|
},
|
|
101
93
|
"./street-normalize": {
|
|
102
|
-
"
|
|
103
|
-
"default": "./out/street-normalize.js"
|
|
104
|
-
"types": "./out/street-normalize.d.ts"
|
|
94
|
+
"types": "./out/street-normalize.d.ts",
|
|
95
|
+
"default": "./out/street-normalize.js"
|
|
105
96
|
},
|
|
106
97
|
"./build-candidate": {
|
|
107
|
-
"
|
|
108
|
-
"default": "./out/build-candidate.js"
|
|
109
|
-
"types": "./out/build-candidate.d.ts"
|
|
98
|
+
"types": "./out/build-candidate.d.ts",
|
|
99
|
+
"default": "./out/build-candidate.js"
|
|
110
100
|
},
|
|
111
101
|
"./candidate-schema": {
|
|
112
|
-
"
|
|
113
|
-
"default": "./out/candidate-schema.js"
|
|
114
|
-
"types": "./out/candidate-schema.d.ts"
|
|
102
|
+
"types": "./out/candidate-schema.d.ts",
|
|
103
|
+
"default": "./out/candidate-schema.js"
|
|
115
104
|
},
|
|
116
105
|
"./postal-city-alias-schema": {
|
|
117
|
-
"
|
|
118
|
-
"default": "./out/postal-city-alias-schema.js"
|
|
119
|
-
"types": "./out/postal-city-alias-schema.d.ts"
|
|
106
|
+
"types": "./out/postal-city-alias-schema.d.ts",
|
|
107
|
+
"default": "./out/postal-city-alias-schema.js"
|
|
120
108
|
},
|
|
121
109
|
"./address-point-schema": {
|
|
122
|
-
"
|
|
123
|
-
"default": "./out/address-point-schema.js"
|
|
124
|
-
"types": "./out/address-point-schema.d.ts"
|
|
110
|
+
"types": "./out/address-point-schema.d.ts",
|
|
111
|
+
"default": "./out/address-point-schema.js"
|
|
125
112
|
},
|
|
126
113
|
"./street-centroid-schema": {
|
|
127
|
-
"
|
|
128
|
-
"default": "./out/street-centroid-schema.js"
|
|
129
|
-
"types": "./out/street-centroid-schema.d.ts"
|
|
114
|
+
"types": "./out/street-centroid-schema.d.ts",
|
|
115
|
+
"default": "./out/street-centroid-schema.js"
|
|
130
116
|
},
|
|
131
117
|
"./street-segment-schema": {
|
|
132
|
-
"
|
|
133
|
-
"default": "./out/street-segment-schema.js"
|
|
134
|
-
"types": "./out/street-segment-schema.d.ts"
|
|
118
|
+
"types": "./out/street-segment-schema.d.ts",
|
|
119
|
+
"default": "./out/street-segment-schema.js"
|
|
135
120
|
},
|
|
136
121
|
"./poi-schema": {
|
|
137
|
-
"
|
|
138
|
-
"default": "./out/poi-schema.js"
|
|
139
|
-
"types": "./out/poi-schema.d.ts"
|
|
122
|
+
"types": "./out/poi-schema.d.ts",
|
|
123
|
+
"default": "./out/poi-schema.js"
|
|
140
124
|
},
|
|
141
125
|
"./poi-lookup": {
|
|
142
|
-
"
|
|
143
|
-
"default": "./out/poi-lookup.js"
|
|
144
|
-
"types": "./out/poi-lookup.d.ts"
|
|
126
|
+
"types": "./out/poi-lookup.d.ts",
|
|
127
|
+
"default": "./out/poi-lookup.js"
|
|
145
128
|
}
|
|
146
129
|
},
|
|
147
130
|
"publishConfig": {
|
|
148
|
-
"access": "public"
|
|
131
|
+
"access": "public",
|
|
132
|
+
"exports": {
|
|
133
|
+
"./package.json": "./package.json",
|
|
134
|
+
".": {
|
|
135
|
+
"types": "./out/index.d.ts",
|
|
136
|
+
"default": "./out/index.js"
|
|
137
|
+
},
|
|
138
|
+
"./fts": {
|
|
139
|
+
"types": "./out/fts.d.ts",
|
|
140
|
+
"default": "./out/fts.js"
|
|
141
|
+
},
|
|
142
|
+
"./build-slim": {
|
|
143
|
+
"types": "./out/build-slim.d.ts",
|
|
144
|
+
"default": "./out/build-slim.js"
|
|
145
|
+
},
|
|
146
|
+
"./fst-builder": {
|
|
147
|
+
"types": "./out/fst-builder.d.ts",
|
|
148
|
+
"default": "./out/fst-builder.js"
|
|
149
|
+
},
|
|
150
|
+
"./street-morphology-fst-builder": {
|
|
151
|
+
"types": "./out/street-morphology-fst-builder.d.ts",
|
|
152
|
+
"default": "./out/street-morphology-fst-builder.js"
|
|
153
|
+
},
|
|
154
|
+
"./fst-matcher": {
|
|
155
|
+
"types": "./out/fst-matcher.d.ts",
|
|
156
|
+
"default": "./out/fst-matcher.js"
|
|
157
|
+
},
|
|
158
|
+
"./fst-serialize": {
|
|
159
|
+
"types": "./out/fst-serialize.d.ts",
|
|
160
|
+
"default": "./out/fst-serialize.js"
|
|
161
|
+
},
|
|
162
|
+
"./fst-autocomplete": {
|
|
163
|
+
"types": "./out/fst-autocomplete.d.ts",
|
|
164
|
+
"default": "./out/fst-autocomplete.js"
|
|
165
|
+
},
|
|
166
|
+
"./fst-types": {
|
|
167
|
+
"types": "./out/fst-types.d.ts",
|
|
168
|
+
"default": "./out/fst-types.js"
|
|
169
|
+
},
|
|
170
|
+
"./fst-deserialize-web": {
|
|
171
|
+
"types": "./out/fst-deserialize-web.d.ts",
|
|
172
|
+
"default": "./out/fst-deserialize-web.js"
|
|
173
|
+
},
|
|
174
|
+
"./unified-schema": {
|
|
175
|
+
"types": "./out/unified-schema.d.ts",
|
|
176
|
+
"default": "./out/unified-schema.js"
|
|
177
|
+
},
|
|
178
|
+
"./ancestry-backfill": {
|
|
179
|
+
"types": "./out/ancestry-backfill.d.ts",
|
|
180
|
+
"default": "./out/ancestry-backfill.js"
|
|
181
|
+
},
|
|
182
|
+
"./coincident-roles": {
|
|
183
|
+
"types": "./out/coincident-roles.d.ts",
|
|
184
|
+
"default": "./out/coincident-roles.js"
|
|
185
|
+
},
|
|
186
|
+
"./geonames-aliases": {
|
|
187
|
+
"types": "./out/geonames-aliases.d.ts",
|
|
188
|
+
"default": "./out/geonames-aliases.js"
|
|
189
|
+
},
|
|
190
|
+
"./geonames-postal": {
|
|
191
|
+
"types": "./out/geonames-postal.d.ts",
|
|
192
|
+
"default": "./out/geonames-postal.js"
|
|
193
|
+
},
|
|
194
|
+
"./geo": {
|
|
195
|
+
"types": "./out/geo.d.ts",
|
|
196
|
+
"default": "./out/geo.js"
|
|
197
|
+
},
|
|
198
|
+
"./street-normalize": {
|
|
199
|
+
"types": "./out/street-normalize.d.ts",
|
|
200
|
+
"default": "./out/street-normalize.js"
|
|
201
|
+
},
|
|
202
|
+
"./build-candidate": {
|
|
203
|
+
"types": "./out/build-candidate.d.ts",
|
|
204
|
+
"default": "./out/build-candidate.js"
|
|
205
|
+
},
|
|
206
|
+
"./candidate-schema": {
|
|
207
|
+
"types": "./out/candidate-schema.d.ts",
|
|
208
|
+
"default": "./out/candidate-schema.js"
|
|
209
|
+
},
|
|
210
|
+
"./postal-city-alias-schema": {
|
|
211
|
+
"types": "./out/postal-city-alias-schema.d.ts",
|
|
212
|
+
"default": "./out/postal-city-alias-schema.js"
|
|
213
|
+
},
|
|
214
|
+
"./address-point-schema": {
|
|
215
|
+
"types": "./out/address-point-schema.d.ts",
|
|
216
|
+
"default": "./out/address-point-schema.js"
|
|
217
|
+
},
|
|
218
|
+
"./street-centroid-schema": {
|
|
219
|
+
"types": "./out/street-centroid-schema.d.ts",
|
|
220
|
+
"default": "./out/street-centroid-schema.js"
|
|
221
|
+
},
|
|
222
|
+
"./street-segment-schema": {
|
|
223
|
+
"types": "./out/street-segment-schema.d.ts",
|
|
224
|
+
"default": "./out/street-segment-schema.js"
|
|
225
|
+
},
|
|
226
|
+
"./poi-schema": {
|
|
227
|
+
"types": "./out/poi-schema.d.ts",
|
|
228
|
+
"default": "./out/poi-schema.js"
|
|
229
|
+
},
|
|
230
|
+
"./poi-lookup": {
|
|
231
|
+
"types": "./out/poi-lookup.d.ts",
|
|
232
|
+
"default": "./out/poi-lookup.js"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
149
235
|
},
|
|
150
236
|
"dependencies": {
|
|
151
|
-
"@mailwoman/codex": "7.2.
|
|
152
|
-
"@mailwoman/core": "7.2.
|
|
153
|
-
"@mailwoman/resolver": "7.2.
|
|
154
|
-
"@mailwoman/spatial": "7.2.
|
|
237
|
+
"@mailwoman/codex": "7.2.1",
|
|
238
|
+
"@mailwoman/core": "7.2.1",
|
|
239
|
+
"@mailwoman/resolver": "7.2.1",
|
|
240
|
+
"@mailwoman/spatial": "7.2.1",
|
|
155
241
|
"h3-js": "^4.5.0",
|
|
156
242
|
"kysely": "^0.29.3"
|
|
157
243
|
}
|
package/poi-lookup.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Node reader for `poi.db` (spec §3.4) — the res-9 k-ring reader over the clustered `poi`
|
|
7
|
+
* `WITHOUT ROWID` B-tree Task 1's schema (`poi-schema.ts`) builds. Three search modes share one
|
|
8
|
+
* artifact:
|
|
9
|
+
*
|
|
10
|
+
* - **Category**: `latLngToCell(center, 9)` → `gridDisk` ring-by-ring expansion, probing each
|
|
11
|
+
* cell's clustered `(h3_cell, category_id, neg_rank, …)` range. Rings accumulate until `limit`
|
|
12
|
+
* rows are on hand after a completed ring, or `maxRings` is exhausted; the pool is sorted by
|
|
13
|
+
* haversine distance from `center` after every ring.
|
|
14
|
+
* - **Brand**: the same k-ring walk, but probing on `brand_wikidata` instead — category
|
|
15
|
+
* unconstrained, so a chain's rows surface regardless of how they were categorized.
|
|
16
|
+
* - **Name**: FTS5 `MATCH` against the `poi_search` virtual table, hydrated back to full rows by
|
|
17
|
+
* `name_key`. No center required; if one is given, hits are still distance-sorted.
|
|
18
|
+
*
|
|
19
|
+
* `latLngToCell`/`gridDisk` come from `h3-js`; the 48-bit short-cell packing that turns a raw H3
|
|
20
|
+
* cell into the integer `poi.h3_cell` stores is `@mailwoman/spatial`'s `shortenH3Cell` — that math
|
|
21
|
+
* is NEVER reimplemented here (see AGENTS.md on `@mailwoman/spatial` being the one true home for
|
|
22
|
+
* it).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { DatabaseSync } from "node:sqlite"
|
|
26
|
+
|
|
27
|
+
import { haversineKm, shortenH3Cell, type H3Cell } from "@mailwoman/spatial"
|
|
28
|
+
import { gridDisk, latLngToCell } from "h3-js"
|
|
29
|
+
|
|
30
|
+
import type { POICategoryCodeTable, POITable } from "./poi-schema.ts"
|
|
31
|
+
|
|
32
|
+
/** Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4). */
|
|
33
|
+
export const POI_H3_RESOLUTION = 9
|
|
34
|
+
|
|
35
|
+
/** Ring budget default: 12 res-9 k-rings ≈ ~4 km. */
|
|
36
|
+
const DEFAULT_MAX_RINGS = 12
|
|
37
|
+
|
|
38
|
+
/** Row-count default when a query doesn't specify `limit`. */
|
|
39
|
+
const DEFAULT_LIMIT = 20
|
|
40
|
+
|
|
41
|
+
export interface POISearchQuery {
|
|
42
|
+
/** Poi-taxonomy category id (string side of the dictionary). Ignored when `brandWikidata` is also set — brand wins. */
|
|
43
|
+
categoryID?: string
|
|
44
|
+
/** Wikidata QID for brand-exact search. */
|
|
45
|
+
brandWikidata?: string
|
|
46
|
+
/** Free-text name (FTS5). */
|
|
47
|
+
name?: string
|
|
48
|
+
/** Search center. Required for category/brand queries (k-ring expansion). */
|
|
49
|
+
center?: { latitude: number; longitude: number }
|
|
50
|
+
/**
|
|
51
|
+
* Ring budget: how many res-9 k-rings to expand before giving up (default 12 ≈ ~4 km). Counts ring 0, so k reaches
|
|
52
|
+
* `maxRings - 1`.
|
|
53
|
+
*/
|
|
54
|
+
maxRings?: number
|
|
55
|
+
limit?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface POISearchHit {
|
|
59
|
+
name: string | null
|
|
60
|
+
categoryID: string | null
|
|
61
|
+
brandWikidata: string | null
|
|
62
|
+
latitude: number
|
|
63
|
+
longitude: number
|
|
64
|
+
country: string
|
|
65
|
+
confidence: number
|
|
66
|
+
distanceM?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface POILookupOpts {
|
|
70
|
+
/** Path to a `poi.db` built by the (future) POI builder. Opened read-only. */
|
|
71
|
+
databasePath?: string
|
|
72
|
+
/** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
|
|
73
|
+
database?: DatabaseSync
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `poi` columns every search mode hydrates — a typed projection of the SHARED {@link POITable}. */
|
|
77
|
+
type POIRow = Pick<
|
|
78
|
+
POITable,
|
|
79
|
+
"name" | "category_id" | "brand_wikidata" | "latitude" | "longitude" | "country" | "confidence" | "name_key"
|
|
80
|
+
>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Node reader over `poi.db`. `implements Disposable` so callers can `using lookup = new POILookup(...)` (or call
|
|
84
|
+
* `[Symbol.dispose]()` explicitly), the same precedent as {@link WOFCandidateTableLookup} /
|
|
85
|
+
* {@link WOFSqlitePlaceLookup}.
|
|
86
|
+
*/
|
|
87
|
+
export class POILookup implements Disposable {
|
|
88
|
+
#db: DatabaseSync
|
|
89
|
+
#ownsDB: boolean
|
|
90
|
+
readonly #categoryToID = new Map<string, number>()
|
|
91
|
+
readonly #idToCategory = new Map<number, string>()
|
|
92
|
+
|
|
93
|
+
/** `(h3_cell, category_id)` → the cell's category-clustered range, most-confident-first. */
|
|
94
|
+
readonly #categoryCellProbe: ReturnType<DatabaseSync["prepare"]>
|
|
95
|
+
/** `(h3_cell, brand_wikidata)` → the cell's brand-matched rows (category unconstrained). */
|
|
96
|
+
readonly #brandCellProbe: ReturnType<DatabaseSync["prepare"]>
|
|
97
|
+
/** FTS5 `MATCH` over `poi_search`, returning candidate `name_key`s to hydrate. */
|
|
98
|
+
readonly #nameFTSProbe: ReturnType<DatabaseSync["prepare"]>
|
|
99
|
+
|
|
100
|
+
constructor(opts: POILookupOpts) {
|
|
101
|
+
if (opts.database) {
|
|
102
|
+
this.#db = opts.database
|
|
103
|
+
this.#ownsDB = false
|
|
104
|
+
} else if (opts.databasePath) {
|
|
105
|
+
this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
|
|
106
|
+
this.#ownsDB = true
|
|
107
|
+
} else {
|
|
108
|
+
throw new Error("POILookup needs `databasePath` or `database`")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The category dictionary is tiny (poi-taxonomy's category count) — load it once at
|
|
112
|
+
// construction so `search` never round-trips to it.
|
|
113
|
+
for (const r of this.#db
|
|
114
|
+
.prepare("SELECT id, category FROM poi_category_codes")
|
|
115
|
+
.all() as unknown as POICategoryCodeTable[]) {
|
|
116
|
+
this.#categoryToID.set(String(r.category), Number(r.id))
|
|
117
|
+
this.#idToCategory.set(Number(r.id), String(r.category))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const columns = "name, category_id, brand_wikidata, latitude, longitude, country, confidence, name_key"
|
|
121
|
+
|
|
122
|
+
this.#categoryCellProbe = this.#db.prepare(
|
|
123
|
+
`SELECT ${columns} FROM poi WHERE h3_cell = ? AND category_id = ? ORDER BY neg_rank ASC LIMIT ?`
|
|
124
|
+
)
|
|
125
|
+
this.#brandCellProbe = this.#db.prepare(
|
|
126
|
+
`SELECT ${columns} FROM poi WHERE h3_cell = ? AND brand_wikidata = ? ORDER BY neg_rank ASC LIMIT ?`
|
|
127
|
+
)
|
|
128
|
+
this.#nameFTSProbe = this.#db.prepare(
|
|
129
|
+
"SELECT name_key FROM poi_search WHERE poi_search MATCH ? ORDER BY bm25(poi_search) LIMIT ?"
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
search(query: POISearchQuery): POISearchHit[] {
|
|
134
|
+
const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT)
|
|
135
|
+
|
|
136
|
+
if (query.name) {
|
|
137
|
+
return this.#searchByName(query.name, limit, query.center)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (query.categoryID || query.brandWikidata) {
|
|
141
|
+
if (!query.center) {
|
|
142
|
+
throw new Error("POILookup.search: category/brand search requires a `center`")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return this.#searchKRing(query, limit)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Category or brand path: k-ring expansion from `query.center`'s res-9 cell, probing each new ring's cells. */
|
|
152
|
+
#searchKRing(query: POISearchQuery, limit: number): POISearchHit[] {
|
|
153
|
+
const center = query.center!
|
|
154
|
+
const maxRings = query.maxRings ?? DEFAULT_MAX_RINGS
|
|
155
|
+
let categoryId: number | undefined
|
|
156
|
+
|
|
157
|
+
// brandWikidata wins over categoryID when both are set — see POISearchQuery.categoryID.
|
|
158
|
+
if (!query.brandWikidata) {
|
|
159
|
+
categoryId = this.#categoryToID.get(query.categoryID!)
|
|
160
|
+
|
|
161
|
+
// An unknown category (not in the dictionary) can't have rows — a clean miss, not a throw.
|
|
162
|
+
if (categoryId === undefined) return []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION) as H3Cell
|
|
166
|
+
const seenCells = new Set<string>()
|
|
167
|
+
let rows: POIRow[] = []
|
|
168
|
+
|
|
169
|
+
// `ring` starts at 0 (the origin cell itself), so this loop's k reaches `maxRings - 1`.
|
|
170
|
+
for (let ring = 0; ring < maxRings; ring++) {
|
|
171
|
+
// gridDisk(origin, ring) returns the WHOLE disk out to `ring`; diffing against what's
|
|
172
|
+
// already been probed derives just this ring's new cells.
|
|
173
|
+
const diskCells = gridDisk(origin, ring) as string[]
|
|
174
|
+
const newCells = diskCells.filter((cell) => !seenCells.has(cell))
|
|
175
|
+
|
|
176
|
+
for (const cell of newCells) {
|
|
177
|
+
seenCells.add(cell)
|
|
178
|
+
const shortCell = h3CellToInt(cell as H3Cell)
|
|
179
|
+
|
|
180
|
+
const hits = query.brandWikidata
|
|
181
|
+
? (this.#brandCellProbe.all(shortCell, query.brandWikidata, limit) as unknown as POIRow[])
|
|
182
|
+
: (this.#categoryCellProbe.all(shortCell, categoryId!, limit) as unknown as POIRow[])
|
|
183
|
+
|
|
184
|
+
rows.push(...hits)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rows = sortByDistance(rows, center)
|
|
188
|
+
|
|
189
|
+
if (rows.length >= limit) break
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return rows.slice(0, limit).map((row) => toHit(row, this.#idToCategory, center))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Name path: FTS5 MATCH → hydrate by name_key. No center required; distance-sorts if one is given anyway.
|
|
197
|
+
*
|
|
198
|
+
* Hydration is ONE batched `WHERE name_key IN (...)` query over the FTS hits' unique `name_key`s, not a per-hit probe
|
|
199
|
+
* — with up to `limit` FTS hits, a per-hit probe was up to `limit` full table scans before `createPOINameKeyIndex`
|
|
200
|
+
* (poi-schema.ts) + this batching.
|
|
201
|
+
*/
|
|
202
|
+
#searchByName(name: string, limit: number, center?: { latitude: number; longitude: number }): POISearchHit[] {
|
|
203
|
+
const matchQuery = sanitizePOINameQuery(name)
|
|
204
|
+
|
|
205
|
+
if (!matchQuery) return []
|
|
206
|
+
|
|
207
|
+
const ftsHits = this.#nameFTSProbe.all(matchQuery, limit) as unknown as Array<{ name_key: string | null }>
|
|
208
|
+
const uniqueKeys: string[] = []
|
|
209
|
+
const seenKeys = new Set<string>()
|
|
210
|
+
|
|
211
|
+
for (const hit of ftsHits) {
|
|
212
|
+
if (!hit.name_key || seenKeys.has(hit.name_key)) continue
|
|
213
|
+
seenKeys.add(hit.name_key)
|
|
214
|
+
uniqueKeys.push(hit.name_key)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (uniqueKeys.length === 0) return []
|
|
218
|
+
|
|
219
|
+
const hydrated = this.#hydrateByNameKeys(uniqueKeys)
|
|
220
|
+
const rowsByKey = new Map<string, POIRow[]>()
|
|
221
|
+
|
|
222
|
+
for (const row of hydrated) {
|
|
223
|
+
if (row.name_key === null) continue
|
|
224
|
+
const bucket = rowsByKey.get(row.name_key)
|
|
225
|
+
|
|
226
|
+
if (bucket) {
|
|
227
|
+
bucket.push(row)
|
|
228
|
+
} else {
|
|
229
|
+
rowsByKey.set(row.name_key, [row])
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let rows: POIRow[] = []
|
|
234
|
+
|
|
235
|
+
for (const key of uniqueKeys) {
|
|
236
|
+
rows.push(...(rowsByKey.get(key) ?? []))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (center) {
|
|
240
|
+
rows = sortByDistance(rows, center)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return rows.slice(0, limit).map((row) => toHit(row, this.#idToCategory, center))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Batched hydration for the FTS name path: `WHERE name_key IN (?, ?, …)`, one query for the whole batch instead of
|
|
248
|
+
* one probe per FTS hit. This is a cold path (name search only) with variable arity per call, so the statement is
|
|
249
|
+
* prepared fresh each time rather than cached.
|
|
250
|
+
*/
|
|
251
|
+
#hydrateByNameKeys(nameKeys: string[]): POIRow[] {
|
|
252
|
+
const columns = "name, category_id, brand_wikidata, latitude, longitude, country, confidence, name_key"
|
|
253
|
+
const placeholders = nameKeys.map(() => "?").join(", ")
|
|
254
|
+
const stmt = this.#db.prepare(`SELECT ${columns} FROM poi WHERE name_key IN (${placeholders})`)
|
|
255
|
+
|
|
256
|
+
return stmt.all(...nameKeys) as unknown as POIRow[]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
close(): void {
|
|
260
|
+
if (this.#ownsDB) {
|
|
261
|
+
this.#db.close()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
[Symbol.dispose](): void {
|
|
266
|
+
this.close()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `poi.h3_cell` is the SHORTENED (48-bit) cell — never the full h3-js cell string. */
|
|
271
|
+
function h3CellToInt(cell: H3Cell): number {
|
|
272
|
+
return Number(BigInt(`0x${shortenH3Cell(cell)}`))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function sortByDistance(rows: POIRow[], center: { latitude: number; longitude: number }): POIRow[] {
|
|
276
|
+
return [...rows].sort(
|
|
277
|
+
(a, b) =>
|
|
278
|
+
haversineKm(center.latitude, center.longitude, a.latitude, a.longitude) -
|
|
279
|
+
haversineKm(center.latitude, center.longitude, b.latitude, b.longitude)
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function toHit(
|
|
284
|
+
row: POIRow,
|
|
285
|
+
idToCategory: ReadonlyMap<number, string>,
|
|
286
|
+
center?: { latitude: number; longitude: number }
|
|
287
|
+
): POISearchHit {
|
|
288
|
+
return {
|
|
289
|
+
name: row.name,
|
|
290
|
+
categoryID: row.category_id !== 0 ? (idToCategory.get(row.category_id) ?? null) : null,
|
|
291
|
+
brandWikidata: row.brand_wikidata,
|
|
292
|
+
latitude: row.latitude,
|
|
293
|
+
longitude: row.longitude,
|
|
294
|
+
country: row.country,
|
|
295
|
+
confidence: row.confidence,
|
|
296
|
+
...(center
|
|
297
|
+
? { distanceM: haversineKm(center.latitude, center.longitude, row.latitude, row.longitude) * 1000 }
|
|
298
|
+
: {}),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sanitize free text into an FTS5-safe MATCH query: strip the characters FTS5 would otherwise read as syntax (`"`
|
|
304
|
+
* phrase delimiters, `*` prefix wildcards, `:` column-filter separators), then phrase-quote each whitespace-separated
|
|
305
|
+
* token (AND-joined).
|
|
306
|
+
*
|
|
307
|
+
* `resolver-wof-sqlite` already has this discipline — `lookup.ts`'s `sanitizeFTSQuery` — but that function is
|
|
308
|
+
* module-private there (not re-exported from `fts.ts` or the package's `index.ts`), so this replicates the same
|
|
309
|
+
* discipline locally rather than reaching across the module boundary for a private helper.
|
|
310
|
+
*/
|
|
311
|
+
function sanitizePOINameQuery(text: string): string {
|
|
312
|
+
return text
|
|
313
|
+
.replace(/["*:]/g, "")
|
|
314
|
+
.trim()
|
|
315
|
+
.split(/\s+/u)
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.map((token) => `"${token.replace(/"/g, '""')}"`)
|
|
318
|
+
.join(" ")
|
|
319
|
+
}
|