@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -2
  3. package/binary-format.md +2620 -0
  4. package/dist/cache.d.ts +24 -0
  5. package/dist/cache.js +60 -0
  6. package/dist/cellBounds.d.ts +19 -0
  7. package/dist/cellBounds.js +36 -0
  8. package/dist/chunk.d.ts +35 -0
  9. package/dist/chunk.js +277 -0
  10. package/dist/chunkDecoder.d.ts +39 -0
  11. package/dist/chunkDecoder.js +116 -0
  12. package/dist/chunkDecoderWasm.d.ts +2 -0
  13. package/dist/chunkDecoderWasm.js +4 -0
  14. package/dist/cli/buildLocateIndex.d.ts +2 -0
  15. package/dist/cli/buildLocateIndex.js +46 -0
  16. package/dist/cli/buildSearchIndex.d.ts +2 -0
  17. package/dist/cli/buildSearchIndex.js +90 -0
  18. package/dist/cli/chunk.d.ts +2 -0
  19. package/dist/cli/chunk.js +67 -0
  20. package/dist/cli/info.d.ts +2 -0
  21. package/dist/cli/info.js +87 -0
  22. package/dist/cli/list.d.ts +2 -0
  23. package/dist/cli/list.js +71 -0
  24. package/dist/cli/locate.d.ts +2 -0
  25. package/dist/cli/locate.js +64 -0
  26. package/dist/cli/lookup.d.ts +2 -0
  27. package/dist/cli/lookup.js +128 -0
  28. package/dist/cli/search.d.ts +2 -0
  29. package/dist/cli/search.js +119 -0
  30. package/dist/cli/shared.d.ts +50 -0
  31. package/dist/cli/shared.js +140 -0
  32. package/dist/cli/show.d.ts +2 -0
  33. package/dist/cli/show.js +38 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +107 -0
  36. package/dist/container.d.ts +17 -0
  37. package/dist/container.js +68 -0
  38. package/dist/directory.d.ts +21 -0
  39. package/dist/directory.js +31 -0
  40. package/dist/errors.d.ts +52 -0
  41. package/dist/errors.js +56 -0
  42. package/dist/format.d.ts +22 -0
  43. package/dist/format.js +23 -0
  44. package/dist/header.d.ts +30 -0
  45. package/dist/header.js +69 -0
  46. package/dist/index.d.ts +20 -0
  47. package/dist/index.js +18 -0
  48. package/dist/indexes.d.ts +11 -0
  49. package/dist/indexes.js +12 -0
  50. package/dist/inflate.d.ts +21 -0
  51. package/dist/inflate.js +61 -0
  52. package/dist/locateIndex.d.ts +142 -0
  53. package/dist/locateIndex.js +561 -0
  54. package/dist/morton.d.ts +28 -0
  55. package/dist/morton.js +51 -0
  56. package/dist/node.d.ts +65 -0
  57. package/dist/node.js +137 -0
  58. package/dist/placeTable.d.ts +69 -0
  59. package/dist/placeTable.js +177 -0
  60. package/dist/rangeChunk.d.ts +46 -0
  61. package/dist/rangeChunk.js +401 -0
  62. package/dist/rangeDecoder.d.ts +17 -0
  63. package/dist/rangeDecoder.js +129 -0
  64. package/dist/rangeDecoderWasm.d.ts +2 -0
  65. package/dist/rangeDecoderWasm.js +4 -0
  66. package/dist/reader.d.ts +534 -0
  67. package/dist/reader.js +1132 -0
  68. package/dist/relevance.d.ts +184 -0
  69. package/dist/relevance.js +266 -0
  70. package/dist/searchIndex.d.ts +148 -0
  71. package/dist/searchIndex.js +786 -0
  72. package/dist/source.d.ts +49 -0
  73. package/dist/source.js +50 -0
  74. package/dist/turns.d.ts +19 -0
  75. package/dist/turns.js +40 -0
  76. package/dist/varint.d.ts +52 -0
  77. package/dist/varint.js +137 -0
  78. 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