@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relevance order for search results (binary-format.md Appendix B.5):
|
|
3
|
+
* what `CGeoDataReader.search({ sort: true })` ranks by, and what a
|
|
4
|
+
* search index's short-query table is built with -- one implementation
|
|
5
|
+
* for both, since the table is only correct if it ranks exactly like the
|
|
6
|
+
* search it stands in for.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlaceLevel } from "./reader.ts";
|
|
9
|
+
/** Per-level bonus: a query that exactly names a country should surface
|
|
10
|
+
* that country, not a same-named city buried inside it (e.g. "Nederland"
|
|
11
|
+
* the country, not a village called Nederland in Overijssel) -- a country
|
|
12
|
+
* is also the rarest, most deliberate kind of query, while a short
|
|
13
|
+
* substring is far more likely to be aimed at one of the many more
|
|
14
|
+
* numerous cities. Only country jumps the queue: city still outranks
|
|
15
|
+
* region exactly as before (a plain reversal of all three would also
|
|
16
|
+
* rank, say, the Antwerpen *province* above the city of Antwerp on an
|
|
17
|
+
* identical exact match, which nobody asked for and is backwards for the
|
|
18
|
+
* much more common case of searching a city's own name). `LEVEL_BONUS`'s
|
|
19
|
+
* spread (2) is kept well under the gap between adjacent quality tiers
|
|
20
|
+
* (`QUALITY_SUBSTRING` to `QUALITY_WHOLE_WORD`, 9) so quality always
|
|
21
|
+
* decides first and the level bonus only ever breaks a tie between two
|
|
22
|
+
* matches of the same tier and spelling exactness -- see
|
|
23
|
+
* `nameMatchQuality`. */
|
|
24
|
+
export declare const LEVEL_BONUS: Readonly<Record<PlaceLevel, number>>;
|
|
25
|
+
/** NFD, strip combining diacritics, lowercase -- diacritic- and
|
|
26
|
+
* case-insensitive text for matching and ranking (Section B.5).
|
|
27
|
+
* `searchIndex.ts`'s `foldSymbols` (Section B.2's full symbol folding
|
|
28
|
+
* used for trigrams) continues from this, and only from this -- but
|
|
29
|
+
* matching and ranking stop here, because that one also buckets
|
|
30
|
+
* every ASCII digit into one shared symbol and every other ASCII
|
|
31
|
+
* punctuation/space character into another, which is the right call for
|
|
32
|
+
* trigram list sizing but would make "5th" match "9th" as a substring --
|
|
33
|
+
* too loose for deciding whether, or how well, a name matches a query.
|
|
34
|
+
* `foldDiacritics` only folds diacritics and case, so it stays strictly
|
|
35
|
+
* narrower than (every match it finds, `foldSymbols`-based folded
|
|
36
|
+
* substring containment also finds) the trigram index's own folding,
|
|
37
|
+
* which is what makes it safe to confirm trigram-index candidates with
|
|
38
|
+
* (Section B.9). */
|
|
39
|
+
export declare function foldDiacritics(text: string): string;
|
|
40
|
+
/** Lowercases and splits on any run of non-letter/non-digit characters,
|
|
41
|
+
* so "Nieuw-Amsterdam" and "Nieuw Amsterdam" tokenize identically
|
|
42
|
+
* regardless of which separator the data used. Unicode-aware (`\p{L}`),
|
|
43
|
+
* so accented letters count as letters rather than splitting a word in
|
|
44
|
+
* two. Case-only -- no diacritic folding; see `tokenizeFolded` for that.
|
|
45
|
+
* Kept separate so `nameMatchQuality` can tell whether a match needed
|
|
46
|
+
* folding to reach its tier (the "exact spelling" bonus). */
|
|
47
|
+
export declare function tokenize(text: string): string[];
|
|
48
|
+
/** Like `tokenize`, but folds diacritics first (see `foldDiacritics`). */
|
|
49
|
+
export declare function tokenizeFolded(text: string): string[];
|
|
50
|
+
/** True if `needleTokens` occurs as a contiguous run within `tokens` --
|
|
51
|
+
* i.e. the query matches one or more *whole* words of the name, not
|
|
52
|
+
* just a substring that happens to straddle a word boundary. */
|
|
53
|
+
export declare function containsTokenRun(tokens: readonly string[], needleTokens: readonly string[]): boolean;
|
|
54
|
+
/** A query, precomputed once per search (not once per name): both its
|
|
55
|
+
* raw (case-folded only) and diacritic-folded forms, each with its own
|
|
56
|
+
* tokens, since `nameMatchQuality` needs both to tell a match that needed
|
|
57
|
+
* folding from one that didn't. */
|
|
58
|
+
export interface Needle {
|
|
59
|
+
/** The query lowercased, diacritics kept. */
|
|
60
|
+
raw: string;
|
|
61
|
+
/** `raw`'s words (`tokenize`). */
|
|
62
|
+
rawTokens: string[];
|
|
63
|
+
/** The query folded (`foldDiacritics`). */
|
|
64
|
+
folded: string;
|
|
65
|
+
/** `folded`'s words. */
|
|
66
|
+
foldedTokens: string[];
|
|
67
|
+
}
|
|
68
|
+
/** `text` as a `Needle`, for scoring many names against one query. */
|
|
69
|
+
export declare function prepareNeedle(text: string): Needle;
|
|
70
|
+
/** A plain substring match. Spaced ten apart from the next tier (and the
|
|
71
|
+
* next from `QUALITY_EXACT`) so `EXACT_SPELLING_BONUS` and
|
|
72
|
+
* `LEVEL_BONUS` can never add up to enough to cross into a higher tier --
|
|
73
|
+
* quality always dominates level and spelling exactness, which only ever
|
|
74
|
+
* break a tie within the same tier. */
|
|
75
|
+
export declare const QUALITY_SUBSTRING = 10;
|
|
76
|
+
/** The query's tokens occur in the field's tokens as a contiguous run. */
|
|
77
|
+
export declare const QUALITY_WHOLE_WORD = 20;
|
|
78
|
+
/** The field equals the query. */
|
|
79
|
+
export declare const QUALITY_EXACT = 40;
|
|
80
|
+
/** Added when a field reaches its tier (substring, whole-word, or exact)
|
|
81
|
+
* without needing diacritic folding at all -- i.e. the query's raw
|
|
82
|
+
* spelling, as typed, was already enough. Rewards typing a name's
|
|
83
|
+
* diacritics correctly over a folding-only match of the same tier,
|
|
84
|
+
* without ever letting a folding-only match of a *better* tier lose to
|
|
85
|
+
* an exact-spelling match of a worse one (`QUALITY_SUBSTRING`'s best case,
|
|
86
|
+
* 11, is still well under `QUALITY_WHOLE_WORD`'s worst, 20). */
|
|
87
|
+
export declare const EXACT_SPELLING_BONUS = 1;
|
|
88
|
+
/** The best any single field can ever score. */
|
|
89
|
+
export declare const QUALITY_MAX: number;
|
|
90
|
+
/**
|
|
91
|
+
* A name's match quality against `needle`: the diacritic-folded
|
|
92
|
+
* comparison decides which tier it reaches (0 for no match, otherwise
|
|
93
|
+
* `QUALITY_SUBSTRING`/`QUALITY_WHOLE_WORD`/`QUALITY_EXACT`), and the raw
|
|
94
|
+
* (unfolded) comparison decides whether `EXACT_SPELLING_BONUS` applies --
|
|
95
|
+
* it does whenever the raw comparison reaches *at least* the same tier,
|
|
96
|
+
* which folding can only ever match or improve on for the same
|
|
97
|
+
* name/needle pair (never make worse), so "reaches at least" is exactly
|
|
98
|
+
* "didn't need folding to get here".
|
|
99
|
+
*
|
|
100
|
+
* Only a substring match counts at all, so sorting ranks the same set
|
|
101
|
+
* unsorted search finds: without that, " di " would match "Di" by its
|
|
102
|
+
* tokens alone, and a search index (which only guarantees folded
|
|
103
|
+
* substring candidates, Appendix B.9) would miss it.
|
|
104
|
+
*/
|
|
105
|
+
export declare function nameMatchQuality(name: string, needle: Needle): number;
|
|
106
|
+
/** The best `nameMatchQuality` over a place's names. */
|
|
107
|
+
export declare function placeMatchQuality(names: readonly string[], needle: Needle): number;
|
|
108
|
+
/** `quality` without `EXACT_SPELLING_BONUS`: the folded tier alone
|
|
109
|
+
* (`QUALITY_SUBSTRING`, `QUALITY_WHOLE_WORD` or `QUALITY_EXACT`), or 0. */
|
|
110
|
+
export declare function qualityTier(quality: number): number;
|
|
111
|
+
/**
|
|
112
|
+
* Appendix C.3's area byte for an area of `cells` full-precision cells:
|
|
113
|
+
* `0` for an empty area, otherwise the bit length of `cells^4`, at most
|
|
114
|
+
* 255 -- `1 + floor(4 × log2(cells))`, a quarter of a doubling per step,
|
|
115
|
+
* computed exactly. `cells` must be a non-negative integer.
|
|
116
|
+
*/
|
|
117
|
+
export declare function areaByte(cells: number): number;
|
|
118
|
+
/**
|
|
119
|
+
* The rank area of a region (Appendix B.5.1): its own area byte, unless
|
|
120
|
+
* one or more of its cities has the same local name (field 0) once folded
|
|
121
|
+
* (`foldDiacritics`) -- then the largest area byte among those cities, so
|
|
122
|
+
* the province of Utrecht ranks right after the city of Utrecht instead of
|
|
123
|
+
* ahead of it for being larger. A city-state with no same-named city of
|
|
124
|
+
* its own (Berlin, Vienna) keeps its own area. Countries and cities rank by
|
|
125
|
+
* their own area byte.
|
|
126
|
+
*/
|
|
127
|
+
export declare function regionRankArea(regionLocalName: string, regionArea: number, cities: Iterable<{
|
|
128
|
+
localName: string;
|
|
129
|
+
area: number;
|
|
130
|
+
}>): number;
|
|
131
|
+
/** One search match, with everything the relevance orders compare. */
|
|
132
|
+
export interface RankedMatch {
|
|
133
|
+
/** The place's id. */
|
|
134
|
+
id: number;
|
|
135
|
+
/** The place's level. */
|
|
136
|
+
level: PlaceLevel;
|
|
137
|
+
/** Its match quality (`placeMatchQuality`) -- for a qualified query
|
|
138
|
+
* (Appendix B.13), the head's. */
|
|
139
|
+
quality: number;
|
|
140
|
+
/** The summed quality of a qualified query's contexts against the
|
|
141
|
+
* ancestors they matched; 0 for a plain query. */
|
|
142
|
+
contextQuality: number;
|
|
143
|
+
/** Its rank area (Appendix B.5.1); only the area order reads it. */
|
|
144
|
+
rankArea: number;
|
|
145
|
+
}
|
|
146
|
+
/** Appendix B.5's relevance order, as a sort comparator: score
|
|
147
|
+
* (`quality + LEVEL_BONUS`) descending, then context quality descending,
|
|
148
|
+
* then id ascending. */
|
|
149
|
+
export declare function compareRelevance(a: RankedMatch, b: RankedMatch): number;
|
|
150
|
+
/**
|
|
151
|
+
* Appendix B.5.1's area order, as a sort comparator: folded tier, then
|
|
152
|
+
* context quality, then countries ahead of regions and cities, then rank
|
|
153
|
+
* area, then the exact spelling bonus, then city ahead of region, then id
|
|
154
|
+
* -- each only breaking ties left by the ones before it. Area comes before
|
|
155
|
+
* the spelling bonus so that typing "zurich" can't put a hamlet literally
|
|
156
|
+
* named Zurich ahead of the much larger Zürich.
|
|
157
|
+
*/
|
|
158
|
+
export declare function compareAreaRelevance(a: RankedMatch, b: RankedMatch): number;
|
|
159
|
+
/** One reading of a search text: a head that a place's own names must
|
|
160
|
+
* match, and contexts its ancestors' names must match, nearest first. */
|
|
161
|
+
export interface QueryInterpretation {
|
|
162
|
+
/** Matched against the place's own names, as a plain query would be. */
|
|
163
|
+
head: string;
|
|
164
|
+
/** Each matched against a different ancestor, each further up than
|
|
165
|
+
* the one before (Appendix B.13). Empty for the whole text. */
|
|
166
|
+
contexts: string[];
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The readings of a search text (Appendix B.13): the whole text as typed,
|
|
170
|
+
* with no contexts, and for every comma in it, the text before that comma
|
|
171
|
+
* as the head (trimmed) and each comma-separated part after it as a
|
|
172
|
+
* context (trimmed, empty ones dropped). "Paris, Texas" is both a place
|
|
173
|
+
* named "Paris, Texas" and a "Paris" under a "Texas"; a trailing comma
|
|
174
|
+
* ("Paris,") reads as "Paris" alone. Duplicate readings are dropped.
|
|
175
|
+
*/
|
|
176
|
+
export declare function parseQualifiedQuery(text: string): QueryInterpretation[];
|
|
177
|
+
/**
|
|
178
|
+
* How well `contexts` match `ancestors` (nearest first, each a name list):
|
|
179
|
+
* the best total quality over every way of matching context `i` to an
|
|
180
|
+
* ancestor further up than context `i - 1`'s, or 0 when there is no such
|
|
181
|
+
* way -- including when there are more contexts than ancestors. Every
|
|
182
|
+
* context must match (Appendix B.13). `contexts` must be non-empty.
|
|
183
|
+
*/
|
|
184
|
+
export declare function contextQuality(contexts: readonly Needle[], ancestors: readonly (readonly string[])[]): number;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/** Per-level bonus: a query that exactly names a country should surface
|
|
2
|
+
* that country, not a same-named city buried inside it (e.g. "Nederland"
|
|
3
|
+
* the country, not a village called Nederland in Overijssel) -- a country
|
|
4
|
+
* is also the rarest, most deliberate kind of query, while a short
|
|
5
|
+
* substring is far more likely to be aimed at one of the many more
|
|
6
|
+
* numerous cities. Only country jumps the queue: city still outranks
|
|
7
|
+
* region exactly as before (a plain reversal of all three would also
|
|
8
|
+
* rank, say, the Antwerpen *province* above the city of Antwerp on an
|
|
9
|
+
* identical exact match, which nobody asked for and is backwards for the
|
|
10
|
+
* much more common case of searching a city's own name). `LEVEL_BONUS`'s
|
|
11
|
+
* spread (2) is kept well under the gap between adjacent quality tiers
|
|
12
|
+
* (`QUALITY_SUBSTRING` to `QUALITY_WHOLE_WORD`, 9) so quality always
|
|
13
|
+
* decides first and the level bonus only ever breaks a tie between two
|
|
14
|
+
* matches of the same tier and spelling exactness -- see
|
|
15
|
+
* `nameMatchQuality`. */
|
|
16
|
+
export const LEVEL_BONUS = {
|
|
17
|
+
country: 3,
|
|
18
|
+
city: 2,
|
|
19
|
+
region: 1,
|
|
20
|
+
};
|
|
21
|
+
/** The Combining Diacritical Marks block (Section B.2 step 2). Written
|
|
22
|
+
* with escapes, not the literal code points, which would otherwise
|
|
23
|
+
* combine onto the surrounding brackets in an editor. */
|
|
24
|
+
const COMBINING = /[\u0300-\u036f]/g;
|
|
25
|
+
/** NFD, strip combining diacritics, lowercase -- diacritic- and
|
|
26
|
+
* case-insensitive text for matching and ranking (Section B.5).
|
|
27
|
+
* `searchIndex.ts`'s `foldSymbols` (Section B.2's full symbol folding
|
|
28
|
+
* used for trigrams) continues from this, and only from this -- but
|
|
29
|
+
* matching and ranking stop here, because that one also buckets
|
|
30
|
+
* every ASCII digit into one shared symbol and every other ASCII
|
|
31
|
+
* punctuation/space character into another, which is the right call for
|
|
32
|
+
* trigram list sizing but would make "5th" match "9th" as a substring --
|
|
33
|
+
* too loose for deciding whether, or how well, a name matches a query.
|
|
34
|
+
* `foldDiacritics` only folds diacritics and case, so it stays strictly
|
|
35
|
+
* narrower than (every match it finds, `foldSymbols`-based folded
|
|
36
|
+
* substring containment also finds) the trigram index's own folding,
|
|
37
|
+
* which is what makes it safe to confirm trigram-index candidates with
|
|
38
|
+
* (Section B.9). */
|
|
39
|
+
export function foldDiacritics(text) {
|
|
40
|
+
return text.normalize("NFD").replace(COMBINING, "").toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
function splitTokens(text) {
|
|
43
|
+
return text.split(/[^\p{L}\p{N}]+/u).filter((token) => token.length > 0);
|
|
44
|
+
}
|
|
45
|
+
/** Lowercases and splits on any run of non-letter/non-digit characters,
|
|
46
|
+
* so "Nieuw-Amsterdam" and "Nieuw Amsterdam" tokenize identically
|
|
47
|
+
* regardless of which separator the data used. Unicode-aware (`\p{L}`),
|
|
48
|
+
* so accented letters count as letters rather than splitting a word in
|
|
49
|
+
* two. Case-only -- no diacritic folding; see `tokenizeFolded` for that.
|
|
50
|
+
* Kept separate so `nameMatchQuality` can tell whether a match needed
|
|
51
|
+
* folding to reach its tier (the "exact spelling" bonus). */
|
|
52
|
+
export function tokenize(text) {
|
|
53
|
+
return splitTokens(text.toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
/** Like `tokenize`, but folds diacritics first (see `foldDiacritics`). */
|
|
56
|
+
export function tokenizeFolded(text) {
|
|
57
|
+
return splitTokens(foldDiacritics(text));
|
|
58
|
+
}
|
|
59
|
+
/** True if `needleTokens` occurs as a contiguous run within `tokens` --
|
|
60
|
+
* i.e. the query matches one or more *whole* words of the name, not
|
|
61
|
+
* just a substring that happens to straddle a word boundary. */
|
|
62
|
+
export function containsTokenRun(tokens, needleTokens) {
|
|
63
|
+
if (needleTokens.length === 0 || needleTokens.length > tokens.length)
|
|
64
|
+
return false;
|
|
65
|
+
search: for (let i = 0; i + needleTokens.length <= tokens.length; i++) {
|
|
66
|
+
for (let j = 0; j < needleTokens.length; j++) {
|
|
67
|
+
if (tokens[i + j] !== needleTokens[j])
|
|
68
|
+
continue search;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** `text` as a `Needle`, for scoring many names against one query. */
|
|
75
|
+
export function prepareNeedle(text) {
|
|
76
|
+
return {
|
|
77
|
+
raw: text.toLowerCase(),
|
|
78
|
+
rawTokens: tokenize(text),
|
|
79
|
+
folded: foldDiacritics(text),
|
|
80
|
+
foldedTokens: tokenizeFolded(text),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** A plain substring match. Spaced ten apart from the next tier (and the
|
|
84
|
+
* next from `QUALITY_EXACT`) so `EXACT_SPELLING_BONUS` and
|
|
85
|
+
* `LEVEL_BONUS` can never add up to enough to cross into a higher tier --
|
|
86
|
+
* quality always dominates level and spelling exactness, which only ever
|
|
87
|
+
* break a tie within the same tier. */
|
|
88
|
+
export const QUALITY_SUBSTRING = 10;
|
|
89
|
+
/** The query's tokens occur in the field's tokens as a contiguous run. */
|
|
90
|
+
export const QUALITY_WHOLE_WORD = 20;
|
|
91
|
+
/** The field equals the query. */
|
|
92
|
+
export const QUALITY_EXACT = 40;
|
|
93
|
+
/** Added when a field reaches its tier (substring, whole-word, or exact)
|
|
94
|
+
* without needing diacritic folding at all -- i.e. the query's raw
|
|
95
|
+
* spelling, as typed, was already enough. Rewards typing a name's
|
|
96
|
+
* diacritics correctly over a folding-only match of the same tier,
|
|
97
|
+
* without ever letting a folding-only match of a *better* tier lose to
|
|
98
|
+
* an exact-spelling match of a worse one (`QUALITY_SUBSTRING`'s best case,
|
|
99
|
+
* 11, is still well under `QUALITY_WHOLE_WORD`'s worst, 20). */
|
|
100
|
+
export const EXACT_SPELLING_BONUS = 1;
|
|
101
|
+
/** The best any single field can ever score. */
|
|
102
|
+
export const QUALITY_MAX = QUALITY_EXACT + EXACT_SPELLING_BONUS;
|
|
103
|
+
/** `0` if `normalized` doesn't contain `needle` at all, else the tier
|
|
104
|
+
* `normalized` reaches against `needle`/`needleTokens` -- both already
|
|
105
|
+
* normalized the same way as `normalized` itself (both raw, or both
|
|
106
|
+
* folded, never mixed). */
|
|
107
|
+
function tierQuality(normalized, needle, needleTokens) {
|
|
108
|
+
if (!normalized.includes(needle))
|
|
109
|
+
return 0;
|
|
110
|
+
if (normalized === needle)
|
|
111
|
+
return QUALITY_EXACT;
|
|
112
|
+
if (containsTokenRun(splitTokens(normalized), needleTokens))
|
|
113
|
+
return QUALITY_WHOLE_WORD;
|
|
114
|
+
return QUALITY_SUBSTRING;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A name's match quality against `needle`: the diacritic-folded
|
|
118
|
+
* comparison decides which tier it reaches (0 for no match, otherwise
|
|
119
|
+
* `QUALITY_SUBSTRING`/`QUALITY_WHOLE_WORD`/`QUALITY_EXACT`), and the raw
|
|
120
|
+
* (unfolded) comparison decides whether `EXACT_SPELLING_BONUS` applies --
|
|
121
|
+
* it does whenever the raw comparison reaches *at least* the same tier,
|
|
122
|
+
* which folding can only ever match or improve on for the same
|
|
123
|
+
* name/needle pair (never make worse), so "reaches at least" is exactly
|
|
124
|
+
* "didn't need folding to get here".
|
|
125
|
+
*
|
|
126
|
+
* Only a substring match counts at all, so sorting ranks the same set
|
|
127
|
+
* unsorted search finds: without that, " di " would match "Di" by its
|
|
128
|
+
* tokens alone, and a search index (which only guarantees folded
|
|
129
|
+
* substring candidates, Appendix B.9) would miss it.
|
|
130
|
+
*/
|
|
131
|
+
export function nameMatchQuality(name, needle) {
|
|
132
|
+
const folded = tierQuality(foldDiacritics(name), needle.folded, needle.foldedTokens);
|
|
133
|
+
if (folded === 0)
|
|
134
|
+
return 0;
|
|
135
|
+
const raw = tierQuality(name.toLowerCase(), needle.raw, needle.rawTokens);
|
|
136
|
+
return folded + (raw >= folded ? EXACT_SPELLING_BONUS : 0);
|
|
137
|
+
}
|
|
138
|
+
/** The best `nameMatchQuality` over a place's names. */
|
|
139
|
+
export function placeMatchQuality(names, needle) {
|
|
140
|
+
let best = 0;
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
best = Math.max(best, nameMatchQuality(name, needle));
|
|
143
|
+
if (best === QUALITY_MAX)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return best;
|
|
147
|
+
}
|
|
148
|
+
/** `quality` without `EXACT_SPELLING_BONUS`: the folded tier alone
|
|
149
|
+
* (`QUALITY_SUBSTRING`, `QUALITY_WHOLE_WORD` or `QUALITY_EXACT`), or 0. */
|
|
150
|
+
export function qualityTier(quality) {
|
|
151
|
+
return quality - (quality % QUALITY_SUBSTRING);
|
|
152
|
+
}
|
|
153
|
+
// ── Area order (Appendix B.5.1) ───────────────────────────────────────────────
|
|
154
|
+
/**
|
|
155
|
+
* Appendix C.3's area byte for an area of `cells` full-precision cells:
|
|
156
|
+
* `0` for an empty area, otherwise the bit length of `cells^4`, at most
|
|
157
|
+
* 255 -- `1 + floor(4 × log2(cells))`, a quarter of a doubling per step,
|
|
158
|
+
* computed exactly. `cells` must be a non-negative integer.
|
|
159
|
+
*/
|
|
160
|
+
export function areaByte(cells) {
|
|
161
|
+
if (cells <= 0)
|
|
162
|
+
return 0;
|
|
163
|
+
const bits = (BigInt(cells) ** 4n).toString(2).length;
|
|
164
|
+
return Math.min(bits, 255);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The rank area of a region (Appendix B.5.1): its own area byte, unless
|
|
168
|
+
* one or more of its cities has the same local name (field 0) once folded
|
|
169
|
+
* (`foldDiacritics`) -- then the largest area byte among those cities, so
|
|
170
|
+
* the province of Utrecht ranks right after the city of Utrecht instead of
|
|
171
|
+
* ahead of it for being larger. A city-state with no same-named city of
|
|
172
|
+
* its own (Berlin, Vienna) keeps its own area. Countries and cities rank by
|
|
173
|
+
* their own area byte.
|
|
174
|
+
*/
|
|
175
|
+
export function regionRankArea(regionLocalName, regionArea, cities) {
|
|
176
|
+
const folded = foldDiacritics(regionLocalName);
|
|
177
|
+
let best = -1;
|
|
178
|
+
for (const city of cities) {
|
|
179
|
+
if (city.area > best && foldDiacritics(city.localName) === folded)
|
|
180
|
+
best = city.area;
|
|
181
|
+
}
|
|
182
|
+
return best < 0 ? regionArea : best;
|
|
183
|
+
}
|
|
184
|
+
/** Appendix B.5's relevance order, as a sort comparator: score
|
|
185
|
+
* (`quality + LEVEL_BONUS`) descending, then context quality descending,
|
|
186
|
+
* then id ascending. */
|
|
187
|
+
export function compareRelevance(a, b) {
|
|
188
|
+
return (b.quality + LEVEL_BONUS[b.level] - (a.quality + LEVEL_BONUS[a.level]) ||
|
|
189
|
+
b.contextQuality - a.contextQuality ||
|
|
190
|
+
a.id - b.id);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Appendix B.5.1's area order, as a sort comparator: folded tier, then
|
|
194
|
+
* context quality, then countries ahead of regions and cities, then rank
|
|
195
|
+
* area, then the exact spelling bonus, then city ahead of region, then id
|
|
196
|
+
* -- each only breaking ties left by the ones before it. Area comes before
|
|
197
|
+
* the spelling bonus so that typing "zurich" can't put a hamlet literally
|
|
198
|
+
* named Zurich ahead of the much larger Zürich.
|
|
199
|
+
*/
|
|
200
|
+
export function compareAreaRelevance(a, b) {
|
|
201
|
+
return (qualityTier(b.quality) - qualityTier(a.quality) ||
|
|
202
|
+
b.contextQuality - a.contextQuality ||
|
|
203
|
+
Number(b.level === "country") - Number(a.level === "country") ||
|
|
204
|
+
b.rankArea - a.rankArea ||
|
|
205
|
+
(b.quality % QUALITY_SUBSTRING) - (a.quality % QUALITY_SUBSTRING) ||
|
|
206
|
+
LEVEL_BONUS[b.level] - LEVEL_BONUS[a.level] ||
|
|
207
|
+
a.id - b.id);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The readings of a search text (Appendix B.13): the whole text as typed,
|
|
211
|
+
* with no contexts, and for every comma in it, the text before that comma
|
|
212
|
+
* as the head (trimmed) and each comma-separated part after it as a
|
|
213
|
+
* context (trimmed, empty ones dropped). "Paris, Texas" is both a place
|
|
214
|
+
* named "Paris, Texas" and a "Paris" under a "Texas"; a trailing comma
|
|
215
|
+
* ("Paris,") reads as "Paris" alone. Duplicate readings are dropped.
|
|
216
|
+
*/
|
|
217
|
+
export function parseQualifiedQuery(text) {
|
|
218
|
+
const readings = [{ head: text, contexts: [] }];
|
|
219
|
+
const parts = text.split(",");
|
|
220
|
+
const seen = new Set([JSON.stringify(readings[0])]);
|
|
221
|
+
for (let k = 1; k < parts.length; k++) {
|
|
222
|
+
const head = parts.slice(0, k).join(",").trim();
|
|
223
|
+
if (head.length === 0)
|
|
224
|
+
continue;
|
|
225
|
+
const contexts = parts
|
|
226
|
+
.slice(k)
|
|
227
|
+
.map((part) => part.trim())
|
|
228
|
+
.filter((part) => part.length > 0);
|
|
229
|
+
const reading = { head, contexts };
|
|
230
|
+
const key = JSON.stringify(reading);
|
|
231
|
+
if (seen.has(key))
|
|
232
|
+
continue;
|
|
233
|
+
seen.add(key);
|
|
234
|
+
readings.push(reading);
|
|
235
|
+
}
|
|
236
|
+
return readings;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* How well `contexts` match `ancestors` (nearest first, each a name list):
|
|
240
|
+
* the best total quality over every way of matching context `i` to an
|
|
241
|
+
* ancestor further up than context `i - 1`'s, or 0 when there is no such
|
|
242
|
+
* way -- including when there are more contexts than ancestors. Every
|
|
243
|
+
* context must match (Appendix B.13). `contexts` must be non-empty.
|
|
244
|
+
*/
|
|
245
|
+
export function contextQuality(contexts, ancestors) {
|
|
246
|
+
// best[j]: the best total for the contexts so far, the last one matched
|
|
247
|
+
// to ancestor j (-1: impossible). Few contexts and at most two
|
|
248
|
+
// ancestors, so this is tiny.
|
|
249
|
+
let best = ancestors.map((names) => placeMatchQuality(names, contexts[0]));
|
|
250
|
+
best = best.map((q) => (q > 0 ? q : -1));
|
|
251
|
+
for (let i = 1; i < contexts.length; i++) {
|
|
252
|
+
const next = ancestors.map(() => -1);
|
|
253
|
+
let prefix = -1;
|
|
254
|
+
for (let j = 0; j < ancestors.length; j++) {
|
|
255
|
+
if (prefix >= 0) {
|
|
256
|
+
const q = placeMatchQuality(ancestors[j], contexts[i]);
|
|
257
|
+
if (q > 0)
|
|
258
|
+
next[j] = prefix + q;
|
|
259
|
+
}
|
|
260
|
+
prefix = Math.max(prefix, best[j]);
|
|
261
|
+
}
|
|
262
|
+
best = next;
|
|
263
|
+
}
|
|
264
|
+
return Math.max(0, ...best);
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=relevance.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
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 type { CGeoDataReader } from "./reader.ts";
|
|
20
|
+
import { type SourceInput } from "./source.ts";
|
|
21
|
+
/** The folded form (`foldDiacritics`: NFD, drop combining diacritics,
|
|
22
|
+
* lowercase), then one symbol per code point: a-z -> 0-25, 0-9 -> 27,
|
|
23
|
+
* other ASCII -> 26, everything from U+0080 up -> its own code point.
|
|
24
|
+
* Section B.2 defines the symbol sequence as *continuing from* the folded
|
|
25
|
+
* form, so this must reuse `foldDiacritics` rather than repeat it -- two
|
|
26
|
+
* copies of the first three steps could drift, and the index's
|
|
27
|
+
* never-lose-a-match guarantee (Section B.9) rests on them being
|
|
28
|
+
* identical. */
|
|
29
|
+
export declare function foldSymbols(text: string): number[];
|
|
30
|
+
/** 32-bit FNV-1a. */
|
|
31
|
+
export declare function fnv1a32(bytes: Uint8Array): number;
|
|
32
|
+
/** The list index of trigram `(a, b, c)`: exact for Latin symbols,
|
|
33
|
+
* otherwise FNV-1a 32 of the three symbols as u24s, xor-folded to
|
|
34
|
+
* `hashExp` bits. */
|
|
35
|
+
export declare function trigramListIndex(a: number, b: number, c: number, hashExp: number): number;
|
|
36
|
+
/** The distinct list indices of `text`'s trigrams, in order of first
|
|
37
|
+
* occurrence; empty when it folds to fewer than 3 symbols. */
|
|
38
|
+
export declare function trigramListIndices(text: string, hashExp: number): number[];
|
|
39
|
+
/** `relevance_rule` of a short-query table in Section B.5's plain order. */
|
|
40
|
+
export declare const RELEVANCE_PLAIN = 0;
|
|
41
|
+
/** `relevance_rule` of a short-query table in Section B.5.1's area order. */
|
|
42
|
+
export declare const RELEVANCE_AREA = 1;
|
|
43
|
+
/** Parameters of a search index (Appendix B.11); every one has a
|
|
44
|
+
* recommended default (`DEFAULT_SEARCH_INDEX_OPTIONS`). */
|
|
45
|
+
export interface SearchIndexOptions {
|
|
46
|
+
/** 2^hashExp shared lists for non-Latin trigrams (1-24). */
|
|
47
|
+
hashExp?: number;
|
|
48
|
+
/** Places per short-query entry; 0 leaves the table out (0-255). */
|
|
49
|
+
topCount?: number;
|
|
50
|
+
/** 2^topBlockExp entries per short-query table block (0-24). */
|
|
51
|
+
topBlockExp?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Which order to build the short-query table in, as
|
|
54
|
+
* `SearchOptions.areaTieBreak` would rank a search: `"auto"` by area
|
|
55
|
+
* when the reader has a locate index (`CGeoDataReader.placeAreas()`),
|
|
56
|
+
* `true` by area (rejecting without one), `false` plainly. A reader
|
|
57
|
+
* only answers from the table when it ranks the same way, so build it
|
|
58
|
+
* the way the index will be used: with `"auto"`, after the locate index.
|
|
59
|
+
*/
|
|
60
|
+
areaTieBreak?: "auto" | boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Appendix B.11's recommended parameters. */
|
|
63
|
+
export declare const DEFAULT_SEARCH_INDEX_OPTIONS: Readonly<Required<SearchIndexOptions>>;
|
|
64
|
+
/** Builds a search index (Appendix B) over every place in `reader` and
|
|
65
|
+
* returns the file's bytes. A pure function of the place table and the
|
|
66
|
+
* options: the same input always gives the same bytes. */
|
|
67
|
+
export declare function buildSearchIndex(reader: CGeoDataReader, options?: SearchIndexOptions): Promise<Uint8Array>;
|
|
68
|
+
/**
|
|
69
|
+
* An open search index file (Appendix B). Opening reads and validates the
|
|
70
|
+
* header only. The list directory, each list, the table directory, and
|
|
71
|
+
* each table block are read the first time a query needs them, and kept
|
|
72
|
+
* behind WeakRefs, so an index that goes unused for weeks costs no memory
|
|
73
|
+
* beyond its header.
|
|
74
|
+
*
|
|
75
|
+
* Opening does NOT verify the source checksum -- `CGeoDataReader`'s
|
|
76
|
+
* `useSearchIndex()` does that against the file it's attached to.
|
|
77
|
+
*/
|
|
78
|
+
export declare class SearchIndex {
|
|
79
|
+
private listDirectoryCache;
|
|
80
|
+
private readonly listCache;
|
|
81
|
+
private tableDirectoryCache;
|
|
82
|
+
private readonly tableBlockCache;
|
|
83
|
+
private readonly source;
|
|
84
|
+
/** The index file's name, when its source has one (`ByteSource.name`). */
|
|
85
|
+
readonly name?: string;
|
|
86
|
+
/** Checksum of the source it was built from (Section B.8): of its
|
|
87
|
+
* place table, or for `RELEVANCE_AREA`, of everything before its first
|
|
88
|
+
* chunk. A reader only accepts the index when it matches its own. */
|
|
89
|
+
readonly sourceChecksum: bigint;
|
|
90
|
+
/** N, the number of places in the source; every one is indexed. */
|
|
91
|
+
readonly placeCount: number;
|
|
92
|
+
/** 2^hashExp shared lists for non-Latin trigrams (Section B.3). */
|
|
93
|
+
readonly hashExp: number;
|
|
94
|
+
/** Places per short-query entry; 0 when the file has no table. */
|
|
95
|
+
readonly topCount: number;
|
|
96
|
+
/** 2^topBlockExp short-query entries per table block (Section B.6). */
|
|
97
|
+
readonly topBlockExp: number;
|
|
98
|
+
/** The order its short-query table is in: `RELEVANCE_PLAIN` (Section
|
|
99
|
+
* B.5) or `RELEVANCE_AREA` (Section B.5.1). */
|
|
100
|
+
readonly relevanceRule: number;
|
|
101
|
+
private readonly listDirectoryStart;
|
|
102
|
+
private readonly listDirectoryLength;
|
|
103
|
+
private readonly listsStart;
|
|
104
|
+
private readonly listsLength;
|
|
105
|
+
private readonly tableDirectoryStart;
|
|
106
|
+
private readonly tableDirectoryLength;
|
|
107
|
+
private readonly tableBlocksStart;
|
|
108
|
+
private readonly tableBlocksLength;
|
|
109
|
+
private constructor();
|
|
110
|
+
/** Opens a search index file (read on demand, like a `.bin`),
|
|
111
|
+
* validating its header (Section B.10). Takes ownership of a
|
|
112
|
+
* `ByteSource`: `close()` closes it, as does a failed open. */
|
|
113
|
+
static open(input: SourceInput): Promise<SearchIndex>;
|
|
114
|
+
private static parseHeader;
|
|
115
|
+
/** Closes the index's source. */
|
|
116
|
+
close(): Promise<void>;
|
|
117
|
+
/** L = 28^3 + 2^hashExp possible list indices. */
|
|
118
|
+
get listCount(): number;
|
|
119
|
+
/**
|
|
120
|
+
* Section B.9 steps 2-4: every place whose names could contain `text`,
|
|
121
|
+
* ascending -- a superset of the real matches, which the caller still
|
|
122
|
+
* has to confirm name by name. Undefined when `text` folds to fewer
|
|
123
|
+
* than 3 symbols, i.e. the lists can't rule anything out.
|
|
124
|
+
*/
|
|
125
|
+
candidates(text: string): Promise<number[] | undefined>;
|
|
126
|
+
/**
|
|
127
|
+
* Section B.9 step 1: the short-query table's entry for `text` -- its
|
|
128
|
+
* top `topCount` places in relevance order (Section B.5), or `[]` when
|
|
129
|
+
* nothing matches. Undefined when the file has no table or `text`
|
|
130
|
+
* isn't one or two code points long once folded.
|
|
131
|
+
*
|
|
132
|
+
* The key is `foldDiacritics(text)`, so an accented query and its
|
|
133
|
+
* unaccented spelling land on the same entry -- which is only the
|
|
134
|
+
* right answer for a query that needed no folding in the first place.
|
|
135
|
+
* Callers MUST therefore apply Section B.9 step 1's own precondition
|
|
136
|
+
* (the query's folded form already equals its plain lowercase form)
|
|
137
|
+
* before trusting this; `CGeoDataReader.searchRanked` does.
|
|
138
|
+
*/
|
|
139
|
+
shortQuery(text: string): Promise<number[] | undefined>;
|
|
140
|
+
private listDirectory;
|
|
141
|
+
/** One stored list's bytes. Shared with later queries through the
|
|
142
|
+
* cache: callers MUST NOT modify it. */
|
|
143
|
+
private list;
|
|
144
|
+
private decodeIdList;
|
|
145
|
+
private expandBitmap;
|
|
146
|
+
private tableDirectory;
|
|
147
|
+
private tableBlock;
|
|
148
|
+
}
|