@ferrybig/cgeodata-reader 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +313 -2
- package/binary-format.md +2366 -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 +34 -0
- package/dist/chunk.js +269 -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 +21 -0
- package/dist/format.js +22 -0
- package/dist/header.d.ts +29 -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/reader.d.ts +534 -0
- package/dist/reader.js +1128 -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
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigram search index (Appendix B of binary-format.md): a sidecar file
|
|
3
|
+
* that lets `CGeoDataReader.search()` confirm only the places whose names
|
|
4
|
+
* could contain the query, instead of scanning every name block.
|
|
5
|
+
*
|
|
6
|
+
* The file is CGEODATA kind 1, version 3 (stored `version` varint 19). It
|
|
7
|
+
* holds, for every place of its source:
|
|
8
|
+
* - one list of place ids per trigram of folded symbols (Section B.3):
|
|
9
|
+
* exact lists for Latin trigrams, hashed ones for the rest, each an
|
|
10
|
+
* id list or a bitmap, whichever is smaller (Section B.4);
|
|
11
|
+
* - a short-query table (Section B.6): the top places, in relevance
|
|
12
|
+
* order, for every 1-2 character query that has any match -- in the
|
|
13
|
+
* plain order of Section B.5 or the area order of Section B.5.1, as
|
|
14
|
+
* its header's `relevance_rule` says.
|
|
15
|
+
* Nothing is compressed, and the header records every section's length,
|
|
16
|
+
* so a reader reads the header once and afterwards only the lists or the
|
|
17
|
+
* table block a query needs, each cached behind a WeakRef.
|
|
18
|
+
*/
|
|
19
|
+
import { CGeoDataFormatError, CGeoDataVersionError } from "./errors.js";
|
|
20
|
+
import { compareAreaRelevance, compareRelevance, foldDiacritics, nameMatchQuality, prepareNeedle, QUALITY_MAX, regionRankArea, } from "./relevance.js";
|
|
21
|
+
import { toSource } from "./source.js";
|
|
22
|
+
import { readVarint, writeVarint } from "./varint.js";
|
|
23
|
+
// ── Symbol folding (Section B.2) ──────────────────────────────────────────────
|
|
24
|
+
/** Symbol 26: every ASCII character other than a letter or digit. */
|
|
25
|
+
const SEPARATOR = 26;
|
|
26
|
+
/** Symbol 27: every ASCII digit. */
|
|
27
|
+
const DIGIT = 27;
|
|
28
|
+
/** The folded form (`foldDiacritics`: NFD, drop combining diacritics,
|
|
29
|
+
* lowercase), then one symbol per code point: a-z -> 0-25, 0-9 -> 27,
|
|
30
|
+
* other ASCII -> 26, everything from U+0080 up -> its own code point.
|
|
31
|
+
* Section B.2 defines the symbol sequence as *continuing from* the folded
|
|
32
|
+
* form, so this must reuse `foldDiacritics` rather than repeat it -- two
|
|
33
|
+
* copies of the first three steps could drift, and the index's
|
|
34
|
+
* never-lose-a-match guarantee (Section B.9) rests on them being
|
|
35
|
+
* identical. */
|
|
36
|
+
export function foldSymbols(text) {
|
|
37
|
+
const symbols = [];
|
|
38
|
+
for (const ch of foldDiacritics(text)) {
|
|
39
|
+
const cp = ch.codePointAt(0);
|
|
40
|
+
if (cp >= 0x61 && cp <= 0x7a)
|
|
41
|
+
symbols.push(cp - 0x61);
|
|
42
|
+
else if (cp >= 0x30 && cp <= 0x39)
|
|
43
|
+
symbols.push(DIGIT);
|
|
44
|
+
else if (cp < 0x80)
|
|
45
|
+
symbols.push(SEPARATOR);
|
|
46
|
+
else
|
|
47
|
+
symbols.push(cp);
|
|
48
|
+
}
|
|
49
|
+
return symbols;
|
|
50
|
+
}
|
|
51
|
+
// ── Trigrams (Section B.3) ────────────────────────────────────────────────────
|
|
52
|
+
/** 28^3: every trigram of Latin symbols (0-27) has a list of its own. */
|
|
53
|
+
const LATIN_LISTS = 28 ** 3;
|
|
54
|
+
/** 32-bit FNV-1a. */
|
|
55
|
+
export function fnv1a32(bytes) {
|
|
56
|
+
let h = 0x811c9dc5;
|
|
57
|
+
for (let i = 0; i < bytes.length; i++)
|
|
58
|
+
h = Math.imul(h ^ bytes[i], 0x01000193);
|
|
59
|
+
return h >>> 0;
|
|
60
|
+
}
|
|
61
|
+
/** The list index of trigram `(a, b, c)`: exact for Latin symbols,
|
|
62
|
+
* otherwise FNV-1a 32 of the three symbols as u24s, xor-folded to
|
|
63
|
+
* `hashExp` bits. */
|
|
64
|
+
export function trigramListIndex(a, b, c, hashExp) {
|
|
65
|
+
if (a < 28 && b < 28 && c < 28)
|
|
66
|
+
return a * 784 + b * 28 + c;
|
|
67
|
+
let h = 0x811c9dc5;
|
|
68
|
+
for (const s of [a, b, c]) {
|
|
69
|
+
h = Math.imul(h ^ (s & 0xff), 0x01000193);
|
|
70
|
+
h = Math.imul(h ^ ((s >>> 8) & 0xff), 0x01000193);
|
|
71
|
+
h = Math.imul(h ^ ((s >>> 16) & 0xff), 0x01000193);
|
|
72
|
+
}
|
|
73
|
+
h >>>= 0;
|
|
74
|
+
return LATIN_LISTS + ((((h >>> hashExp) ^ h) >>> 0) % 2 ** hashExp);
|
|
75
|
+
}
|
|
76
|
+
/** The distinct list indices of `text`'s trigrams, in order of first
|
|
77
|
+
* occurrence; empty when it folds to fewer than 3 symbols. */
|
|
78
|
+
export function trigramListIndices(text, hashExp) {
|
|
79
|
+
const s = foldSymbols(text);
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (let i = 0; i + 3 <= s.length; i++) {
|
|
82
|
+
seen.add(trigramListIndex(s[i], s[i + 1], s[i + 2], hashExp));
|
|
83
|
+
}
|
|
84
|
+
return [...seen];
|
|
85
|
+
}
|
|
86
|
+
// ── File format (Section B.7) ─────────────────────────────────────────────────
|
|
87
|
+
// "CGEODATA" -- same signature as the main data file.
|
|
88
|
+
const SIGNATURE = Uint8Array.from([
|
|
89
|
+
0x43, 0x47, 0x45, 0x4f, 0x44, 0x41, 0x54, 0x41,
|
|
90
|
+
]);
|
|
91
|
+
const KIND_SEARCH_INDEX = 1;
|
|
92
|
+
// Version 2: relevance order (Section B.5) folds diacritics and reverses
|
|
93
|
+
// the per-level bonus to favor country over city -- a version-1 file's
|
|
94
|
+
// short-query table was built under the old, un-folded rule and would
|
|
95
|
+
// silently rank or match differently under new code, with nothing in its
|
|
96
|
+
// bytes to tell a reader it's stale (Section B.8's checksum only covers
|
|
97
|
+
// the source place table, not the ranking rule an index was built with).
|
|
98
|
+
// Version 3 records that rule in `relevance_rule`, since a table can now
|
|
99
|
+
// be built in either of two orders.
|
|
100
|
+
const FORMAT_VERSION = 3;
|
|
101
|
+
const STORED_VERSION = KIND_SEARCH_INDEX * 16 + FORMAT_VERSION; // 19
|
|
102
|
+
/** `relevance_rule` of a short-query table in Section B.5's plain order. */
|
|
103
|
+
export const RELEVANCE_PLAIN = 0;
|
|
104
|
+
/** `relevance_rule` of a short-query table in Section B.5.1's area order. */
|
|
105
|
+
export const RELEVANCE_AREA = 1;
|
|
106
|
+
const ENCODING_ID_LIST = 0;
|
|
107
|
+
const ENCODING_BITMAP = 1;
|
|
108
|
+
const MAX_EXP = 24;
|
|
109
|
+
/** signature + version + checksum + place_count + 4 u8s + 3 lengths,
|
|
110
|
+
* with every varint at its 8-byte maximum. */
|
|
111
|
+
const MAX_HEADER_LENGTH = 8 + 8 + 8 + 8 + 4 + 3 * 8;
|
|
112
|
+
/** Appendix B.11's recommended parameters. */
|
|
113
|
+
export const DEFAULT_SEARCH_INDEX_OPTIONS = {
|
|
114
|
+
hashExp: 15,
|
|
115
|
+
topCount: 5,
|
|
116
|
+
topBlockExp: 6,
|
|
117
|
+
areaTieBreak: "auto",
|
|
118
|
+
};
|
|
119
|
+
const utf8Encoder = new TextEncoder();
|
|
120
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
121
|
+
function compareBytes(a, b) {
|
|
122
|
+
const n = Math.min(a.length, b.length);
|
|
123
|
+
for (let i = 0; i < n; i++)
|
|
124
|
+
if (a[i] !== b[i])
|
|
125
|
+
return a[i] - b[i];
|
|
126
|
+
return a.length - b.length;
|
|
127
|
+
}
|
|
128
|
+
/** A growable byte buffer, for assembling a file far larger than a
|
|
129
|
+
* `number[]` should hold. */
|
|
130
|
+
class ByteSink {
|
|
131
|
+
buf = new Uint8Array(1024);
|
|
132
|
+
length = 0;
|
|
133
|
+
reserve(extra) {
|
|
134
|
+
if (this.length + extra <= this.buf.length)
|
|
135
|
+
return;
|
|
136
|
+
let size = this.buf.length * 2;
|
|
137
|
+
while (size < this.length + extra)
|
|
138
|
+
size *= 2;
|
|
139
|
+
const grown = new Uint8Array(size);
|
|
140
|
+
grown.set(this.buf.subarray(0, this.length));
|
|
141
|
+
this.buf = grown;
|
|
142
|
+
}
|
|
143
|
+
byte(value) {
|
|
144
|
+
this.reserve(1);
|
|
145
|
+
this.buf[this.length++] = value;
|
|
146
|
+
}
|
|
147
|
+
bytes(values) {
|
|
148
|
+
this.reserve(values.length);
|
|
149
|
+
this.buf.set(values, this.length);
|
|
150
|
+
this.length += values.length;
|
|
151
|
+
}
|
|
152
|
+
varint(value) {
|
|
153
|
+
this.bytes(writeVarint(value));
|
|
154
|
+
}
|
|
155
|
+
toBytes() {
|
|
156
|
+
return this.buf.slice(0, this.length);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ── Builder ───────────────────────────────────────────────────────────────────
|
|
160
|
+
/** Inserts into a top-`k` list kept in `compare`'s order. */
|
|
161
|
+
function pushTop(top, match, k, compare) {
|
|
162
|
+
if (top.length === k && compare(match, top[k - 1]) >= 0)
|
|
163
|
+
return;
|
|
164
|
+
let i = top.length;
|
|
165
|
+
while (i > 0 && compare(match, top[i - 1]) < 0)
|
|
166
|
+
i--;
|
|
167
|
+
top.splice(i, 0, match);
|
|
168
|
+
if (top.length > k)
|
|
169
|
+
top.pop();
|
|
170
|
+
}
|
|
171
|
+
/** Builds a search index (Appendix B) over every place in `reader` and
|
|
172
|
+
* returns the file's bytes. A pure function of the place table and the
|
|
173
|
+
* options: the same input always gives the same bytes. */
|
|
174
|
+
export async function buildSearchIndex(reader, options = {}) {
|
|
175
|
+
const { hashExp, topCount, topBlockExp, areaTieBreak } = {
|
|
176
|
+
...DEFAULT_SEARCH_INDEX_OPTIONS,
|
|
177
|
+
...options,
|
|
178
|
+
};
|
|
179
|
+
if (!(Number.isInteger(hashExp) && hashExp >= 1 && hashExp <= MAX_EXP)) {
|
|
180
|
+
throw new RangeError(`hashExp must be an integer from 1 to ${MAX_EXP}, not ${hashExp}`);
|
|
181
|
+
}
|
|
182
|
+
if (!(Number.isInteger(topCount) && topCount >= 0 && topCount <= 255)) {
|
|
183
|
+
throw new RangeError(`topCount must be an integer from 0 to 255, not ${topCount}`);
|
|
184
|
+
}
|
|
185
|
+
if (!(Number.isInteger(topBlockExp) &&
|
|
186
|
+
topBlockExp >= 0 &&
|
|
187
|
+
topBlockExp <= MAX_EXP)) {
|
|
188
|
+
throw new RangeError(`topBlockExp must be an integer from 0 to ${MAX_EXP}, not ${topBlockExp}`);
|
|
189
|
+
}
|
|
190
|
+
const { countries, regions, cities } = reader.placeCounts;
|
|
191
|
+
const N = countries + regions + cities;
|
|
192
|
+
const levelOf = (id) => id < countries ? "country" : id < countries + regions ? "region" : "city";
|
|
193
|
+
const found = areaTieBreak === false ? undefined : await reader.placeAreas();
|
|
194
|
+
if (areaTieBreak === true && !found) {
|
|
195
|
+
throw new RangeError("areaTieBreak true ranks by the areas in a locate index, and the reader has none");
|
|
196
|
+
}
|
|
197
|
+
// Only the short-query table is ranked; without one, there's nothing to
|
|
198
|
+
// rank by area, and no reason to bind the index to the chunks.
|
|
199
|
+
const areas = topCount > 0 ? found : undefined;
|
|
200
|
+
const rule = areas ? RELEVANCE_AREA : RELEVANCE_PLAIN;
|
|
201
|
+
const compare = areas ? compareAreaRelevance : compareRelevance;
|
|
202
|
+
const rankAreas = areas ? await placeRankAreas(reader, areas) : undefined;
|
|
203
|
+
const postings = new Map();
|
|
204
|
+
const table = new Map();
|
|
205
|
+
const keyNeedles = new Map();
|
|
206
|
+
for (let id = 0; id < N; id++) {
|
|
207
|
+
const names = await reader.placeNames(id);
|
|
208
|
+
const lists = new Set();
|
|
209
|
+
const keyQuality = new Map();
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
for (const l of trigramListIndices(name, hashExp))
|
|
212
|
+
lists.add(l);
|
|
213
|
+
if (topCount === 0)
|
|
214
|
+
continue;
|
|
215
|
+
// Every 1-2 code point substring of the name's folded form is a
|
|
216
|
+
// key (Section B.6); grade it exactly as a live query of that key
|
|
217
|
+
// would be -- `nameMatchQuality` is the same function `search()`
|
|
218
|
+
// uses, so the table can never rank differently than a live
|
|
219
|
+
// search over the same data would.
|
|
220
|
+
const folded = foldDiacritics(name);
|
|
221
|
+
const cps = Array.from(folded);
|
|
222
|
+
for (let i = 0; i < cps.length; i++) {
|
|
223
|
+
for (let len = 1; len <= 2 && i + len <= cps.length; len++) {
|
|
224
|
+
const key = len === 1 ? cps[i] : cps[i] + cps[i + 1];
|
|
225
|
+
if ((keyQuality.get(key) ?? 0) >= QUALITY_MAX)
|
|
226
|
+
continue;
|
|
227
|
+
let needle = keyNeedles.get(key);
|
|
228
|
+
if (!needle) {
|
|
229
|
+
needle = prepareNeedle(key);
|
|
230
|
+
keyNeedles.set(key, needle);
|
|
231
|
+
}
|
|
232
|
+
const quality = nameMatchQuality(name, needle);
|
|
233
|
+
if (quality > (keyQuality.get(key) ?? 0))
|
|
234
|
+
keyQuality.set(key, quality);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const l of lists) {
|
|
239
|
+
const list = postings.get(l);
|
|
240
|
+
if (list)
|
|
241
|
+
list.push(id);
|
|
242
|
+
else
|
|
243
|
+
postings.set(l, [id]);
|
|
244
|
+
}
|
|
245
|
+
const level = levelOf(id);
|
|
246
|
+
const rankArea = rankAreas?.[id] ?? 0;
|
|
247
|
+
for (const [key, quality] of keyQuality) {
|
|
248
|
+
let top = table.get(key);
|
|
249
|
+
if (!top) {
|
|
250
|
+
top = [];
|
|
251
|
+
table.set(key, top);
|
|
252
|
+
}
|
|
253
|
+
pushTop(top, { id, level, quality, contextQuality: 0, rankArea }, topCount, compare);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Lists (Sections B.4, B.7).
|
|
257
|
+
const listCount = LATIN_LISTS + 2 ** hashExp;
|
|
258
|
+
const bitmapLength = Math.ceil(N / 8);
|
|
259
|
+
const presence = new Uint8Array(Math.ceil(listCount / 8));
|
|
260
|
+
const entries = new ByteSink();
|
|
261
|
+
const lists = new ByteSink();
|
|
262
|
+
for (const l of [...postings.keys()].sort((a, b) => a - b)) {
|
|
263
|
+
const ids = postings.get(l);
|
|
264
|
+
const idList = new ByteSink();
|
|
265
|
+
let prev = 0;
|
|
266
|
+
for (const id of ids) {
|
|
267
|
+
idList.varint(id - prev);
|
|
268
|
+
prev = id;
|
|
269
|
+
}
|
|
270
|
+
presence[l >> 3] |= 1 << (l & 7);
|
|
271
|
+
if (idList.length <= bitmapLength) {
|
|
272
|
+
entries.varint(idList.length * 2 + ENCODING_ID_LIST);
|
|
273
|
+
lists.bytes(idList.toBytes());
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const bitmap = new Uint8Array(bitmapLength);
|
|
277
|
+
for (const id of ids)
|
|
278
|
+
bitmap[id >> 3] |= 1 << (id & 7);
|
|
279
|
+
entries.varint(bitmapLength * 2 + ENCODING_BITMAP);
|
|
280
|
+
lists.bytes(bitmap);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Short-query table (Section B.6).
|
|
284
|
+
const tableDirectory = new ByteSink();
|
|
285
|
+
const tableBlocks = new ByteSink();
|
|
286
|
+
if (topCount > 0) {
|
|
287
|
+
const keys = [...table.keys()]
|
|
288
|
+
.map((key) => ({ key, bytes: utf8Encoder.encode(key) }))
|
|
289
|
+
.sort((a, b) => compareBytes(a.bytes, b.bytes));
|
|
290
|
+
const blockSize = 2 ** topBlockExp;
|
|
291
|
+
tableDirectory.varint(keys.length);
|
|
292
|
+
for (let first = 0; first < keys.length; first += blockSize) {
|
|
293
|
+
const block = new ByteSink();
|
|
294
|
+
for (const { key, bytes } of keys.slice(first, first + blockSize)) {
|
|
295
|
+
const top = table.get(key);
|
|
296
|
+
block.bytes(bytes);
|
|
297
|
+
block.byte(0);
|
|
298
|
+
block.varint(top.length);
|
|
299
|
+
for (const { id } of top)
|
|
300
|
+
block.varint(id);
|
|
301
|
+
}
|
|
302
|
+
tableDirectory.bytes(keys[first].bytes);
|
|
303
|
+
tableDirectory.byte(0);
|
|
304
|
+
tableDirectory.varint(block.length);
|
|
305
|
+
tableBlocks.bytes(block.toBytes());
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const out = new ByteSink();
|
|
309
|
+
out.bytes(SIGNATURE);
|
|
310
|
+
out.varint(STORED_VERSION);
|
|
311
|
+
// A table ranked by area depends on the chunks too (Section B.8).
|
|
312
|
+
const checksum = rule === RELEVANCE_AREA
|
|
313
|
+
? await reader.prefixChecksum()
|
|
314
|
+
: await reader.placeTableChecksum();
|
|
315
|
+
const checksumBytes = new Uint8Array(8);
|
|
316
|
+
const dv = new DataView(checksumBytes.buffer);
|
|
317
|
+
dv.setUint32(0, Number(checksum & 0xffffffffn), true);
|
|
318
|
+
dv.setUint32(4, Number(checksum >> 32n), true);
|
|
319
|
+
out.bytes(checksumBytes);
|
|
320
|
+
out.varint(N);
|
|
321
|
+
out.byte(hashExp);
|
|
322
|
+
out.byte(topCount);
|
|
323
|
+
out.byte(topBlockExp);
|
|
324
|
+
out.byte(rule);
|
|
325
|
+
out.varint(presence.length + entries.length);
|
|
326
|
+
out.varint(lists.length);
|
|
327
|
+
out.varint(tableDirectory.length);
|
|
328
|
+
out.bytes(presence);
|
|
329
|
+
out.bytes(entries.toBytes());
|
|
330
|
+
out.bytes(lists.toBytes());
|
|
331
|
+
out.bytes(tableDirectory.toBytes());
|
|
332
|
+
out.bytes(tableBlocks.toBytes());
|
|
333
|
+
return out.toBytes();
|
|
334
|
+
}
|
|
335
|
+
/** Every place's rank area (Section B.5.1), by id: its area byte, or for
|
|
336
|
+
* a region with a same-named city, that city's. */
|
|
337
|
+
async function placeRankAreas(reader, areas) {
|
|
338
|
+
const rankAreas = areas.slice();
|
|
339
|
+
const { countries, regions } = reader.placeCounts;
|
|
340
|
+
for (let region = countries; region < countries + regions; region++) {
|
|
341
|
+
const [lo, hi] = reader.subtreeIdRanges(region)[1] ?? [0, 0];
|
|
342
|
+
const cities = [];
|
|
343
|
+
for (let city = lo; city < hi; city++) {
|
|
344
|
+
const names = await reader.placeNames(city);
|
|
345
|
+
cities.push({ localName: names[0] ?? "", area: areas[city] });
|
|
346
|
+
}
|
|
347
|
+
const own = await reader.placeNames(region);
|
|
348
|
+
rankAreas[region] = regionRankArea(own[0] ?? "", areas[region], cities);
|
|
349
|
+
}
|
|
350
|
+
return rankAreas;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* An open search index file (Appendix B). Opening reads and validates the
|
|
354
|
+
* header only. The list directory, each list, the table directory, and
|
|
355
|
+
* each table block are read the first time a query needs them, and kept
|
|
356
|
+
* behind WeakRefs, so an index that goes unused for weeks costs no memory
|
|
357
|
+
* beyond its header.
|
|
358
|
+
*
|
|
359
|
+
* Opening does NOT verify the source checksum -- `CGeoDataReader`'s
|
|
360
|
+
* `useSearchIndex()` does that against the file it's attached to.
|
|
361
|
+
*/
|
|
362
|
+
export class SearchIndex {
|
|
363
|
+
listDirectoryCache;
|
|
364
|
+
listCache = new Map();
|
|
365
|
+
tableDirectoryCache;
|
|
366
|
+
tableBlockCache = new Map();
|
|
367
|
+
source;
|
|
368
|
+
/** The index file's name, when its source has one (`ByteSource.name`). */
|
|
369
|
+
name;
|
|
370
|
+
/** Checksum of the source it was built from (Section B.8): of its
|
|
371
|
+
* place table, or for `RELEVANCE_AREA`, of everything before its first
|
|
372
|
+
* chunk. A reader only accepts the index when it matches its own. */
|
|
373
|
+
sourceChecksum;
|
|
374
|
+
/** N, the number of places in the source; every one is indexed. */
|
|
375
|
+
placeCount;
|
|
376
|
+
/** 2^hashExp shared lists for non-Latin trigrams (Section B.3). */
|
|
377
|
+
hashExp;
|
|
378
|
+
/** Places per short-query entry; 0 when the file has no table. */
|
|
379
|
+
topCount;
|
|
380
|
+
/** 2^topBlockExp short-query entries per table block (Section B.6). */
|
|
381
|
+
topBlockExp;
|
|
382
|
+
/** The order its short-query table is in: `RELEVANCE_PLAIN` (Section
|
|
383
|
+
* B.5) or `RELEVANCE_AREA` (Section B.5.1). */
|
|
384
|
+
relevanceRule;
|
|
385
|
+
listDirectoryStart;
|
|
386
|
+
listDirectoryLength;
|
|
387
|
+
listsStart;
|
|
388
|
+
listsLength;
|
|
389
|
+
tableDirectoryStart;
|
|
390
|
+
tableDirectoryLength;
|
|
391
|
+
tableBlocksStart;
|
|
392
|
+
tableBlocksLength;
|
|
393
|
+
constructor(source, sourceChecksum, placeCount, hashExp, topCount, topBlockExp, relevanceRule, listDirectoryStart, listDirectoryLength, listsStart, listsLength, tableDirectoryStart, tableDirectoryLength, tableBlocksStart, tableBlocksLength) {
|
|
394
|
+
this.source = source;
|
|
395
|
+
if (source.name !== undefined)
|
|
396
|
+
this.name = source.name;
|
|
397
|
+
this.sourceChecksum = sourceChecksum;
|
|
398
|
+
this.placeCount = placeCount;
|
|
399
|
+
this.hashExp = hashExp;
|
|
400
|
+
this.topCount = topCount;
|
|
401
|
+
this.topBlockExp = topBlockExp;
|
|
402
|
+
this.relevanceRule = relevanceRule;
|
|
403
|
+
this.listDirectoryStart = listDirectoryStart;
|
|
404
|
+
this.listDirectoryLength = listDirectoryLength;
|
|
405
|
+
this.listsStart = listsStart;
|
|
406
|
+
this.listsLength = listsLength;
|
|
407
|
+
this.tableDirectoryStart = tableDirectoryStart;
|
|
408
|
+
this.tableDirectoryLength = tableDirectoryLength;
|
|
409
|
+
this.tableBlocksStart = tableBlocksStart;
|
|
410
|
+
this.tableBlocksLength = tableBlocksLength;
|
|
411
|
+
}
|
|
412
|
+
/** Opens a search index file (read on demand, like a `.bin`),
|
|
413
|
+
* validating its header (Section B.10). Takes ownership of a
|
|
414
|
+
* `ByteSource`: `close()` closes it, as does a failed open. */
|
|
415
|
+
static async open(input) {
|
|
416
|
+
const source = toSource(input);
|
|
417
|
+
try {
|
|
418
|
+
return await SearchIndex.parseHeader(source);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
await source.close?.();
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
static async parseHeader(source) {
|
|
426
|
+
const data = await source.read(0, Math.min(MAX_HEADER_LENGTH, source.size));
|
|
427
|
+
if (data.length < SIGNATURE.length + 1) {
|
|
428
|
+
throw new CGeoDataFormatError("search index file is too short to be valid");
|
|
429
|
+
}
|
|
430
|
+
for (let i = 0; i < SIGNATURE.length; i++) {
|
|
431
|
+
if (data[i] !== SIGNATURE[i]) {
|
|
432
|
+
throw new CGeoDataFormatError("not a CGEODATA search index file (wrong magic bytes)");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
let off = SIGNATURE.length;
|
|
436
|
+
const varint = () => {
|
|
437
|
+
const { value, bytesRead } = readVarint(data, off);
|
|
438
|
+
off += bytesRead;
|
|
439
|
+
return value;
|
|
440
|
+
};
|
|
441
|
+
const u8 = (what) => {
|
|
442
|
+
if (off >= data.length)
|
|
443
|
+
throw new CGeoDataFormatError(`search index file is too short for ${what}`);
|
|
444
|
+
return data[off++];
|
|
445
|
+
};
|
|
446
|
+
const storedVersion = varint();
|
|
447
|
+
const kind = Math.floor(storedVersion / 16);
|
|
448
|
+
const version = storedVersion % 16;
|
|
449
|
+
if (kind !== KIND_SEARCH_INDEX) {
|
|
450
|
+
throw new CGeoDataFormatError(`not a text search index file (kind ${kind}, expected ${KIND_SEARCH_INDEX})`);
|
|
451
|
+
}
|
|
452
|
+
if (version !== FORMAT_VERSION) {
|
|
453
|
+
throw new CGeoDataVersionError(version === 0
|
|
454
|
+
? "search index is the old bigram layout (kind 1, version 0); rebuild it with build-search-index"
|
|
455
|
+
: version === 1
|
|
456
|
+
? "search index predates diacritic folding (kind 1, version 1); rebuild it with build-search-index"
|
|
457
|
+
: version === 2
|
|
458
|
+
? "search index predates ranking by area (kind 1, version 2); rebuild it with build-search-index"
|
|
459
|
+
: `unsupported search index version ${version} (expected ${FORMAT_VERSION})`, version);
|
|
460
|
+
}
|
|
461
|
+
if (data.length < off + 8)
|
|
462
|
+
throw new CGeoDataFormatError("search index file is too short for source checksum");
|
|
463
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
464
|
+
const sourceChecksum = BigInt(dv.getUint32(off, true)) |
|
|
465
|
+
(BigInt(dv.getUint32(off + 4, true)) << 32n);
|
|
466
|
+
off += 8;
|
|
467
|
+
const placeCount = varint();
|
|
468
|
+
const hashExp = u8("hash_exp");
|
|
469
|
+
const topCount = u8("top_count");
|
|
470
|
+
const topBlockExp = u8("top_block_exp");
|
|
471
|
+
const relevanceRule = u8("relevance_rule");
|
|
472
|
+
if (relevanceRule !== RELEVANCE_PLAIN && relevanceRule !== RELEVANCE_AREA)
|
|
473
|
+
throw new CGeoDataFormatError(`unknown search index relevance_rule ${relevanceRule}`);
|
|
474
|
+
if (hashExp < 1 || hashExp > MAX_EXP)
|
|
475
|
+
throw new CGeoDataFormatError(`hash_exp must be from 1 to ${MAX_EXP}, not ${hashExp}`);
|
|
476
|
+
if (topBlockExp > MAX_EXP)
|
|
477
|
+
throw new CGeoDataFormatError(`top_block_exp must be at most ${MAX_EXP}, not ${topBlockExp}`);
|
|
478
|
+
const listDirectoryLength = varint();
|
|
479
|
+
const listsLength = varint();
|
|
480
|
+
const tableDirectoryLength = varint();
|
|
481
|
+
const listDirectoryStart = off;
|
|
482
|
+
const listsStart = listDirectoryStart + listDirectoryLength;
|
|
483
|
+
const tableDirectoryStart = listsStart + listsLength;
|
|
484
|
+
const tableBlocksStart = tableDirectoryStart + tableDirectoryLength;
|
|
485
|
+
if (tableBlocksStart > source.size) {
|
|
486
|
+
throw new CGeoDataFormatError(`search index header's section lengths run to byte ${tableBlocksStart}, past the end of the ${source.size}-byte file`);
|
|
487
|
+
}
|
|
488
|
+
const tableBlocksLength = source.size - tableBlocksStart;
|
|
489
|
+
if (topCount === 0 && tableDirectoryLength + tableBlocksLength > 0) {
|
|
490
|
+
throw new CGeoDataFormatError("search index has a short-query table but top_count 0");
|
|
491
|
+
}
|
|
492
|
+
const presenceLength = Math.ceil((LATIN_LISTS + 2 ** hashExp) / 8);
|
|
493
|
+
if (listDirectoryLength < presenceLength) {
|
|
494
|
+
throw new CGeoDataFormatError(`search index list directory is ${listDirectoryLength} bytes, shorter than its ${presenceLength}-byte presence bitmap`);
|
|
495
|
+
}
|
|
496
|
+
return new SearchIndex(source, sourceChecksum, placeCount, hashExp, topCount, topBlockExp, relevanceRule, listDirectoryStart, listDirectoryLength, listsStart, listsLength, tableDirectoryStart, tableDirectoryLength, tableBlocksStart, tableBlocksLength);
|
|
497
|
+
}
|
|
498
|
+
/** Closes the index's source. */
|
|
499
|
+
async close() {
|
|
500
|
+
await this.source.close?.();
|
|
501
|
+
}
|
|
502
|
+
/** L = 28^3 + 2^hashExp possible list indices. */
|
|
503
|
+
get listCount() {
|
|
504
|
+
return LATIN_LISTS + 2 ** this.hashExp;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Section B.9 steps 2-4: every place whose names could contain `text`,
|
|
508
|
+
* ascending -- a superset of the real matches, which the caller still
|
|
509
|
+
* has to confirm name by name. Undefined when `text` folds to fewer
|
|
510
|
+
* than 3 symbols, i.e. the lists can't rule anything out.
|
|
511
|
+
*/
|
|
512
|
+
async candidates(text) {
|
|
513
|
+
const indices = trigramListIndices(text, this.hashExp);
|
|
514
|
+
if (indices.length === 0)
|
|
515
|
+
return undefined;
|
|
516
|
+
const dir = await this.listDirectory();
|
|
517
|
+
const slots = [];
|
|
518
|
+
for (const l of indices) {
|
|
519
|
+
const slot = findSlot(dir.indices, l);
|
|
520
|
+
if (slot < 0)
|
|
521
|
+
return []; // an empty list: nothing can match
|
|
522
|
+
slots.push(slot);
|
|
523
|
+
}
|
|
524
|
+
// Shortest first: an id list's length tracks its size, and every
|
|
525
|
+
// bitmap is as long as the longest id list could be.
|
|
526
|
+
slots.sort((a, b) => dir.lengths[a] - dir.lengths[b]);
|
|
527
|
+
let candidates;
|
|
528
|
+
for (const slot of slots) {
|
|
529
|
+
const list = await this.list(dir, slot);
|
|
530
|
+
if (dir.encodings[slot] === ENCODING_ID_LIST) {
|
|
531
|
+
const ids = this.decodeIdList(list);
|
|
532
|
+
candidates = candidates ? intersectSorted(candidates, ids) : ids;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
candidates = candidates
|
|
536
|
+
? candidates.filter((id) => (list[id >> 3] & (1 << (id & 7))) !== 0)
|
|
537
|
+
: this.expandBitmap(list);
|
|
538
|
+
}
|
|
539
|
+
if (candidates.length === 0)
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
return candidates;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Section B.9 step 1: the short-query table's entry for `text` -- its
|
|
546
|
+
* top `topCount` places in relevance order (Section B.5), or `[]` when
|
|
547
|
+
* nothing matches. Undefined when the file has no table or `text`
|
|
548
|
+
* isn't one or two code points long once folded.
|
|
549
|
+
*
|
|
550
|
+
* The key is `foldDiacritics(text)`, so an accented query and its
|
|
551
|
+
* unaccented spelling land on the same entry -- which is only the
|
|
552
|
+
* right answer for a query that needed no folding in the first place.
|
|
553
|
+
* Callers MUST therefore apply Section B.9 step 1's own precondition
|
|
554
|
+
* (the query's folded form already equals its plain lowercase form)
|
|
555
|
+
* before trusting this; `CGeoDataReader.searchRanked` does.
|
|
556
|
+
*/
|
|
557
|
+
async shortQuery(text) {
|
|
558
|
+
if (this.topCount === 0)
|
|
559
|
+
return undefined;
|
|
560
|
+
const key = foldDiacritics(text);
|
|
561
|
+
const length = Array.from(key).length;
|
|
562
|
+
if (length < 1 || length > 2)
|
|
563
|
+
return undefined;
|
|
564
|
+
const dir = await this.tableDirectory();
|
|
565
|
+
const keyBytes = utf8Encoder.encode(key);
|
|
566
|
+
// The last block whose first key is at most `key`.
|
|
567
|
+
let lo = 0;
|
|
568
|
+
let hi = dir.firstKeys.length;
|
|
569
|
+
while (lo < hi) {
|
|
570
|
+
const mid = (lo + hi) >>> 1;
|
|
571
|
+
if (compareBytes(dir.firstKeys[mid], keyBytes) <= 0)
|
|
572
|
+
lo = mid + 1;
|
|
573
|
+
else
|
|
574
|
+
hi = mid;
|
|
575
|
+
}
|
|
576
|
+
if (lo === 0)
|
|
577
|
+
return [];
|
|
578
|
+
return (await this.tableBlock(dir, lo - 1)).get(key) ?? [];
|
|
579
|
+
}
|
|
580
|
+
async listDirectory() {
|
|
581
|
+
const cached = this.listDirectoryCache?.deref();
|
|
582
|
+
if (cached)
|
|
583
|
+
return cached;
|
|
584
|
+
const data = await this.source.read(this.listDirectoryStart, this.listDirectoryLength);
|
|
585
|
+
const listCount = this.listCount;
|
|
586
|
+
const presenceLength = Math.ceil(listCount / 8);
|
|
587
|
+
const present = [];
|
|
588
|
+
for (let byte = 0; byte < presenceLength; byte++) {
|
|
589
|
+
const bits = data[byte];
|
|
590
|
+
if (bits === 0)
|
|
591
|
+
continue;
|
|
592
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
593
|
+
if ((bits & (1 << bit)) === 0)
|
|
594
|
+
continue;
|
|
595
|
+
const l = byte * 8 + bit;
|
|
596
|
+
if (l >= listCount)
|
|
597
|
+
throw new CGeoDataFormatError(`search index presence bitmap marks list ${l}, beyond its ${listCount} lists`);
|
|
598
|
+
present.push(l);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const dir = {
|
|
602
|
+
indices: Int32Array.from(present),
|
|
603
|
+
offsets: new Float64Array(present.length),
|
|
604
|
+
lengths: new Int32Array(present.length),
|
|
605
|
+
encodings: new Uint8Array(present.length),
|
|
606
|
+
};
|
|
607
|
+
const bitmapLength = Math.ceil(this.placeCount / 8);
|
|
608
|
+
let off = presenceLength;
|
|
609
|
+
let listOffset = 0;
|
|
610
|
+
for (let slot = 0; slot < present.length; slot++) {
|
|
611
|
+
const { value, bytesRead } = readVarint(data, off);
|
|
612
|
+
off += bytesRead;
|
|
613
|
+
const length = Math.floor(value / 2);
|
|
614
|
+
const encoding = value % 2;
|
|
615
|
+
if (length === 0)
|
|
616
|
+
throw new CGeoDataFormatError(`search index stores list ${present[slot]} with length 0`);
|
|
617
|
+
if (encoding === ENCODING_BITMAP && length !== bitmapLength) {
|
|
618
|
+
throw new CGeoDataFormatError(`search index bitmap for list ${present[slot]} is ${length} bytes, expected ${bitmapLength}`);
|
|
619
|
+
}
|
|
620
|
+
dir.offsets[slot] = listOffset;
|
|
621
|
+
dir.lengths[slot] = length;
|
|
622
|
+
dir.encodings[slot] = encoding;
|
|
623
|
+
listOffset += length;
|
|
624
|
+
}
|
|
625
|
+
if (off !== data.length) {
|
|
626
|
+
throw new CGeoDataFormatError(`search index list directory has ${data.length - off} bytes after its last entry`);
|
|
627
|
+
}
|
|
628
|
+
if (listOffset !== this.listsLength) {
|
|
629
|
+
throw new CGeoDataFormatError(`search index list lengths add up to ${listOffset}, but lists_length is ${this.listsLength}`);
|
|
630
|
+
}
|
|
631
|
+
this.listDirectoryCache = new WeakRef(dir);
|
|
632
|
+
return dir;
|
|
633
|
+
}
|
|
634
|
+
/** One stored list's bytes. Shared with later queries through the
|
|
635
|
+
* cache: callers MUST NOT modify it. */
|
|
636
|
+
async list(dir, slot) {
|
|
637
|
+
const l = dir.indices[slot];
|
|
638
|
+
const cached = this.listCache.get(l)?.deref();
|
|
639
|
+
if (cached)
|
|
640
|
+
return cached;
|
|
641
|
+
const list = await this.source.read(this.listsStart + dir.offsets[slot], dir.lengths[slot]);
|
|
642
|
+
this.listCache.set(l, new WeakRef(list));
|
|
643
|
+
return list;
|
|
644
|
+
}
|
|
645
|
+
decodeIdList(list) {
|
|
646
|
+
const ids = [];
|
|
647
|
+
let off = 0;
|
|
648
|
+
let id = 0;
|
|
649
|
+
while (off < list.length) {
|
|
650
|
+
const { value, bytesRead } = readVarint(list, off);
|
|
651
|
+
off += bytesRead;
|
|
652
|
+
if (ids.length > 0 && value === 0)
|
|
653
|
+
throw new CGeoDataFormatError("search index id list is not strictly increasing");
|
|
654
|
+
id += value;
|
|
655
|
+
if (id >= this.placeCount)
|
|
656
|
+
throw new CGeoDataFormatError(`search index lists place ${id}, beyond its ${this.placeCount} places`);
|
|
657
|
+
ids.push(id);
|
|
658
|
+
}
|
|
659
|
+
return ids;
|
|
660
|
+
}
|
|
661
|
+
expandBitmap(bitmap) {
|
|
662
|
+
const ids = [];
|
|
663
|
+
for (let byte = 0; byte < bitmap.length; byte++) {
|
|
664
|
+
const bits = bitmap[byte];
|
|
665
|
+
if (bits === 0)
|
|
666
|
+
continue;
|
|
667
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
668
|
+
if ((bits & (1 << bit)) === 0)
|
|
669
|
+
continue;
|
|
670
|
+
const id = byte * 8 + bit;
|
|
671
|
+
if (id >= this.placeCount)
|
|
672
|
+
throw new CGeoDataFormatError(`search index bitmap sets place ${id}, beyond its ${this.placeCount} places`);
|
|
673
|
+
ids.push(id);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return ids;
|
|
677
|
+
}
|
|
678
|
+
async tableDirectory() {
|
|
679
|
+
const cached = this.tableDirectoryCache?.deref();
|
|
680
|
+
if (cached)
|
|
681
|
+
return cached;
|
|
682
|
+
const data = await this.source.read(this.tableDirectoryStart, this.tableDirectoryLength);
|
|
683
|
+
let off = 0;
|
|
684
|
+
const { value: keyCount, bytesRead } = readVarint(data, off);
|
|
685
|
+
off += bytesRead;
|
|
686
|
+
const blockCount = Math.ceil(keyCount / 2 ** this.topBlockExp);
|
|
687
|
+
const dir = { firstKeys: [], offsets: [], lengths: [] };
|
|
688
|
+
let blockOffset = 0;
|
|
689
|
+
for (let b = 0; b < blockCount; b++) {
|
|
690
|
+
const end = data.indexOf(0, off);
|
|
691
|
+
if (end < 0)
|
|
692
|
+
throw new CGeoDataFormatError("search index table directory ends inside a key");
|
|
693
|
+
const firstKey = data.slice(off, end);
|
|
694
|
+
off = end + 1;
|
|
695
|
+
const { value: length, bytesRead: lb } = readVarint(data, off);
|
|
696
|
+
off += lb;
|
|
697
|
+
const prev = dir.firstKeys[b - 1];
|
|
698
|
+
if (prev && compareBytes(prev, firstKey) >= 0)
|
|
699
|
+
throw new CGeoDataFormatError("search index table directory keys are not in increasing order");
|
|
700
|
+
dir.firstKeys.push(firstKey);
|
|
701
|
+
dir.offsets.push(blockOffset);
|
|
702
|
+
dir.lengths.push(length);
|
|
703
|
+
blockOffset += length;
|
|
704
|
+
}
|
|
705
|
+
if (off !== data.length) {
|
|
706
|
+
throw new CGeoDataFormatError(`search index table directory has ${data.length - off} bytes after its last block`);
|
|
707
|
+
}
|
|
708
|
+
if (blockOffset !== this.tableBlocksLength) {
|
|
709
|
+
throw new CGeoDataFormatError(`search index table block lengths add up to ${blockOffset}, but the table is ${this.tableBlocksLength} bytes`);
|
|
710
|
+
}
|
|
711
|
+
this.tableDirectoryCache = new WeakRef(dir);
|
|
712
|
+
return dir;
|
|
713
|
+
}
|
|
714
|
+
async tableBlock(dir, b) {
|
|
715
|
+
const cached = this.tableBlockCache.get(b)?.deref();
|
|
716
|
+
if (cached)
|
|
717
|
+
return cached;
|
|
718
|
+
const data = await this.source.read(this.tableBlocksStart + dir.offsets[b], dir.lengths[b]);
|
|
719
|
+
const entries = new Map();
|
|
720
|
+
let off = 0;
|
|
721
|
+
let prevKey;
|
|
722
|
+
while (off < data.length) {
|
|
723
|
+
const end = data.indexOf(0, off);
|
|
724
|
+
if (end < 0)
|
|
725
|
+
throw new CGeoDataFormatError(`search index table block ${b} ends inside a key`);
|
|
726
|
+
const keyBytes = data.subarray(off, end);
|
|
727
|
+
if (prevKey
|
|
728
|
+
? compareBytes(prevKey, keyBytes) >= 0
|
|
729
|
+
: compareBytes(dir.firstKeys[b], keyBytes) !== 0) {
|
|
730
|
+
throw new CGeoDataFormatError(`search index table block ${b} is not in key order`);
|
|
731
|
+
}
|
|
732
|
+
prevKey = keyBytes;
|
|
733
|
+
off = end + 1;
|
|
734
|
+
const { value: count, bytesRead } = readVarint(data, off);
|
|
735
|
+
off += bytesRead;
|
|
736
|
+
if (count < 1 || count > this.topCount) {
|
|
737
|
+
throw new CGeoDataFormatError(`search index table entry has ${count} places, expected 1 to ${this.topCount}`);
|
|
738
|
+
}
|
|
739
|
+
const ids = [];
|
|
740
|
+
for (let i = 0; i < count; i++) {
|
|
741
|
+
const { value: id, bytesRead: ib } = readVarint(data, off);
|
|
742
|
+
off += ib;
|
|
743
|
+
if (id >= this.placeCount)
|
|
744
|
+
throw new CGeoDataFormatError(`search index table lists place ${id}, beyond its ${this.placeCount} places`);
|
|
745
|
+
ids.push(id);
|
|
746
|
+
}
|
|
747
|
+
entries.set(utf8Decoder.decode(keyBytes), ids);
|
|
748
|
+
}
|
|
749
|
+
if (entries.size > 2 ** this.topBlockExp) {
|
|
750
|
+
throw new CGeoDataFormatError(`search index table block ${b} has ${entries.size} entries, more than 2^${this.topBlockExp}`);
|
|
751
|
+
}
|
|
752
|
+
this.tableBlockCache.set(b, new WeakRef(entries));
|
|
753
|
+
return entries;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** Position of `value` in the ascending `sorted`, or -1. */
|
|
757
|
+
function findSlot(sorted, value) {
|
|
758
|
+
let lo = 0;
|
|
759
|
+
let hi = sorted.length;
|
|
760
|
+
while (lo < hi) {
|
|
761
|
+
const mid = (lo + hi) >>> 1;
|
|
762
|
+
if (sorted[mid] < value)
|
|
763
|
+
lo = mid + 1;
|
|
764
|
+
else
|
|
765
|
+
hi = mid;
|
|
766
|
+
}
|
|
767
|
+
return lo < sorted.length && sorted[lo] === value ? lo : -1;
|
|
768
|
+
}
|
|
769
|
+
function intersectSorted(a, b) {
|
|
770
|
+
const out = [];
|
|
771
|
+
let i = 0;
|
|
772
|
+
let j = 0;
|
|
773
|
+
while (i < a.length && j < b.length) {
|
|
774
|
+
if (a[i] < b[j])
|
|
775
|
+
i++;
|
|
776
|
+
else if (a[i] > b[j])
|
|
777
|
+
j++;
|
|
778
|
+
else {
|
|
779
|
+
out.push(a[i]);
|
|
780
|
+
i++;
|
|
781
|
+
j++;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=searchIndex.js.map
|