@ferrybig/cgeodata-reader 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +313 -2
- package/binary-format.md +2620 -0
- package/dist/cache.d.ts +24 -0
- package/dist/cache.js +60 -0
- package/dist/cellBounds.d.ts +19 -0
- package/dist/cellBounds.js +36 -0
- package/dist/chunk.d.ts +35 -0
- package/dist/chunk.js +277 -0
- package/dist/chunkDecoder.d.ts +39 -0
- package/dist/chunkDecoder.js +116 -0
- package/dist/chunkDecoderWasm.d.ts +2 -0
- package/dist/chunkDecoderWasm.js +4 -0
- package/dist/cli/buildLocateIndex.d.ts +2 -0
- package/dist/cli/buildLocateIndex.js +46 -0
- package/dist/cli/buildSearchIndex.d.ts +2 -0
- package/dist/cli/buildSearchIndex.js +90 -0
- package/dist/cli/chunk.d.ts +2 -0
- package/dist/cli/chunk.js +67 -0
- package/dist/cli/info.d.ts +2 -0
- package/dist/cli/info.js +87 -0
- package/dist/cli/list.d.ts +2 -0
- package/dist/cli/list.js +71 -0
- package/dist/cli/locate.d.ts +2 -0
- package/dist/cli/locate.js +64 -0
- package/dist/cli/lookup.d.ts +2 -0
- package/dist/cli/lookup.js +128 -0
- package/dist/cli/search.d.ts +2 -0
- package/dist/cli/search.js +119 -0
- package/dist/cli/shared.d.ts +50 -0
- package/dist/cli/shared.js +140 -0
- package/dist/cli/show.d.ts +2 -0
- package/dist/cli/show.js +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/container.d.ts +17 -0
- package/dist/container.js +68 -0
- package/dist/directory.d.ts +21 -0
- package/dist/directory.js +31 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +56 -0
- package/dist/format.d.ts +22 -0
- package/dist/format.js +23 -0
- package/dist/header.d.ts +30 -0
- package/dist/header.js +69 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -0
- package/dist/indexes.d.ts +11 -0
- package/dist/indexes.js +12 -0
- package/dist/inflate.d.ts +21 -0
- package/dist/inflate.js +61 -0
- package/dist/locateIndex.d.ts +142 -0
- package/dist/locateIndex.js +561 -0
- package/dist/morton.d.ts +28 -0
- package/dist/morton.js +51 -0
- package/dist/node.d.ts +65 -0
- package/dist/node.js +137 -0
- package/dist/placeTable.d.ts +69 -0
- package/dist/placeTable.js +177 -0
- package/dist/rangeChunk.d.ts +46 -0
- package/dist/rangeChunk.js +401 -0
- package/dist/rangeDecoder.d.ts +17 -0
- package/dist/rangeDecoder.js +129 -0
- package/dist/rangeDecoderWasm.d.ts +2 -0
- package/dist/rangeDecoderWasm.js +4 -0
- package/dist/reader.d.ts +534 -0
- package/dist/reader.js +1132 -0
- package/dist/relevance.d.ts +184 -0
- package/dist/relevance.js +266 -0
- package/dist/searchIndex.d.ts +148 -0
- package/dist/searchIndex.js +786 -0
- package/dist/source.d.ts +49 -0
- package/dist/source.js +50 -0
- package/dist/turns.d.ts +19 -0
- package/dist/turns.js +40 -0
- package/dist/varint.d.ts +52 -0
- package/dist/varint.js +137 -0
- package/package.json +76 -5
package/dist/reader.js
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
import { ByteBudgetCache } from "./cache.js";
|
|
2
|
+
import { cellBoundingBox, chunkBoundingBox, } from "./cellBounds.js";
|
|
3
|
+
import { MAX_SUPPORTED_DEPTH, parseChunk } from "./chunk.js";
|
|
4
|
+
import { readHeaderAndDirectory } from "./container.js";
|
|
5
|
+
import { CGeoDataError, CGeoDataFormatError, CGeoDataIndexMismatchError, CGeoDataUnsupportedError, } from "./errors.js";
|
|
6
|
+
import { inflateBlock, streamInflater } from "./inflate.js";
|
|
7
|
+
import { computeLocateCell, decodeLocateEntry, LocateIndex, locateEntryWidth, } from "./locateIndex.js";
|
|
8
|
+
import { mortonDecode, mortonEncode, shiftRight } from "./morton.js";
|
|
9
|
+
import { largestIndexWithPrefixAtMost, parsePlaceTable, readNameBlock, resolveIdentifier, } from "./placeTable.js";
|
|
10
|
+
import { compareAreaRelevance, compareRelevance, contextQuality, foldDiacritics, parseQualifiedQuery, placeMatchQuality, prepareNeedle, regionRankArea, } from "./relevance.js";
|
|
11
|
+
import { RELEVANCE_AREA, RELEVANCE_PLAIN, SearchIndex } from "./searchIndex.js";
|
|
12
|
+
import { toSource } from "./source.js";
|
|
13
|
+
import { TurnQueue } from "./turns.js";
|
|
14
|
+
/** `OpenOptions.cacheBytes` when not given. */
|
|
15
|
+
export const DEFAULT_CACHE_BYTES = 64 * 2 ** 20;
|
|
16
|
+
/**
|
|
17
|
+
* A CGEODATA file opened for reading: answers which country, region and
|
|
18
|
+
* city a point lies in (`query`, `lookup`), what a place is called and
|
|
19
|
+
* where it sits in the hierarchy (`place`, `placeIds`), which places
|
|
20
|
+
* match a name (`search`), and where a place is (`locate`).
|
|
21
|
+
*
|
|
22
|
+
* Opening reads only the header, directory and place counts; each lookup
|
|
23
|
+
* reads and decodes the one chunk its point lies in, which then stays
|
|
24
|
+
* cached (`OpenOptions.cacheBytes`).
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // Node.js
|
|
29
|
+
* import { openFile } from "@ferrybig/cgeodata-reader/node";
|
|
30
|
+
* const reader = await openFile("world.bin");
|
|
31
|
+
*
|
|
32
|
+
* // Anywhere else
|
|
33
|
+
* import { CGeoDataReader } from "@ferrybig/cgeodata-reader";
|
|
34
|
+
* const reader = await CGeoDataReader.open(await (await fetch(url)).arrayBuffer());
|
|
35
|
+
*
|
|
36
|
+
* const { city, region, country } = await reader.query(52.0799, 4.3113);
|
|
37
|
+
* city?.internationalName; // "The Hague"
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class CGeoDataReader {
|
|
41
|
+
/** Decoded chunks (keyed by chunk index) and name blocks (keyed by
|
|
42
|
+
* `-1 - blockIndex`) under one byte budget. */
|
|
43
|
+
cache;
|
|
44
|
+
/** Loads in flight, under the same keys as `cache`, so concurrent
|
|
45
|
+
* lookups in one chunk decode it once. */
|
|
46
|
+
loading = new Map();
|
|
47
|
+
/** The chunk the last lookup used, held outside `cache`: lookups close
|
|
48
|
+
* together (a tile's samples) mostly land in the same chunk, and this
|
|
49
|
+
* answers those without touching the LRU's bookkeeping. */
|
|
50
|
+
lastChunkIndex = -1;
|
|
51
|
+
lastChunk;
|
|
52
|
+
/** Decodes take turns, one per macrotask; see `TurnQueue`. */
|
|
53
|
+
decodes = new TurnQueue();
|
|
54
|
+
/** The full-precision cell of the point `gridCell()` last placed; read
|
|
55
|
+
* right after, in the same synchronous run. */
|
|
56
|
+
gx = 0;
|
|
57
|
+
gy = 0;
|
|
58
|
+
search_;
|
|
59
|
+
locate_;
|
|
60
|
+
/** Rank areas of the regions searches have matched so far (Appendix
|
|
61
|
+
* B.5.1), -1 where not yet known, for the area bytes they came from. */
|
|
62
|
+
rankAreas;
|
|
63
|
+
placeTableChecksumPromise;
|
|
64
|
+
prefixChecksumPromise;
|
|
65
|
+
source;
|
|
66
|
+
inflater;
|
|
67
|
+
/** The file's header (Section 5), including its `attribution`. */
|
|
68
|
+
header;
|
|
69
|
+
/** The file's directory (Section 6): where each chunk is. */
|
|
70
|
+
directory;
|
|
71
|
+
placeTable;
|
|
72
|
+
constructor(source, header, directory, placeTable, cacheBytes, options) {
|
|
73
|
+
this.cache = new ByteBudgetCache(cacheBytes);
|
|
74
|
+
this.source = source;
|
|
75
|
+
this.inflater = options.inflater ?? streamInflater;
|
|
76
|
+
this.header = header;
|
|
77
|
+
this.directory = directory;
|
|
78
|
+
this.placeTable = placeTable;
|
|
79
|
+
this.search_ = pendingSlot(options.searchIndex);
|
|
80
|
+
this.locate_ = pendingSlot(options.locateIndex);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens a CGEODATA file: reads and validates its header, directory and
|
|
84
|
+
* the uncompressed part of its place table (Sections 5-7.2), and
|
|
85
|
+
* nothing else -- chunks and names are read on first use. The reader
|
|
86
|
+
* takes ownership of `input`, if it's a `ByteSource`: `close()` closes
|
|
87
|
+
* it, as does a failed open.
|
|
88
|
+
*/
|
|
89
|
+
static async open(input, options = {}) {
|
|
90
|
+
const { cacheBytes = DEFAULT_CACHE_BYTES } = options;
|
|
91
|
+
if (!(cacheBytes >= 0)) {
|
|
92
|
+
throw new RangeError(`cacheBytes must be a non-negative number, not ${cacheBytes}`);
|
|
93
|
+
}
|
|
94
|
+
const source = toSource(input);
|
|
95
|
+
try {
|
|
96
|
+
const { header, directory } = await readHeaderAndDirectory(source);
|
|
97
|
+
if (header.maxDepth > MAX_SUPPORTED_DEPTH) {
|
|
98
|
+
throw new CGeoDataUnsupportedError(`max_depth ${header.maxDepth} is past the ${MAX_SUPPORTED_DEPTH} this reader supports: rebuild the file with a bigger chunk grid instead`);
|
|
99
|
+
}
|
|
100
|
+
const placeTable = await parsePlaceTable(source, directory.placeTableStart, directory.placeTableLength, header.keyframeExp);
|
|
101
|
+
return new CGeoDataReader(source, header, directory, placeTable, cacheBytes, options);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
await source.close?.();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Closes the source, and any index this reader opened itself. */
|
|
109
|
+
async close() {
|
|
110
|
+
await this.useSearchIndex(undefined);
|
|
111
|
+
await this.useLocateIndex(undefined);
|
|
112
|
+
this.cache.clear();
|
|
113
|
+
this.lastChunk = undefined;
|
|
114
|
+
this.lastChunkIndex = -1;
|
|
115
|
+
await this.source.close?.();
|
|
116
|
+
}
|
|
117
|
+
/** How many countries, regions and cities the file has. */
|
|
118
|
+
get placeCounts() {
|
|
119
|
+
const { countryCount, totalRegions, totalCities } = this.placeTable;
|
|
120
|
+
return {
|
|
121
|
+
countries: countryCount,
|
|
122
|
+
regions: totalRegions,
|
|
123
|
+
cities: totalCities,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The country, region and city a point lies in: Section 9's lookup
|
|
128
|
+
* procedure (`lookup()`) with the names resolved (`place()`). Rejects
|
|
129
|
+
* with a `RangeError` for a latitude outside [-90, 90) or a longitude
|
|
130
|
+
* outside [-180, 180).
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const result = await reader.query(35.6762, 139.6503);
|
|
135
|
+
* result.city?.localName; // "杉並区"
|
|
136
|
+
* result.city?.internationalName; // "Suginami"
|
|
137
|
+
* result.country?.internationalName; // "Japan"
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
async query(lat, lon) {
|
|
141
|
+
const result = await this.lookup(lat, lon);
|
|
142
|
+
if (result.placeId === null) {
|
|
143
|
+
return { ...result, country: null, region: null, city: null };
|
|
144
|
+
}
|
|
145
|
+
const { country, region, city } = await this.place(result.placeId);
|
|
146
|
+
return { ...result, country, region, city };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Section 9's lookup procedure: the place a point lies in, as an id, and
|
|
150
|
+
* the cell the answer holds for. Reads and decodes the point's chunk if
|
|
151
|
+
* it isn't cached; resolve the id with `place()` or `placeIds()`.
|
|
152
|
+
*/
|
|
153
|
+
async lookup(lat, lon) {
|
|
154
|
+
const chunkIndex = this.gridCell(lat, lon);
|
|
155
|
+
let chunk = this.loadedChunk(chunkIndex);
|
|
156
|
+
if (chunk === undefined) {
|
|
157
|
+
chunk = await this.loadChunk(chunkIndex, true);
|
|
158
|
+
this.gridCell(lat, lon); // another lookup may have run meanwhile
|
|
159
|
+
}
|
|
160
|
+
return this.resolve(chunkIndex, chunk);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `lookup()` answered from memory only: the same result when the
|
|
164
|
+
* point's chunk is already decoded, undefined when answering would
|
|
165
|
+
* need a read. For callers doing many lookups close together, which
|
|
166
|
+
* mostly hit the same few chunks:
|
|
167
|
+
*
|
|
168
|
+
* ```ts
|
|
169
|
+
* const result = reader.lookupCached(lat, lon) ?? (await reader.lookup(lat, lon));
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* waits only for the lookups that actually read something.
|
|
173
|
+
*/
|
|
174
|
+
lookupCached(lat, lon) {
|
|
175
|
+
const chunkIndex = this.gridCell(lat, lon);
|
|
176
|
+
const chunk = this.loadedChunk(chunkIndex);
|
|
177
|
+
return chunk === undefined ? undefined : this.resolve(chunkIndex, chunk);
|
|
178
|
+
}
|
|
179
|
+
/** The index of the chunk a point lies in (Section 6.1). */
|
|
180
|
+
chunkIndexAt(lat, lon) {
|
|
181
|
+
return this.gridCell(lat, lon);
|
|
182
|
+
}
|
|
183
|
+
/** Validates a point and places it on the full-precision grid
|
|
184
|
+
* (`this.gx`, `this.gy`), returning its chunk's index. */
|
|
185
|
+
gridCell(lat, lon) {
|
|
186
|
+
if (!(lat >= -90 && lat < 90)) {
|
|
187
|
+
throw new RangeError(`latitude ${lat} is outside the valid range [-90, 90)`);
|
|
188
|
+
}
|
|
189
|
+
if (!(lon >= -180 && lon < 180)) {
|
|
190
|
+
throw new RangeError(`longitude ${lon} is outside the valid range [-180, 180)`);
|
|
191
|
+
}
|
|
192
|
+
const { width: W, height: H, maxDepth: D } = this.header;
|
|
193
|
+
const NX = W * 2 ** D;
|
|
194
|
+
const NY = H * 2 ** D;
|
|
195
|
+
const gx = Math.floor(((lon + 180) / 360) * NX);
|
|
196
|
+
const gy = Math.floor(((lat + 90) / 180) * NY);
|
|
197
|
+
this.gx = Math.min(Math.max(gx, 0), NX - 1);
|
|
198
|
+
this.gy = Math.min(Math.max(gy, 0), NY - 1);
|
|
199
|
+
return Math.floor(this.gy / 2 ** D) * W + Math.floor(this.gx / 2 ** D);
|
|
200
|
+
}
|
|
201
|
+
/** Section 9 steps 5-8 for the point `gridCell()` just placed, in its
|
|
202
|
+
* (decoded, or null for empty) chunk. */
|
|
203
|
+
resolve(chunkIndex, chunk) {
|
|
204
|
+
const { width: W, height: H, maxDepth: D } = this.header;
|
|
205
|
+
const cellsPerChunkSide = 2 ** D;
|
|
206
|
+
const c = chunkIndex % W;
|
|
207
|
+
const r = Math.floor(chunkIndex / W);
|
|
208
|
+
const x = this.gx - c * cellsPerChunkSide;
|
|
209
|
+
const y = this.gy - r * cellsPerChunkSide;
|
|
210
|
+
const chunkBox = chunkBoundingBox(c, r, W, H);
|
|
211
|
+
if (!chunk)
|
|
212
|
+
return unknownResult(chunkBox, chunkIndex); // whole chunk is empty
|
|
213
|
+
const q = mortonEncode(x, y, D);
|
|
214
|
+
const j = bisectRightMinusOne(chunk.codes, q);
|
|
215
|
+
// Neither of these two "no record covers this" cases can recover the
|
|
216
|
+
// gap's true extent without redoing the encoder's own subdivision
|
|
217
|
+
// work, so fall back to the single finest cell around the query point.
|
|
218
|
+
if (j < 0)
|
|
219
|
+
return unknownResult(cellBoundingBox(chunkBox, x, y, D), chunkIndex);
|
|
220
|
+
const depth = chunk.depths[j];
|
|
221
|
+
const shift = 2 * (D - depth);
|
|
222
|
+
const code = chunk.codes[j];
|
|
223
|
+
if (shiftRight(q, shift) !== shiftRight(code, shift)) {
|
|
224
|
+
// gap between records
|
|
225
|
+
return unknownResult(cellBoundingBox(chunkBox, x, y, D), chunkIndex);
|
|
226
|
+
}
|
|
227
|
+
// A real record matched: recover its exact cell (Section 8.1) for a
|
|
228
|
+
// tight, correct bounding box, whether or not it resolves to a place.
|
|
229
|
+
const matchedCellBox = this.recordBoundingBox(chunkBox, code, depth);
|
|
230
|
+
const k = chunk.paletteIndices[j];
|
|
231
|
+
if (k === 0)
|
|
232
|
+
return unknownResult(matchedCellBox, chunkIndex);
|
|
233
|
+
return {
|
|
234
|
+
placeId: chunk.palette[k - 1],
|
|
235
|
+
boundingBox: matchedCellBox,
|
|
236
|
+
chunkIndex,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** The names of one place by global id, without resolving its ancestors.
|
|
240
|
+
* Cheaper than `place()` when only the names are needed. */
|
|
241
|
+
async placeNames(id) {
|
|
242
|
+
this.checkPlaceId(id);
|
|
243
|
+
return this.loadedNames(id) ?? (await this.loadNames(id));
|
|
244
|
+
}
|
|
245
|
+
/** FNV-1a 64-bit hash of the place table bytes, as defined by
|
|
246
|
+
* binary-format.md Section B.6. Used to bind a search index to its source. */
|
|
247
|
+
placeTableChecksum() {
|
|
248
|
+
this.placeTableChecksumPromise ??= (async () => {
|
|
249
|
+
const { placeTableStart, placeTableLength } = this.directory;
|
|
250
|
+
return fnv1a64(await this.source.read(placeTableStart, placeTableLength));
|
|
251
|
+
})();
|
|
252
|
+
return this.placeTableChecksumPromise;
|
|
253
|
+
}
|
|
254
|
+
/** FNV-1a 64-bit hash of everything before the first chunk -- header,
|
|
255
|
+
* directory and place table -- as defined by binary-format.md Section
|
|
256
|
+
* C.4. Binds a locate index to its source: the chunk lengths it covers
|
|
257
|
+
* change whenever the chunks do. */
|
|
258
|
+
prefixChecksum() {
|
|
259
|
+
this.prefixChecksumPromise ??= (async () => {
|
|
260
|
+
const { placeTableStart, placeTableLength } = this.directory;
|
|
261
|
+
return fnv1a64(await this.source.read(0, placeTableStart + placeTableLength));
|
|
262
|
+
})();
|
|
263
|
+
return this.prefixChecksumPromise;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Attaches a text search index (Appendix B, built by `buildSearchIndex`
|
|
267
|
+
* / `cgeodata build-search-index`) so `search()` only checks the places
|
|
268
|
+
* the index says could match instead of every place. Results are
|
|
269
|
+
* identical with or without it -- only the speed differs.
|
|
270
|
+
*
|
|
271
|
+
* Takes the index's file (as for `open()`; read on demand) or an open
|
|
272
|
+
* `SearchIndex` (which the caller keeps ownership of), or undefined to
|
|
273
|
+
* search without one. Replaces `OpenOptions.searchIndex`. Rejects with
|
|
274
|
+
* a `CGeoDataIndexMismatchError` if it was built from a different file.
|
|
275
|
+
*/
|
|
276
|
+
async useSearchIndex(index) {
|
|
277
|
+
await this.attach(this.search_, index, SearchIndex.open, (opened) => this.checkSearchIndex(opened));
|
|
278
|
+
}
|
|
279
|
+
/** Whether `search()` will use an index. Reads (and validates) the one
|
|
280
|
+
* from `OpenOptions.searchIndex` if that hasn't happened yet. */
|
|
281
|
+
async hasSearchIndex() {
|
|
282
|
+
return (await this.activeSearchIndex()) !== undefined;
|
|
283
|
+
}
|
|
284
|
+
/** The attached search index, opening `OpenOptions.searchIndex` on
|
|
285
|
+
* first use. One that's there but unusable (stale, corrupt) rejects on
|
|
286
|
+
* every call rather than silently falling back to a linear scan. */
|
|
287
|
+
activeSearchIndex() {
|
|
288
|
+
return this.activate(this.search_, "search", (index) => this.useSearchIndex(index));
|
|
289
|
+
}
|
|
290
|
+
async checkSearchIndex(index) {
|
|
291
|
+
// An index ranked by area depends on the chunks too (Section B.8).
|
|
292
|
+
const checksum = index.relevanceRule === RELEVANCE_AREA
|
|
293
|
+
? await this.prefixChecksum()
|
|
294
|
+
: await this.placeTableChecksum();
|
|
295
|
+
if (index.sourceChecksum !== checksum) {
|
|
296
|
+
throw new CGeoDataIndexMismatchError("search index was built from a different .bin file (checksum mismatch); rebuild it with build-search-index");
|
|
297
|
+
}
|
|
298
|
+
if (index.placeCount !== this.placeTable.totalPlaces) {
|
|
299
|
+
throw new CGeoDataIndexMismatchError(`search index covers ${index.placeCount} places, but this file has ${this.placeTable.totalPlaces}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Attaches a locate index (Appendix C, built by `buildLocateIndex` /
|
|
304
|
+
* `cgeodata build-locate-index`) so `locate()` reads one entry instead
|
|
305
|
+
* of scanning every chunk. Results are identical with or without it --
|
|
306
|
+
* only the speed differs -- except that its areas also let a sorted
|
|
307
|
+
* `search()` rank equal matches by size (`SearchOptions.areaTieBreak`).
|
|
308
|
+
*
|
|
309
|
+
* Takes the index's file (as for `open()`; read on demand) or an open
|
|
310
|
+
* `LocateIndex` (which the caller keeps ownership of), or undefined to
|
|
311
|
+
* locate without one. Replaces `OpenOptions.locateIndex`. Rejects with
|
|
312
|
+
* a `CGeoDataIndexMismatchError` if it was built from a different file.
|
|
313
|
+
*/
|
|
314
|
+
async useLocateIndex(index) {
|
|
315
|
+
await this.attach(this.locate_, index, LocateIndex.open, (opened) => this.checkLocateIndex(opened));
|
|
316
|
+
}
|
|
317
|
+
/** Whether `locate()` will use an index. Reads (and validates) the one
|
|
318
|
+
* from `OpenOptions.locateIndex` if that hasn't happened yet. */
|
|
319
|
+
async hasLocateIndex() {
|
|
320
|
+
return (await this.activeLocateIndex()) !== undefined;
|
|
321
|
+
}
|
|
322
|
+
/** The attached locate index, opening `OpenOptions.locateIndex` on
|
|
323
|
+
* first use. One that's there but unusable (stale, corrupt) rejects on
|
|
324
|
+
* every call rather than silently falling back to a chunk scan. */
|
|
325
|
+
activeLocateIndex() {
|
|
326
|
+
return this.activate(this.locate_, "locate", (index) => this.useLocateIndex(index));
|
|
327
|
+
}
|
|
328
|
+
async checkLocateIndex(index) {
|
|
329
|
+
if (index.sourceChecksum !== (await this.prefixChecksum())) {
|
|
330
|
+
throw new CGeoDataIndexMismatchError("locate index was built from a different .bin file (checksum mismatch); rebuild it with build-locate-index");
|
|
331
|
+
}
|
|
332
|
+
const { totalPlaces } = this.placeTable;
|
|
333
|
+
if (index.placeCount !== totalPlaces) {
|
|
334
|
+
throw new CGeoDataIndexMismatchError(`locate index covers ${index.placeCount} places, but this file has ${totalPlaces}`);
|
|
335
|
+
}
|
|
336
|
+
const expectedWidth = locateEntryWidth(this.header);
|
|
337
|
+
if (index.entryWidth !== expectedWidth) {
|
|
338
|
+
throw new CGeoDataIndexMismatchError(`locate index has ${index.entryWidth}-byte entries, but this file's grid needs ${expectedWidth}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** `useSearchIndex`/`useLocateIndex`, for either slot. */
|
|
342
|
+
async attach(slot, index, open, check) {
|
|
343
|
+
slot.pending = undefined;
|
|
344
|
+
if (slot.owned)
|
|
345
|
+
await slot.index?.close();
|
|
346
|
+
slot.index = undefined;
|
|
347
|
+
slot.owned = false;
|
|
348
|
+
if (index === undefined)
|
|
349
|
+
return;
|
|
350
|
+
const owned = !(index instanceof SearchIndex || index instanceof LocateIndex);
|
|
351
|
+
const opened = owned ? await open(index) : index;
|
|
352
|
+
try {
|
|
353
|
+
await check(opened);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (owned)
|
|
357
|
+
await opened.close();
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
slot.index = opened;
|
|
361
|
+
slot.owned = owned;
|
|
362
|
+
}
|
|
363
|
+
/** The slot's index, trying its pending one first. */
|
|
364
|
+
activate(slot, what, use) {
|
|
365
|
+
if (slot.pending === undefined)
|
|
366
|
+
return Promise.resolve(slot.index);
|
|
367
|
+
slot.activating ??= this.tryPending(slot, what, use).finally(() => {
|
|
368
|
+
slot.activating = undefined;
|
|
369
|
+
});
|
|
370
|
+
return slot.activating;
|
|
371
|
+
}
|
|
372
|
+
async tryPending(slot, what, use) {
|
|
373
|
+
const pending = slot.pending;
|
|
374
|
+
let input;
|
|
375
|
+
try {
|
|
376
|
+
input = await pending();
|
|
377
|
+
if (input === undefined)
|
|
378
|
+
slot.pending = undefined;
|
|
379
|
+
else
|
|
380
|
+
await use(input);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
slot.pending = pending; // `use` cleared it; try again next time
|
|
384
|
+
const name = indexName(input);
|
|
385
|
+
throw name === undefined
|
|
386
|
+
? error
|
|
387
|
+
: withContext(error, `${name}: `, ` (or delete it to ${what} without an index)`);
|
|
388
|
+
}
|
|
389
|
+
return slot.index;
|
|
390
|
+
}
|
|
391
|
+
/** Resolves one place id (Section 7.5) to its names and ancestors. */
|
|
392
|
+
async place(id) {
|
|
393
|
+
const loaded = this.loadedPlace(id);
|
|
394
|
+
if (loaded)
|
|
395
|
+
return loaded;
|
|
396
|
+
const ids = this.placeIds(id);
|
|
397
|
+
await Promise.all([ids.country, ids.region, ids.city].map((globalId) => globalId === null || this.loadedNames(globalId)
|
|
398
|
+
? undefined
|
|
399
|
+
: this.loadNames(globalId)));
|
|
400
|
+
// Loaded, though not necessarily still cached: rebuild from the
|
|
401
|
+
// blocks if the budget already let one go.
|
|
402
|
+
return this.loadedPlace(id) ?? this.placeFrom(ids, id);
|
|
403
|
+
}
|
|
404
|
+
/** `place()` when every name it needs is cached, else undefined. */
|
|
405
|
+
loadedPlace(id) {
|
|
406
|
+
const ids = this.placeIds(id);
|
|
407
|
+
const country = this.loadedNames(ids.country);
|
|
408
|
+
if (!country)
|
|
409
|
+
return undefined;
|
|
410
|
+
let region;
|
|
411
|
+
if (ids.region !== null) {
|
|
412
|
+
region = this.loadedNames(ids.region);
|
|
413
|
+
if (!region)
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
let city;
|
|
417
|
+
if (ids.city !== null) {
|
|
418
|
+
city = this.loadedNames(ids.city);
|
|
419
|
+
if (!city)
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return assemblePlace(id, ids, country, region, city);
|
|
423
|
+
}
|
|
424
|
+
/** `place()` loading each name block in turn, for when the cache is
|
|
425
|
+
* too small to hold them all at once. */
|
|
426
|
+
async placeFrom(ids, id) {
|
|
427
|
+
const country = await this.loadNames(ids.country);
|
|
428
|
+
const region = ids.region === null ? undefined : await this.loadNames(ids.region);
|
|
429
|
+
const city = ids.city === null ? undefined : await this.loadNames(ids.city);
|
|
430
|
+
return assemblePlace(id, ids, country, region, city);
|
|
431
|
+
}
|
|
432
|
+
/** `place()` without the names: the place's level and the global ids
|
|
433
|
+
* of it and its ancestors, from the place table's counts alone, so no
|
|
434
|
+
* name block is read -- and so synchronous. */
|
|
435
|
+
placeIds(id) {
|
|
436
|
+
const resolved = resolveIdentifier(this.placeTable, id);
|
|
437
|
+
const C = this.placeTable.countryCount;
|
|
438
|
+
const R = this.placeTable.totalRegions;
|
|
439
|
+
return {
|
|
440
|
+
level: resolved.level,
|
|
441
|
+
country: resolved.countryId,
|
|
442
|
+
region: "regionId" in resolved ? C + resolved.regionId : null,
|
|
443
|
+
city: resolved.level === "city" ? C + R + resolved.cityId : null,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/** How many places sit directly under `id`: a country's regions, a
|
|
447
|
+
* region's cities, always 0 for a city. From the place table's counts
|
|
448
|
+
* alone, like `placeIds()`, so no name block is read and it is
|
|
449
|
+
* synchronous: `childCount(region) === 0` finds a region with no city
|
|
450
|
+
* mapped to it. */
|
|
451
|
+
childCount(id) {
|
|
452
|
+
this.checkPlaceId(id);
|
|
453
|
+
const { countryCount: C, totalRegions: R } = this.placeTable;
|
|
454
|
+
const { regionCountryPrefix, cityRegionPrefix } = this.placeTable;
|
|
455
|
+
if (id < C)
|
|
456
|
+
return regionCountryPrefix[id + 1] - regionCountryPrefix[id];
|
|
457
|
+
if (id < C + R) {
|
|
458
|
+
const region = id - C;
|
|
459
|
+
return cityRegionPrefix[region + 1] - cityRegionPrefix[region];
|
|
460
|
+
}
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Walks the place hierarchy in id order (countries, then regions, then
|
|
465
|
+
* cities), optionally restricted to one level and/or one ancestor. Each
|
|
466
|
+
* level's ids for a given parent are contiguous (Section 7.5), so a
|
|
467
|
+
* filtered walk only touches the ids it yields.
|
|
468
|
+
*/
|
|
469
|
+
async *places(filter = {}) {
|
|
470
|
+
for (const level of LEVELS) {
|
|
471
|
+
if (filter.level !== undefined && filter.level !== level)
|
|
472
|
+
continue;
|
|
473
|
+
const [start, end] = this.idRange(level, filter);
|
|
474
|
+
for (let id = start; id < end; id++)
|
|
475
|
+
yield this.loadedPlace(id) ?? (await this.place(id));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Diacritic- and case-insensitive substring match against every name of
|
|
480
|
+
* every place (all levels unless filtered) -- Section B.5's "matches":
|
|
481
|
+
* the folded form (`foldDiacritics`) of a name contains the folded form
|
|
482
|
+
* of the query. Without a search index this is a plain linear scan over
|
|
483
|
+
* the name blocks; with one (`useSearchIndex()`), only the places the
|
|
484
|
+
* index can't rule out are compared. Same results either way.
|
|
485
|
+
*
|
|
486
|
+
* A text with commas also reads as a place and the places it lies in
|
|
487
|
+
* (Appendix B.13): "Paris, Texas" finds a place named "Paris, Texas"
|
|
488
|
+
* and every "Paris" whose region or country matches "Texas";
|
|
489
|
+
* "Springfield, Illinois, United States" narrows by both. Each part
|
|
490
|
+
* after a comma has to match a different ancestor, nearest first.
|
|
491
|
+
*
|
|
492
|
+
* @example
|
|
493
|
+
* ```ts
|
|
494
|
+
* for await (const place of reader.search("munchen", { sort: true, limit: 20 }))
|
|
495
|
+
* console.log(place.internationalName, place.country.internationalName);
|
|
496
|
+
* // Munich Germany
|
|
497
|
+
* // Grafing bei München Germany
|
|
498
|
+
* // ...
|
|
499
|
+
* ```
|
|
500
|
+
*/
|
|
501
|
+
async *search(text, options = {}) {
|
|
502
|
+
if (options.sort) {
|
|
503
|
+
yield* this.searchRanked(text, options);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
let remaining = options.limit ?? Infinity;
|
|
507
|
+
if (remaining <= 0)
|
|
508
|
+
return;
|
|
509
|
+
const query = await this.prepareQuery(text, await this.activeSearchIndex());
|
|
510
|
+
for (const level of LEVELS) {
|
|
511
|
+
if (options.level !== undefined && options.level !== level)
|
|
512
|
+
continue;
|
|
513
|
+
for (const id of this.searchScope(level, options, query.candidates)) {
|
|
514
|
+
// Match on the bare names first; resolving parents is only worth
|
|
515
|
+
// doing for the few that hit.
|
|
516
|
+
if (!(await this.matchQuery(id, level, query)))
|
|
517
|
+
continue;
|
|
518
|
+
yield this.loadedPlace(id) ?? (await this.place(id));
|
|
519
|
+
if (--remaining <= 0)
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** The `sort: true` path of `search()`: scores every match in relevance
|
|
525
|
+
* order (Appendix B.5, or B.5.1 when ranking by area; `relevance.ts`),
|
|
526
|
+
* then yields the best first. `limit` is applied after sorting, not
|
|
527
|
+
* during the scan -- the top match can turn up anywhere in id order. */
|
|
528
|
+
async *searchRanked(text, options) {
|
|
529
|
+
const areas = await this.searchAreas(options.areaTieBreak ?? "auto");
|
|
530
|
+
const compare = areas ? compareAreaRelevance : compareRelevance;
|
|
531
|
+
const needle = foldDiacritics(text);
|
|
532
|
+
const index = await this.activeSearchIndex();
|
|
533
|
+
// The table is keyed, and was scored, by each key's own folded form
|
|
534
|
+
// (Section B.6) -- the same needle a live query would use only when
|
|
535
|
+
// the query itself already equals its folded form, i.e. has no
|
|
536
|
+
// diacritic of its own to fold away. A query that does (`ë`, say)
|
|
537
|
+
// would need `nameMatchQuality`'s exact-spelling bonus evaluated
|
|
538
|
+
// against ITS OWN raw spelling, not the plain, diacritic-free key
|
|
539
|
+
// ("e") the table was built from -- those can legitimately disagree
|
|
540
|
+
// (an accented query can give a different field its spelling bonus
|
|
541
|
+
// than the plain key would). Falling back to the live scan for such
|
|
542
|
+
// a query is the only way to keep index and no-index results equal.
|
|
543
|
+
// So is one ranked by a different order than the table was built
|
|
544
|
+
// with, and one with a comma, which the table knows nothing about.
|
|
545
|
+
const shortQuery = index !== undefined &&
|
|
546
|
+
index.relevanceRule === (areas ? RELEVANCE_AREA : RELEVANCE_PLAIN) &&
|
|
547
|
+
options.limit !== undefined &&
|
|
548
|
+
options.limit <= index.topCount &&
|
|
549
|
+
options.level === undefined &&
|
|
550
|
+
options.country === undefined &&
|
|
551
|
+
options.region === undefined &&
|
|
552
|
+
!text.includes(",") &&
|
|
553
|
+
needle === text.toLowerCase()
|
|
554
|
+
? await index.shortQuery(text)
|
|
555
|
+
: undefined;
|
|
556
|
+
if (shortQuery) {
|
|
557
|
+
// The table already holds the answer (Appendix B.9 step 1); only
|
|
558
|
+
// confirm each place really matches, so a corrupt table can't add
|
|
559
|
+
// a wrong result.
|
|
560
|
+
let remaining = options.limit ?? Infinity;
|
|
561
|
+
for (const id of shortQuery) {
|
|
562
|
+
if (remaining <= 0)
|
|
563
|
+
return;
|
|
564
|
+
const names = this.loadedNames(id) ?? (await this.loadNames(id));
|
|
565
|
+
if (!names.some((name) => foldDiacritics(name).includes(needle)))
|
|
566
|
+
continue;
|
|
567
|
+
yield this.loadedPlace(id) ?? (await this.place(id));
|
|
568
|
+
remaining--;
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const query = await this.prepareQuery(text, index);
|
|
573
|
+
const scored = [];
|
|
574
|
+
for (const level of LEVELS) {
|
|
575
|
+
if (options.level !== undefined && options.level !== level)
|
|
576
|
+
continue;
|
|
577
|
+
for (const id of this.searchScope(level, options, query.candidates)) {
|
|
578
|
+
const match = await this.matchQuery(id, level, query, compare);
|
|
579
|
+
if (match)
|
|
580
|
+
scored.push(match);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (areas) {
|
|
584
|
+
for (const match of scored)
|
|
585
|
+
match.rankArea = await this.rankArea(match.id, match.level, areas);
|
|
586
|
+
}
|
|
587
|
+
scored.sort(compare);
|
|
588
|
+
let remaining = options.limit ?? Infinity;
|
|
589
|
+
for (const { id } of scored) {
|
|
590
|
+
if (remaining-- <= 0)
|
|
591
|
+
return;
|
|
592
|
+
yield this.loadedPlace(id) ?? (await this.place(id));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** `text`'s readings (Appendix B.13) as needles, and the places a search
|
|
596
|
+
* index says could match any of them (ascending), or undefined when
|
|
597
|
+
* there's no index or it can't narrow one of the readings down. */
|
|
598
|
+
async prepareQuery(text, index) {
|
|
599
|
+
const parsed = parseQualifiedQuery(text);
|
|
600
|
+
const readings = parsed.map(({ head, contexts }) => ({
|
|
601
|
+
head: prepareNeedle(head),
|
|
602
|
+
contexts: contexts.map(prepareNeedle),
|
|
603
|
+
}));
|
|
604
|
+
if (!index)
|
|
605
|
+
return { readings, candidates: undefined };
|
|
606
|
+
let candidates = [];
|
|
607
|
+
for (const { head } of parsed) {
|
|
608
|
+
const found = await index.candidates(head);
|
|
609
|
+
if (!found)
|
|
610
|
+
return { readings, candidates: undefined };
|
|
611
|
+
candidates =
|
|
612
|
+
candidates.length === 0 ? found : unionSorted(candidates, found);
|
|
613
|
+
}
|
|
614
|
+
return { readings, candidates };
|
|
615
|
+
}
|
|
616
|
+
/** Whether place `id` matches any reading of `query`. With `compare`,
|
|
617
|
+
* instead the best-ranked way it does (by that order), or undefined;
|
|
618
|
+
* `rankArea` is left at 0 for the caller to fill in. */
|
|
619
|
+
async matchQuery(id, level, query, compare) {
|
|
620
|
+
const names = this.loadedNames(id) ?? (await this.loadNames(id));
|
|
621
|
+
let best;
|
|
622
|
+
let ancestors;
|
|
623
|
+
for (const { head, contexts } of query.readings) {
|
|
624
|
+
let quality;
|
|
625
|
+
if (compare) {
|
|
626
|
+
quality = placeMatchQuality(names, head);
|
|
627
|
+
if (quality === 0)
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
if (!names.some((name) => foldDiacritics(name).includes(head.folded)))
|
|
632
|
+
continue;
|
|
633
|
+
quality = 0;
|
|
634
|
+
}
|
|
635
|
+
let context = 0;
|
|
636
|
+
if (contexts.length > 0) {
|
|
637
|
+
ancestors ??= await this.ancestorNames(id);
|
|
638
|
+
context = contextQuality(contexts, ancestors);
|
|
639
|
+
if (context === 0)
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const match = {
|
|
643
|
+
id,
|
|
644
|
+
level,
|
|
645
|
+
quality,
|
|
646
|
+
contextQuality: context,
|
|
647
|
+
rankArea: 0,
|
|
648
|
+
};
|
|
649
|
+
if (!compare)
|
|
650
|
+
return match;
|
|
651
|
+
if (!best || compare(match, best) < 0)
|
|
652
|
+
best = match;
|
|
653
|
+
}
|
|
654
|
+
return best;
|
|
655
|
+
}
|
|
656
|
+
/** The names of place `id`'s ancestors, nearest first: a city's region
|
|
657
|
+
* and country, a region's country, nothing for a country. */
|
|
658
|
+
async ancestorNames(id) {
|
|
659
|
+
const ids = this.placeIds(id);
|
|
660
|
+
const ancestors = [];
|
|
661
|
+
if (ids.level === "city")
|
|
662
|
+
ancestors.push(ids.region);
|
|
663
|
+
if (ids.level !== "country")
|
|
664
|
+
ancestors.push(ids.country);
|
|
665
|
+
const names = [];
|
|
666
|
+
for (const ancestor of ancestors)
|
|
667
|
+
names.push(this.loadedNames(ancestor) ?? (await this.loadNames(ancestor)));
|
|
668
|
+
return names;
|
|
669
|
+
}
|
|
670
|
+
/** `areaTieBreak` resolved to the area bytes to rank by, or undefined
|
|
671
|
+
* for Appendix B.5's plain order. */
|
|
672
|
+
async searchAreas(mode) {
|
|
673
|
+
if (mode === false)
|
|
674
|
+
return undefined;
|
|
675
|
+
const areas = await this.placeAreas();
|
|
676
|
+
if (!areas && mode === true) {
|
|
677
|
+
throw new CGeoDataError("areaTieBreak: true ranks by the areas in a locate index, and none is attached; build one with build-locate-index");
|
|
678
|
+
}
|
|
679
|
+
return areas;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The area byte (binary-format.md Section C.3) of every place, by id --
|
|
683
|
+
* the size of the place and everything under it, on a log scale, a
|
|
684
|
+
* quarter of a doubling per step -- read from the attached locate index
|
|
685
|
+
* (`useLocateIndex()`), or undefined without one. What
|
|
686
|
+
* `search({ sort: true })` breaks ties with (`SearchOptions.areaTieBreak`).
|
|
687
|
+
* Read once and then kept; the array is shared, so don't modify it.
|
|
688
|
+
*/
|
|
689
|
+
async placeAreas() {
|
|
690
|
+
return (await this.activeLocateIndex())?.areas();
|
|
691
|
+
}
|
|
692
|
+
/** Place `id`'s rank area (Appendix B.5.1, `regionRankArea`): its area
|
|
693
|
+
* byte, except for a region with a city of the same local name. That
|
|
694
|
+
* takes reading the region's city names, so it's worked out only for
|
|
695
|
+
* the regions a search actually matched, once each. */
|
|
696
|
+
async rankArea(id, level, areas) {
|
|
697
|
+
if (level !== "region")
|
|
698
|
+
return areas[id];
|
|
699
|
+
const C = this.placeTable.countryCount;
|
|
700
|
+
if (this.rankAreas?.areas !== areas) {
|
|
701
|
+
this.rankAreas = {
|
|
702
|
+
areas,
|
|
703
|
+
regions: new Int16Array(this.placeTable.totalRegions).fill(-1),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const { regions } = this.rankAreas;
|
|
707
|
+
const cached = regions[id - C];
|
|
708
|
+
if (cached >= 0)
|
|
709
|
+
return cached;
|
|
710
|
+
const own = this.loadedNames(id) ?? (await this.loadNames(id));
|
|
711
|
+
const [lo, hi] = this.idRange("city", { region: id });
|
|
712
|
+
const cities = [];
|
|
713
|
+
for (let city = lo; city < hi; city++) {
|
|
714
|
+
const names = this.loadedNames(city) ?? (await this.loadNames(city));
|
|
715
|
+
cities.push({ localName: names[0] ?? "", area: areas[city] });
|
|
716
|
+
}
|
|
717
|
+
const value = regionRankArea(own[0] ?? "", areas[id], cities);
|
|
718
|
+
regions[id - C] = value;
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
/** The ids (ascending) of `level` under the filter that `search()` has
|
|
722
|
+
* to check: the whole `idRange`, or, when a search index narrowed the
|
|
723
|
+
* query to `candidates` (ascending), just those within it. */
|
|
724
|
+
*searchScope(level, filter, candidates) {
|
|
725
|
+
const [start, end] = this.idRange(level, filter);
|
|
726
|
+
if (!candidates) {
|
|
727
|
+
for (let id = start; id < end; id++)
|
|
728
|
+
yield id;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
for (let i = lowerBound(candidates, start); i < candidates.length; i++) {
|
|
732
|
+
const id = candidates[i];
|
|
733
|
+
if (id >= end)
|
|
734
|
+
return;
|
|
735
|
+
yield id;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** The parsed records of one chunk (Section 8), or undefined when the
|
|
739
|
+
* chunk is empty. Cached like lookups, unless `cache` is false: a
|
|
740
|
+
* one-pass walk over every chunk should skip the cache, so it doesn't
|
|
741
|
+
* evict everything lookups had cached for chunks it only visits once. */
|
|
742
|
+
async chunk(index, { cache = true } = {}) {
|
|
743
|
+
if (!Number.isInteger(index) ||
|
|
744
|
+
index < 0 ||
|
|
745
|
+
index >= this.directory.chunkLengths.length) {
|
|
746
|
+
throw new RangeError(`chunk index ${index} is out of range for this file (0 <= index < ${this.directory.chunkLengths.length})`);
|
|
747
|
+
}
|
|
748
|
+
const loaded = this.loadedChunk(index);
|
|
749
|
+
return ((loaded === undefined ? await this.loadChunk(index, cache) : loaded) ??
|
|
750
|
+
undefined);
|
|
751
|
+
}
|
|
752
|
+
/** The lat/lon extent of a chunk (Section 6.1). */
|
|
753
|
+
chunkBoundingBox(index) {
|
|
754
|
+
const { width, height } = this.header;
|
|
755
|
+
return chunkBoundingBox(index % width, Math.floor(index / width), width, height);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* A representative lat/lon for a place id: the centre of the place's
|
|
759
|
+
* representative cell (binary-format.md Section C.3), a cell near the
|
|
760
|
+
* middle of the place's area -- its own cells plus those of every place
|
|
761
|
+
* under it -- that is guaranteed to lie in that area, so `query()` at
|
|
762
|
+
* the returned point resolves to the place or one of its descendants.
|
|
763
|
+
*
|
|
764
|
+
* @example
|
|
765
|
+
* ```ts
|
|
766
|
+
* const position = await reader.locate(place.id);
|
|
767
|
+
* if (position) map.setView([position.lat, position.lon]);
|
|
768
|
+
* ```
|
|
769
|
+
*
|
|
770
|
+
* Null if neither the place nor anything under it has any cells -- and
|
|
771
|
+
* that happens to real places, not only to broken files: a place
|
|
772
|
+
* smaller than the file's finest cell (a tiny island, an enclave, a
|
|
773
|
+
* boundary sliver) loses every cell to its neighbours. On a worldwide
|
|
774
|
+
* build that's about 0.15% of places, every one of them still found
|
|
775
|
+
* by `search()`; drop them from results that need a position rather
|
|
776
|
+
* than defaulting to a coordinate.
|
|
777
|
+
*
|
|
778
|
+
* With a locate index (`useLocateIndex()`) this reads one entry;
|
|
779
|
+
* without one it scans every chunk. Same result either way.
|
|
780
|
+
*/
|
|
781
|
+
async locate(id) {
|
|
782
|
+
this.checkPlaceId(id);
|
|
783
|
+
const index = await this.activeLocateIndex();
|
|
784
|
+
let found;
|
|
785
|
+
if (index) {
|
|
786
|
+
const cell = decodeLocateEntry(await index.entry(id), this.header);
|
|
787
|
+
found = cell && { cell, area: await index.area(id) };
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
found = await computeLocateCell(this, id);
|
|
791
|
+
}
|
|
792
|
+
if (!found)
|
|
793
|
+
return null;
|
|
794
|
+
const { cell, area } = found;
|
|
795
|
+
const box = this.recordBoundingBox(this.chunkBoundingBox(cell.chunkIndex), cell.code, cell.depth);
|
|
796
|
+
const { width, height, maxDepth } = this.header;
|
|
797
|
+
const cellDegrees = (360 / (width * 2 ** maxDepth)) * (180 / (height * 2 ** maxDepth));
|
|
798
|
+
return {
|
|
799
|
+
lat: (box.minLat + box.maxLat) / 2,
|
|
800
|
+
lon: (box.minLon + box.maxLon) / 2,
|
|
801
|
+
boundingBox: box,
|
|
802
|
+
// The middle of the byte's range, 2^((area - 1) / 4) up to
|
|
803
|
+
// 2^(area / 4) cells, on the same log scale.
|
|
804
|
+
area: area === 0 ? 0 : 2 ** ((area - 0.5) / 4) * cellDegrees,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The ids of place `id` and of every place under it, as half-open
|
|
809
|
+
* ranges: `[id, id + 1]` first, then for a country its regions and
|
|
810
|
+
* their cities, for a region its cities. Each level's children are
|
|
811
|
+
* contiguous (Section 7.1), so this is at most three ranges.
|
|
812
|
+
*/
|
|
813
|
+
subtreeIdRanges(id) {
|
|
814
|
+
const resolved = resolveIdentifier(this.placeTable, id);
|
|
815
|
+
const ranges = [[id, id + 1]];
|
|
816
|
+
if (resolved.level === "country") {
|
|
817
|
+
ranges.push(this.idRange("region", { country: id }));
|
|
818
|
+
ranges.push(this.idRange("city", { country: id }));
|
|
819
|
+
}
|
|
820
|
+
else if (resolved.level === "region") {
|
|
821
|
+
ranges.push(this.idRange("city", { region: id }));
|
|
822
|
+
}
|
|
823
|
+
return ranges;
|
|
824
|
+
}
|
|
825
|
+
/** One chunk's records with their cells and places resolved -- what a
|
|
826
|
+
* lookup in that chunk can answer with, laid out for inspection. */
|
|
827
|
+
async *chunkRecords(index) {
|
|
828
|
+
const chunk = await this.chunk(index);
|
|
829
|
+
if (!chunk)
|
|
830
|
+
return;
|
|
831
|
+
const chunkBox = this.chunkBoundingBox(index);
|
|
832
|
+
for (let j = 0; j < chunk.codes.length; j++) {
|
|
833
|
+
const code = chunk.codes[j];
|
|
834
|
+
const depth = chunk.depths[j];
|
|
835
|
+
const k = chunk.paletteIndices[j];
|
|
836
|
+
const placeId = k === 0 ? undefined : chunk.palette[k - 1];
|
|
837
|
+
yield {
|
|
838
|
+
code,
|
|
839
|
+
depth,
|
|
840
|
+
boundingBox: this.recordBoundingBox(chunkBox, code, depth),
|
|
841
|
+
place: placeId === undefined
|
|
842
|
+
? null
|
|
843
|
+
: (this.loadedPlace(placeId) ?? (await this.place(placeId))),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
recordBoundingBox(chunkBox, code, depth) {
|
|
848
|
+
const D = this.header.maxDepth;
|
|
849
|
+
const { x: fullX, y: fullY } = mortonDecode(code, D);
|
|
850
|
+
return cellBoundingBox(chunkBox, shiftRight(fullX, D - depth), shiftRight(fullY, D - depth), depth);
|
|
851
|
+
}
|
|
852
|
+
/** The half-open global id range of `level` under the filter's
|
|
853
|
+
* narrowest ancestor, via the Section 7.5 prefix arrays. */
|
|
854
|
+
idRange(level, filter) {
|
|
855
|
+
const { countryCount: C, totalRegions: R, totalPlaces, regionCountryPrefix, cityRegionPrefix, } = this.placeTable;
|
|
856
|
+
let { country } = filter;
|
|
857
|
+
const { region } = filter;
|
|
858
|
+
if (country !== undefined)
|
|
859
|
+
checkId(country, 0, C, "country");
|
|
860
|
+
if (region !== undefined) {
|
|
861
|
+
checkId(region, C, C + R, "region");
|
|
862
|
+
const owner = largestIndexWithPrefixAtMost(regionCountryPrefix, region - C);
|
|
863
|
+
if (country !== undefined && country !== owner)
|
|
864
|
+
return [0, 0]; // region isn't in that country
|
|
865
|
+
country = owner;
|
|
866
|
+
}
|
|
867
|
+
// Region-local index range under the narrowest ancestor, then the
|
|
868
|
+
// level's own ids from there.
|
|
869
|
+
const regionLo = region !== undefined
|
|
870
|
+
? region - C
|
|
871
|
+
: country !== undefined
|
|
872
|
+
? regionCountryPrefix[country]
|
|
873
|
+
: 0;
|
|
874
|
+
const regionHi = region !== undefined
|
|
875
|
+
? region - C + 1
|
|
876
|
+
: country !== undefined
|
|
877
|
+
? regionCountryPrefix[country + 1]
|
|
878
|
+
: R;
|
|
879
|
+
switch (level) {
|
|
880
|
+
case "country":
|
|
881
|
+
return country !== undefined ? [country, country + 1] : [0, C];
|
|
882
|
+
case "region":
|
|
883
|
+
return [C + regionLo, C + regionHi];
|
|
884
|
+
case "city":
|
|
885
|
+
return [
|
|
886
|
+
Math.min(C + R + cityRegionPrefix[regionLo], totalPlaces),
|
|
887
|
+
Math.min(C + R + cityRegionPrefix[regionHi], totalPlaces),
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
checkPlaceId(id) {
|
|
892
|
+
const { totalPlaces } = this.placeTable;
|
|
893
|
+
if (!Number.isInteger(id) || id < 0 || id >= totalPlaces) {
|
|
894
|
+
throw new RangeError(`${id} is not a place id for this file (0 <= id < ${totalPlaces})`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/** A chunk without reading anything: its records when decoded and
|
|
898
|
+
* cached, null when it's empty, undefined when it would need a read. */
|
|
899
|
+
loadedChunk(index) {
|
|
900
|
+
if (this.directory.chunkLengths[index] === 0)
|
|
901
|
+
return null;
|
|
902
|
+
if (index === this.lastChunkIndex)
|
|
903
|
+
return this.lastChunk;
|
|
904
|
+
const cached = this.cache.get(index);
|
|
905
|
+
if (cached) {
|
|
906
|
+
this.lastChunkIndex = index;
|
|
907
|
+
this.lastChunk = cached;
|
|
908
|
+
}
|
|
909
|
+
return cached;
|
|
910
|
+
}
|
|
911
|
+
/** Reads and decodes a chunk, sharing one decode among concurrent
|
|
912
|
+
* callers. */
|
|
913
|
+
loadChunk(index, cache) {
|
|
914
|
+
if (!cache)
|
|
915
|
+
return this.decodeChunk(index);
|
|
916
|
+
const inFlight = this.loading.get(index);
|
|
917
|
+
if (inFlight)
|
|
918
|
+
return inFlight;
|
|
919
|
+
const load = this.decodeChunk(index)
|
|
920
|
+
.then((chunk) => {
|
|
921
|
+
this.cache.set(index, chunk, chunkBytes(chunk));
|
|
922
|
+
this.lastChunkIndex = index;
|
|
923
|
+
this.lastChunk = chunk;
|
|
924
|
+
return chunk;
|
|
925
|
+
})
|
|
926
|
+
.finally(() => this.loading.delete(index));
|
|
927
|
+
this.loading.set(index, load);
|
|
928
|
+
return load;
|
|
929
|
+
}
|
|
930
|
+
async decodeChunk(index) {
|
|
931
|
+
const compressed = await this.source.read(this.directory.chunkOffsets[index], this.directory.chunkLengths[index]);
|
|
932
|
+
return this.decodes.run(async () => this.checkPalette(index, parseChunk(
|
|
933
|
+
// Version 2 chunks are range coded, not zlib (Section 8.7).
|
|
934
|
+
this.header.version >= 2
|
|
935
|
+
? compressed
|
|
936
|
+
: await inflateBlock(this.inflater, compressed), this.header.maxDepth, this.header.version)));
|
|
937
|
+
}
|
|
938
|
+
checkPalette(index, chunk) {
|
|
939
|
+
// Section 11: "every palette entry [MUST be] less than N".
|
|
940
|
+
// `parseChunk` can't check that -- it has no place table -- and this
|
|
941
|
+
// is the one place that has both, so a corrupt palette fails here
|
|
942
|
+
// with a format error naming the chunk, instead of surfacing much
|
|
943
|
+
// later as an out-of-range place id (`chunkRecords`) or as a bare
|
|
944
|
+
// TypeError on an undefined lookup (`buildLocateIndex`'s
|
|
945
|
+
// `countryOf[placeId]`).
|
|
946
|
+
const { totalPlaces } = this.placeTable;
|
|
947
|
+
for (const placeId of chunk.palette) {
|
|
948
|
+
if (placeId >= totalPlaces) {
|
|
949
|
+
throw new CGeoDataFormatError(`chunk ${index}'s palette references place ${placeId}, beyond this file's ${totalPlaces} places`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return chunk;
|
|
953
|
+
}
|
|
954
|
+
/** A place's names when its name block is cached, else undefined. */
|
|
955
|
+
loadedNames(globalId) {
|
|
956
|
+
const blockIndex = Math.floor(globalId / this.nameBlockSize);
|
|
957
|
+
const block = this.cache.get(-1 - blockIndex);
|
|
958
|
+
return block && this.entryOf(block, blockIndex, globalId);
|
|
959
|
+
}
|
|
960
|
+
/** Reads and decodes a place's name block (sharing one read among
|
|
961
|
+
* concurrent callers), and returns its names. */
|
|
962
|
+
async loadNames(globalId) {
|
|
963
|
+
const blockIndex = Math.floor(globalId / this.nameBlockSize);
|
|
964
|
+
const key = -1 - blockIndex;
|
|
965
|
+
let load = this.loading.get(key);
|
|
966
|
+
if (!load) {
|
|
967
|
+
load = readNameBlock(this.source, this.placeTable, blockIndex, this.inflater, (work) => this.decodes.run(work))
|
|
968
|
+
.then((block) => {
|
|
969
|
+
this.cache.set(key, block, nameBlockBytes(block));
|
|
970
|
+
return block;
|
|
971
|
+
})
|
|
972
|
+
.finally(() => this.loading.delete(key));
|
|
973
|
+
this.loading.set(key, load);
|
|
974
|
+
}
|
|
975
|
+
return this.entryOf(await load, blockIndex, globalId);
|
|
976
|
+
}
|
|
977
|
+
get nameBlockSize() {
|
|
978
|
+
const { keyframeExp, totalPlaces } = this.placeTable;
|
|
979
|
+
return keyframeExp === 0 ? totalPlaces : 2 ** keyframeExp;
|
|
980
|
+
}
|
|
981
|
+
entryOf(block, blockIndex, globalId) {
|
|
982
|
+
const entry = block[globalId - blockIndex * this.nameBlockSize];
|
|
983
|
+
if (!entry)
|
|
984
|
+
throw new CGeoDataFormatError(`name block ${blockIndex} ended before reaching place ${globalId}`);
|
|
985
|
+
return entry;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const LEVELS = ["country", "region", "city"];
|
|
989
|
+
function pendingSlot(option) {
|
|
990
|
+
return {
|
|
991
|
+
index: undefined,
|
|
992
|
+
owned: false,
|
|
993
|
+
activating: undefined,
|
|
994
|
+
pending: option === undefined
|
|
995
|
+
? undefined
|
|
996
|
+
: typeof option === "function"
|
|
997
|
+
? option
|
|
998
|
+
: () => Promise.resolve(option),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/** What an index's errors are prefixed with, if anything names it. */
|
|
1002
|
+
function indexName(input) {
|
|
1003
|
+
const name = input?.name;
|
|
1004
|
+
return typeof name === "string" ? name : undefined;
|
|
1005
|
+
}
|
|
1006
|
+
function assemblePlace(id, ids, country, region, city) {
|
|
1007
|
+
const countryRef = placeRef(ids.country, country);
|
|
1008
|
+
const regionRef = ids.region === null ? null : placeRef(ids.region, region);
|
|
1009
|
+
const cityRef = ids.city === null ? null : placeRef(ids.city, city);
|
|
1010
|
+
const own = cityRef ?? regionRef ?? countryRef;
|
|
1011
|
+
return {
|
|
1012
|
+
id,
|
|
1013
|
+
localName: own.localName,
|
|
1014
|
+
internationalName: own.internationalName,
|
|
1015
|
+
names: own.names,
|
|
1016
|
+
level: ids.level,
|
|
1017
|
+
country: countryRef,
|
|
1018
|
+
region: regionRef,
|
|
1019
|
+
city: cityRef,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
function placeRef(id, names) {
|
|
1023
|
+
const localName = names[0] ?? "";
|
|
1024
|
+
return {
|
|
1025
|
+
id,
|
|
1026
|
+
localName,
|
|
1027
|
+
internationalName: names[1] || localName,
|
|
1028
|
+
names,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/** A decoded chunk's size, for the cache budget. */
|
|
1032
|
+
function chunkBytes(chunk) {
|
|
1033
|
+
return (chunk.codes.byteLength +
|
|
1034
|
+
chunk.depths.byteLength +
|
|
1035
|
+
chunk.paletteIndices.byteLength +
|
|
1036
|
+
chunk.palette.length * 8);
|
|
1037
|
+
}
|
|
1038
|
+
/** A rough size of a decoded name block, for the cache budget: two bytes
|
|
1039
|
+
* per character (V8's worst case), plus per-string and per-array
|
|
1040
|
+
* overhead. */
|
|
1041
|
+
function nameBlockBytes(block) {
|
|
1042
|
+
let bytes = 0;
|
|
1043
|
+
for (const entry of block) {
|
|
1044
|
+
bytes += 32;
|
|
1045
|
+
for (const name of entry)
|
|
1046
|
+
bytes += 24 + 2 * name.length;
|
|
1047
|
+
}
|
|
1048
|
+
return bytes;
|
|
1049
|
+
}
|
|
1050
|
+
function checkId(id, lo, hi, what) {
|
|
1051
|
+
if (!Number.isInteger(id) || id < lo || id >= hi) {
|
|
1052
|
+
throw new RangeError(`${id} is not a ${what} id for this file (${what} ids are ${lo} <= id < ${hi})`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/** `error` with `prefix` and `suffix` added to its message, keeping its
|
|
1056
|
+
* class -- so a stale index next to the file still throws a
|
|
1057
|
+
* `CGeoDataIndexMismatchError`, and says which file it was. */
|
|
1058
|
+
function withContext(error, prefix, suffix) {
|
|
1059
|
+
if (!(error instanceof Error))
|
|
1060
|
+
return error;
|
|
1061
|
+
const message = `${prefix}${error.message}${suffix}`;
|
|
1062
|
+
if (error.stack)
|
|
1063
|
+
error.stack = error.stack.replace(error.message, message);
|
|
1064
|
+
error.message = message;
|
|
1065
|
+
return error;
|
|
1066
|
+
}
|
|
1067
|
+
function unknownResult(boundingBox, chunkIndex) {
|
|
1068
|
+
return { placeId: null, boundingBox, chunkIndex };
|
|
1069
|
+
}
|
|
1070
|
+
/** The union of two ascending id lists, ascending, without duplicates. */
|
|
1071
|
+
function unionSorted(a, b) {
|
|
1072
|
+
const out = [];
|
|
1073
|
+
let i = 0;
|
|
1074
|
+
let j = 0;
|
|
1075
|
+
while (i < a.length || j < b.length) {
|
|
1076
|
+
const x = i < a.length ? a[i] : Infinity;
|
|
1077
|
+
const y = j < b.length ? b[j] : Infinity;
|
|
1078
|
+
out.push(Math.min(x, y));
|
|
1079
|
+
if (x <= y)
|
|
1080
|
+
i++;
|
|
1081
|
+
if (y <= x)
|
|
1082
|
+
j++;
|
|
1083
|
+
}
|
|
1084
|
+
return out;
|
|
1085
|
+
}
|
|
1086
|
+
/** The first index of the ascending `sorted` whose value is >= `value`. */
|
|
1087
|
+
function lowerBound(sorted, value) {
|
|
1088
|
+
let lo = 0;
|
|
1089
|
+
let hi = sorted.length;
|
|
1090
|
+
while (lo < hi) {
|
|
1091
|
+
const mid = (lo + hi) >>> 1;
|
|
1092
|
+
if (sorted[mid] < value)
|
|
1093
|
+
lo = mid + 1;
|
|
1094
|
+
else
|
|
1095
|
+
hi = mid;
|
|
1096
|
+
}
|
|
1097
|
+
return lo;
|
|
1098
|
+
}
|
|
1099
|
+
/** bisect_right(sorted, value) - 1, per Section 9 step 6. */
|
|
1100
|
+
function bisectRightMinusOne(sorted, value) {
|
|
1101
|
+
let lo = 0;
|
|
1102
|
+
let hi = sorted.length;
|
|
1103
|
+
while (lo < hi) {
|
|
1104
|
+
const mid = (lo + hi) >>> 1;
|
|
1105
|
+
if (sorted[mid] <= value)
|
|
1106
|
+
lo = mid + 1;
|
|
1107
|
+
else
|
|
1108
|
+
hi = mid;
|
|
1109
|
+
}
|
|
1110
|
+
return lo - 1;
|
|
1111
|
+
}
|
|
1112
|
+
/** FNV-1a 64-bit hash per binary-format.md Section B.6.
|
|
1113
|
+
* offset_basis = 0xcbf29ce484222325, prime = 0x100000001b3.
|
|
1114
|
+
*
|
|
1115
|
+
* Done in two 32-bit halves rather than with BigInt, which costs ~100ms
|
|
1116
|
+
* on a world-sized place table: prime = 2^40 + 0x1b3, so
|
|
1117
|
+
* h * prime mod 2^64 = h * 0x1b3 + (lo << 40), and every intermediate
|
|
1118
|
+
* below stays under 2^53. */
|
|
1119
|
+
function fnv1a64(data) {
|
|
1120
|
+
let hi = 0xcbf29ce4;
|
|
1121
|
+
let lo = 0x84222325;
|
|
1122
|
+
for (let i = 0; i < data.length; i++) {
|
|
1123
|
+
lo ^= data[i];
|
|
1124
|
+
const loProduct = (lo >>> 0) * 0x1b3;
|
|
1125
|
+
hi =
|
|
1126
|
+
(hi * 0x1b3 + Math.floor(loProduct / 0x100000000) + ((lo << 8) >>> 0)) >>>
|
|
1127
|
+
0;
|
|
1128
|
+
lo = loProduct >>> 0;
|
|
1129
|
+
}
|
|
1130
|
+
return (BigInt(hi) << 32n) | BigInt(lo);
|
|
1131
|
+
}
|
|
1132
|
+
//# sourceMappingURL=reader.js.map
|