@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -2
  3. package/binary-format.md +2366 -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 +34 -0
  9. package/dist/chunk.js +269 -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 +21 -0
  43. package/dist/format.js +22 -0
  44. package/dist/header.d.ts +29 -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/reader.d.ts +534 -0
  61. package/dist/reader.js +1128 -0
  62. package/dist/relevance.d.ts +184 -0
  63. package/dist/relevance.js +266 -0
  64. package/dist/searchIndex.d.ts +148 -0
  65. package/dist/searchIndex.js +786 -0
  66. package/dist/source.d.ts +49 -0
  67. package/dist/source.js +50 -0
  68. package/dist/turns.d.ts +19 -0
  69. package/dist/turns.js +40 -0
  70. package/dist/varint.d.ts +52 -0
  71. package/dist/varint.js +137 -0
  72. 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
+ }