@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fernando van Loenhout
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,314 @@
1
- # Temporary Holding Version
1
+ # @ferrybig/cgeodata-reader
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Turns a latitude/longitude into the country, region and city it lies in,
4
+ using real administrative boundaries from OpenStreetMap rather than "the
5
+ nearest labelled point", which goes wrong near borders. Lookups run
6
+ in-process against a single CGEODATA file (a worldwide one is about
7
+ 45 MiB), reading and decoding only the small part of the file each lookup
8
+ needs.
9
+
10
+ - No runtime dependencies. Works in Node.js (24 or newer) and in browsers.
11
+ Also reads files of the format's previous version, 0, whose chunks
12
+ decode with WebAssembly SIMD where the platform allows it, and in
13
+ plain JavaScript (about twice as slow) where it doesn't, such as a
14
+ page whose Content Security Policy lacks `'wasm-unsafe-eval'`.
15
+ - Async throughout: reads never block, and the event loop is never held
16
+ for more than one chunk's decode.
17
+ - Includes place search (diacritic-insensitive, all alternative names)
18
+ and a representative coordinate for every place.
19
+
20
+ The file format is specified in [binary-format.md](binary-format.md),
21
+ which ships with this package.
22
+
23
+ ## Quick start (Node.js)
24
+
25
+ ```ts
26
+ import { openFile } from "@ferrybig/cgeodata-reader/node";
27
+
28
+ const reader = await openFile("world.bin");
29
+
30
+ const result = await reader.query(35.6762, 139.6503);
31
+ result.country?.internationalName; // "Japan"
32
+ result.region?.localName; // "東京都"
33
+ result.region?.internationalName; // "Tokyo"
34
+ result.city?.localName; // "杉並区"
35
+ result.city?.internationalName; // "Suginami"
36
+
37
+ await reader.close();
38
+ ```
39
+
40
+ `query()` returns `null` for a level that doesn't apply: `city` for a point
41
+ that only resolves to a region, all three in the open ocean. Its
42
+ `boundingBox` is the area the answer holds for. Any point inside it gives
43
+ the same result.
44
+
45
+ ## In a browser
46
+
47
+ ```ts
48
+ import { CGeoDataReader } from "@ferrybig/cgeodata-reader";
49
+
50
+ // The whole file in memory...
51
+ const bytes = await (await fetch("/data/world.bin")).arrayBuffer();
52
+ const reader = await CGeoDataReader.open(bytes);
53
+
54
+ // ...or a File the user picked, read range by range as lookups need it.
55
+ const fromDisk = await CGeoDataReader.open(fileInput.files[0]);
56
+ ```
57
+
58
+ `CGeoDataReader.open()` accepts an `ArrayBuffer`, a `Uint8Array`, a `Blob`
59
+ or `File`, or any `ByteSource` (an object with a `size` and an async
60
+ `read(offset, length)`). With a `ByteSource` you can serve ranges from
61
+ anywhere, for example HTTP range requests. Compressed blocks are inflated
62
+ with the platform's `DecompressionStream`.
63
+
64
+ ## Many lookups close together
65
+
66
+ Each lookup decodes the chunk of the file its point lies in, then keeps
67
+ it cached. Points close together mostly share a chunk, so most lookups
68
+ can answer from memory. `lookupCached()` does exactly that, synchronously,
69
+ and returns `undefined` when answering would need a read:
70
+
71
+ ```ts
72
+ for (const [lat, lon] of points) {
73
+ const { placeId, boundingBox } =
74
+ reader.lookupCached(lat, lon) ?? (await reader.lookup(lat, lon));
75
+ if (placeId === null) continue; // nothing here
76
+ const { level, country, region, city } = reader.placeIds(placeId); // sync, no names
77
+ // ...
78
+ }
79
+ ```
80
+
81
+ `lookup()` is `query()` without the names. It returns the place's id,
82
+ which `place(id)` resolves to names and `placeIds(id)` resolves
83
+ synchronously to the ids of the place and its ancestors. Two results with
84
+ the same `placeId` are the same place, so per-place work (a colour, a
85
+ label) can be keyed on it. A map tile renderer combines all of this: it
86
+ uses `boundingBox` to fill every pixel a result covers, and looks up names
87
+ once per place rather than once per pixel.
88
+
89
+ ## Names: `localName` and `internationalName`
90
+
91
+ Every place has a name in its own language and script, and usually an
92
+ international (English) one:
93
+
94
+ | | `localName` | `internationalName` | `names` |
95
+ |---|---|---|---|
96
+ | Tokyo | 東京都 | Tokyo | `["東京都", "Tokyo"]` |
97
+ | The Hague | Den Haag | The Hague | `["Den Haag", "The Hague"]` |
98
+ | Amsterdam | Amsterdam | Amsterdam | `["Amsterdam"]` |
99
+
100
+ `internationalName` falls back to `localName` when there's no separate
101
+ international name. When a name is stored once and shown to everyone,
102
+ whatever their language, use `internationalName`. **Don't** index into
103
+ `names`: `names[0]` is the local name, so it gives non-Latin script for
104
+ every place in Japan, Korea, China, Russia and many other countries.
105
+ `names` lists every name, alternative spellings included. It's what
106
+ `search()` matches against.
107
+
108
+ ## Search, and a coordinate per place
109
+
110
+ ```ts
111
+ for await (const place of reader.search("munchen", { sort: true, limit: 20 })) {
112
+ place.internationalName; // "Munich"
113
+ place.country.internationalName; // "Germany"
114
+ const position = await reader.locate(place.id); // { lat, lon, boundingBox, area } or null
115
+ }
116
+ ```
117
+
118
+ Search is case- and diacritic-insensitive ("munchen" finds München). It
119
+ matches any part of any name, alternative names included. With
120
+ `sort: true`, an exact name beats a whole-word match, which beats a plain
121
+ substring match, and among equal matches a country comes first. Filter
122
+ with `level`, `country` or `region` (place ids).
123
+
124
+ **Equal matches are ranked by area when a locate index is attached**
125
+ (`areaTieBreak: "auto"`, the default): "berlin" puts Berlin, Germany
126
+ ahead of the towns called Berlin in the United States, and a province
127
+ still comes right after its own same-named city. Without a locate index
128
+ they fall back to city before region, then id order, which is close to
129
+ arbitrary. So attaching a locate index changes the *order* of sorted
130
+ results, never which places match. Pass `areaTieBreak: true` to insist on
131
+ area order (it rejects without a locate index) or `false` to opt out.
132
+ Area is a size signal, not fame: a large rural Portland parish in Jamaica
133
+ still outranks Portland, Oregon.
134
+
135
+ A comma narrows by the places a match lies in: `"Paris, Texas"`,
136
+ `"Springfield, Illinois, United States"`. Each part after a comma must
137
+ match a different, further-up ancestor (region, then country). The whole
138
+ text is still matched as a name too, for the places whose names contain
139
+ a comma.
140
+
141
+ `locate(id)` returns a point inside the place: `query()` at that point
142
+ resolves to the place or something inside it. It **can return `null` for
143
+ a real place that `search()` finds**: a place smaller than the file's
144
+ finest cell (a tiny island, an enclave, a boundary sliver) has no cells
145
+ of its own. That's about 0.15% of a worldwide file. Leave those out of
146
+ results that need a position. Don't default them to a coordinate.
147
+
148
+ `area` is the place's approximate size in square degrees (within about
149
+ 9%), everything under it included. To zoom a Web Mercator map to it,
150
+ `Math.log2(360 / Math.sqrt(area))` is the zoom at which a square of that
151
+ area spans one 256-pixel tile; subtract a little to leave a margin.
152
+
153
+ `search()` and `places()` are async iterators. Collect them with
154
+ `for await`, or with `Array.fromAsync()` where your runtime has it.
155
+
156
+ ## Ship the index files too
157
+
158
+ Two sidecar files make search and locate fast. On a server they're
159
+ effectively **required**:
160
+
161
+ | File | Without it |
162
+ |---|---|
163
+ | `world.search-index.bin` | every `search()` scans every name: ~430 ms on a worldwide file |
164
+ | `world.locate-index.bin` | every `locate()` decodes the whole file: ~1.2 s on a worldwide file |
165
+
166
+ `openFile("world.bin")` picks up `world.search-index.bin` and
167
+ `world.locate-index.bin` next to it, the first time a `search()` or
168
+ `locate()` can use them. An index that doesn't belong to the file (built
169
+ from an older build of it, say) makes those calls reject with a
170
+ `CGeoDataIndexMismatchError` naming the index file. The reader never
171
+ falls back to a slow scan silently, so rebuild the index or delete it.
172
+ Elsewhere, pass the indexes yourself:
173
+
174
+ ```ts
175
+ const reader = await CGeoDataReader.open(bytes, {
176
+ searchIndex: await (await fetch("/data/world.search-index.bin")).arrayBuffer(),
177
+ locateIndex: await (await fetch("/data/world.locate-index.bin")).arrayBuffer(),
178
+ });
179
+ ```
180
+
181
+ `cgeodata build-locate-index world.bin --output world.locate-index.bin`
182
+ and `cgeodata build-search-index ...` build them (see [CLI](#cli)).
183
+ **Build the locate index first.** The search index stores the top
184
+ results of 1-2 character searches ranked the same way the reader will
185
+ rank them. With a locate index next to the `.bin`, it ranks them by area,
186
+ matching the reader's default. Built without one, sorted 1-2 character
187
+ searches can't use it once a locate index is present. They still return
188
+ the same results, just more slowly (~0.6 s on a worldwide file).
189
+
190
+ Only the `.bin` needs distributing: both indexes are a pure function of
191
+ it and can be built where it's used.
192
+
193
+ ## Memory and the event loop
194
+
195
+ - **Cache**: decoded chunks and names are kept in a least-recently-used
196
+ cache, by default up to 64 MiB (`open(input, { cacheBytes })`). A
197
+ worldwide file decodes to about 690 MB in all and its largest chunk to
198
+ about 1.9 MB. Lookups in a few regions fit easily. Lookups scattered over
199
+ a whole continent re-decode chunks unless you give the cache more room.
200
+ `0` turns caching off.
201
+ - **Event loop**: every read is asynchronous, and decodes take turns, one
202
+ per macrotask, even when many lookups run at once. The longest the
203
+ reader holds the event loop is one chunk's decode: under 1 ms for most
204
+ chunks, about 3 ms for the largest chunk of a worldwide file (8 ms
205
+ for a version 0 file without WebAssembly SIMD).
206
+ - **Decoding**: the WebAssembly decoder (version 0 files only) keeps one
207
+ block of memory, as big as the largest chunk it has decoded (about
208
+ 1.8 MB for a worldwide file), while decodes follow each other, and
209
+ frees it once none has run for a second. An idle reader holds only its cache.
210
+ - **Synchronous methods** answer from memory only: `lookupCached()`,
211
+ `placeIds()`, `chunkIndexAt()`, `subtreeIdRanges()`, `placeCounts`,
212
+ `header`.
213
+ - **Closing**: `await reader.close()` closes the file. A reader that's
214
+ garbage collected without being closed has its file closed then, rather
215
+ than leaking it.
216
+
217
+ ## Errors
218
+
219
+ Problems with the data reject with one of these, all exported from the
220
+ package root:
221
+
222
+ | Class | Meaning |
223
+ |---|---|
224
+ | `CGeoDataFormatError` | the `.bin` or an index file is malformed or truncated. Download it again. |
225
+ | `CGeoDataVersionError` | (a `CGeoDataFormatError`) the file uses a format version this reader doesn't know (`error.version`). Upgrade the reader. |
226
+ | `CGeoDataUnsupportedError` | (a `CGeoDataFormatError`) a valid file this reader won't read: `max_depth` past 16. Rebuild it with a bigger chunk grid. |
227
+ | `CGeoDataIndexMismatchError` | a valid index, but built from a different `.bin`. Rebuild or delete it. |
228
+ | `CGeoDataError` | base class of all of the above; also a read that returned fewer bytes than the file's size promised. |
229
+
230
+ Invalid arguments (a latitude outside [-90, 90), an id the file doesn't
231
+ have) are plain `RangeError`s.
232
+
233
+ ## What the data does and doesn't contain
234
+
235
+ Check these before migrating from another geocoder:
236
+
237
+ - **Three tiers: country, region, city.** There is no second
238
+ administrative level (GeoNames' `admin2`). A schema with a field for it
239
+ needs changing, not filling.
240
+ - **What "city" means varies by country.** The reader returns whichever
241
+ OpenStreetMap level covers that country best: municipalities in the
242
+ Netherlands, counties in most of the US, communes in France, localities
243
+ in Australia. See `countries.md` in the project root for each country.
244
+ - **Country names come from Natural Earth, in English.** `localName` and
245
+ `internationalName` are the same for countries. Some spellings differ
246
+ from GeoNames: "United States of America" (GeoNames: "United States"),
247
+ "North Macedonia" ("Macedonia"), "Macao" ("Macau"). Data you stored
248
+ earlier under the GeoNames spelling won't match new lookups until it's
249
+ looked up again.
250
+ - **Search ranks by size, not by importance.** With a locate index,
251
+ equally good matches are ordered by area ("berlin" puts the German
252
+ capital first); without one, the order among them is close to
253
+ arbitrary. Area isn't fame, though: "portland" still puts a Jamaican
254
+ parish before Portland, Oregon. Show enough results, and show each
255
+ one's region and country.
256
+ - **Attribution**: the data is derived from OpenStreetMap (ODbL), GeoNames
257
+ and Natural Earth. `reader.header.attribution` holds the notice the file
258
+ was built with. Show it wherever you show the results.
259
+
260
+ ## Entry points
261
+
262
+ | Import | Contents |
263
+ |---|---|
264
+ | `@ferrybig/cgeodata-reader` | `CGeoDataReader`, its result types, the error classes, `BytesSource` / `BlobSource` / `ByteSource`, `streamInflater`. Works everywhere. |
265
+ | `@ferrybig/cgeodata-reader/node` | `openFile()`, `FileSource`, `nodeInflater` (zlib, faster than `DecompressionStream` on Node.js), `searchIndexPathFor()` / `locateIndexPathFor()`. |
266
+ | `@ferrybig/cgeodata-reader/indexes` | `SearchIndex` / `LocateIndex`, to open an index once and share it between readers, and `buildSearchIndex()` / `buildLocateIndex()`. |
267
+ | `@ferrybig/cgeodata-reader/format` | The format's low-level codecs and parsers, for tools that write or inspect files. These follow the spec, not a stable API. |
268
+
269
+ The package is ESM, with `.d.ts` types included. `dist/` is prebuilt, so
270
+ you don't compile this package's sources.
271
+
272
+ ## CLI
273
+
274
+ The package installs `cgeodata` (also as `cgeodata-reader`), a command-line
275
+ front end to the same API. It needs a data file; the demo serves the
276
+ worldwide one, plus the two sidecar indexes that make `search` and
277
+ `--locate` fast (they are picked up automatically when they sit next to the
278
+ `.bin`):
279
+
280
+ ```sh
281
+ wget https://geodata.ferrybig.nl/download/world.bin \
282
+ https://geodata.ferrybig.nl/download/world.search-index.bin \
283
+ https://geodata.ferrybig.nl/download/world.locate-index.bin
284
+ ```
285
+
286
+ Then run it without installing anything:
287
+
288
+ ```sh
289
+ npx @ferrybig/cgeodata-reader lookup world.bin 52.37,4.9
290
+ ```
291
+
292
+ or, installed, as `cgeodata`:
293
+
294
+ ```sh
295
+ cgeodata lookup world.bin 52.37,4.9 # or points on stdin, one per line
296
+ cgeodata search world.bin amsterdam --sort --locate
297
+ cgeodata show world.bin 192858 # a place id from any other command
298
+ cgeodata info world.bin # header, counts, sizes
299
+ cgeodata build-locate-index world.bin --output world.locate-index.bin
300
+ cgeodata build-search-index world.bin --output world.search-index.bin # after the locate index
301
+ cgeodata help [<command>]
302
+ ```
303
+
304
+ ## API reference
305
+
306
+ Every exported symbol has a doc comment, and your editor shows them.
307
+ `npm run docs` in this package's source generates an HTML reference
308
+ (TypeDoc) into `docs/`. The file format, including the exact lookup
309
+ procedure, is in [binary-format.md](binary-format.md).
310
+
311
+ ## License
312
+
313
+ MIT for the code. A data file carries the license and attribution of the
314
+ data it was built from (see above).