t3grid 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TriGrid contributors
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 ADDED
@@ -0,0 +1,461 @@
1
+ # Trifold T3 — a hierarchical triangular DGGS with *exact* nesting
2
+
3
+ **Triangles tile the sphere into a quadtree where every parent is *exactly*
4
+ the union of its four children — something neither hexagons nor most
5
+ square systems can offer — with a 6-character address for a ~110 km cell.**
6
+
7
+ **[Live demo & intro site](https://jaakla.github.io/trifold/)** · globe ↔ flat ·
8
+ 7 grid systems side by side · click any cell for its address ·
9
+ [![Technical reference](https://img.shields.io/badge/Technical%20Reference-Docs-blue?logo=github)](https://github.com/jaakla/trifold/blob/main/docs/t3-technical-reference.md)
10
+
11
+ ![global overview](docs/img/global_overview.png)
12
+
13
+ ---
14
+
15
+ ## 1. The idea in 30 seconds
16
+
17
+ Start from the icosahedron: 20 spherical triangles covering the Earth.
18
+ Split every triangle into 4 by connecting the great-circle midpoints of its
19
+ edges. Repeat. Each level halves the edge length and quadruples the cell
20
+ count (*aperture 4*):
21
+
22
+ | level | mean edge | mean area | cells (global) |
23
+ |---:|---:|---:|---:|
24
+ | 0 | 7,054 km | 25.5M km² | 20 |
25
+ | 3 | 882 km | 399k km² | 1,280 |
26
+ | 6 | 110 km | 6,226 km² | 81,920 |
27
+ | 9 | 13.8 km | 97 km² | 5.2M |
28
+ | 12 | 1.7 km | 1.5 km² | 336M |
29
+ | 15 | 215 m | 24 ha | 21.5B |
30
+
31
+ Because children are built from the parent's own vertices plus edge
32
+ midpoints, **a parent cell is bit-for-bit the union of its children**.
33
+ Aggregating data up the hierarchy or drilling down loses nothing and
34
+ double-counts nothing. That property — *exact nesting* (with limited size variation, about ±20%) — is the central property of this project and is uncommon among global grids (see [§6](#6-comparison-with-other-dggs)).
35
+
36
+ The repository contains the Python library, a three-form addressing codec,
37
+ global grid products generated against Natural Earth land, generators for
38
+ comparison grid systems, an interactive MapLibre demo (globe and flat),
39
+ and a Cloudflare Worker that computes cells on demand from the grid geometry.
40
+
41
+ ### SDK and application code
42
+
43
+ Core grid behavior is exposed through two standalone SDKs:
44
+
45
+ | Runtime | Public SDK | Install | Code using it |
46
+ |---|---|---|---|
47
+ | Python | `trifold.api` (also re-exported by `trifold`) | `pip install t3grid` | CLI and build scripts |
48
+ | JavaScript | `js/trifold.js` | `npm install t3grid` | Cloudflare Worker and website |
49
+
50
+ The distributable packages are named **`t3grid`** (the unscoped name `trifold`
51
+ was already taken on both registries); the Python import stays `import trifold`.
52
+ The landcheck application ships separately as `pip install landcheck` /
53
+ `npm install landcheck`.
54
+
55
+ The SDKs cover address codecs, hierarchy operations, point location, cell
56
+ geometry, metrics, and GeoJSON. Python land classification is an optional
57
+ extension under `trifold.land`. See the [SDK API reference](docs/sdk-api.md)
58
+ for the supported functions and examples.
59
+
60
+ ---
61
+
62
+ ## 2. Addressing: one identity, three encodings
63
+
64
+ A cell is identified by `(face, path)`: which of the 20 icosahedron faces
65
+ it lives on, and the sequence of base-4 digits choosing a child at every
66
+ subdivision (`0,1,2` = corner children toward the parent's vertices,
67
+ `3` = the central, orientation-flipped child).
68
+
69
+ The same identity has three interchangeable encodings, each optimized for
70
+ a different consumer:
71
+
72
+ | form | example (London, level 6) | for | size |
73
+ |---|---|---|---|
74
+ | **compact** | `TF6958` | humans, URLs, labels, CSV columns | 3 + ⌈2L/5⌉ chars |
75
+ | **path** | `F15-102111` | teaching, debugging — shows the tree descent | 4 + L chars |
76
+ | **addr64** | `8811996358392152070` | compute — sort, join, mask | 8 bytes |
77
+
78
+ **Why not just digits 0–3?** A digit string spends 8 bits per character to
79
+ carry 2 bits of information. The compact form re-encodes the *same* path
80
+ bits in Crockford base32 (5 bits/char, no ambiguous `I L O U`), prefixed
81
+ by face and level characters: `T` `F`(face 15) `6`(level 6) `958`(12 path
82
+ bits in 3 chars). Level 15 — sub-kilometre cells — still fits in 9
83
+ characters. Base64 would save little and is not URL-safe; raw binary is
84
+ not human-readable. Base32 provides a compact, URL-safe representation.
85
+
86
+ **The uint64 layout** packs face (5 bits) + up to 27 path digits (54 bits)
87
+ + level (5 bits). The path is left-aligned and the level is the low-bit
88
+ tie-breaker that places a parent before its descendants:
89
+
90
+ ```
91
+ 63 59 5 0
92
+ ┌─────────┬────────────────────────────────────────────────────────┬─────┐
93
+ │ face:5 │ path digits, 2 bits each, left-aligned │ L:5 │
94
+ └─────────┴────────────────────────────────────────────────────────┴─────┘
95
+ ```
96
+
97
+ Left-alignment and the low level field provide these properties:
98
+
99
+ * numeric sort = depth-first hierarchical order within a face
100
+ (Z-order curve — spatially adjacent cells tend to be numerically close);
101
+ * parent and child addresses are direct masks/shifts — no tree traversal;
102
+ * `is_ancestor(a, b)` = one shift and compare;
103
+ * `descendant_range(a)` returns the inclusive uint64 interval containing
104
+ that cell and all descendants, suitable for database range scans.
105
+
106
+ **Compatibility:** this corrected field order changes numeric `addr64`
107
+ values produced by the initial v0.1.0 code. Compact and path addresses are
108
+ unchanged; regenerate stored numeric IDs from either string form.
109
+
110
+ ```python
111
+ import trifold.api as tg
112
+ addr = tg.encode64(*tg.locate(-0.1276, 51.5072, level=6))
113
+ tg.to_compact(addr) # 'TF6958'
114
+ tg.to_path(addr) # 'F15-102111'
115
+ tg.to_compact(tg.parent64(addr)) # 'TF595'
116
+ [tg.to_compact(c) for c in tg.children64(addr)]
117
+ # ['TF7958', 'TF795A', 'TF795C', 'TF795E']
118
+ ```
119
+
120
+ Same answers from the command line (`pip install -e .`):
121
+
122
+ ```console
123
+ $ trifold locate -0.1276 51.5072 6
124
+ TF6958
125
+ $ trifold show TF6958
126
+ compact : TF6958
127
+ path : F15-102111
128
+ addr64 : 8811996358392152070 (0x7A4A800000000006)
129
+ level : 6
130
+ edge_km : 116.9
131
+ area_km2: 5864
132
+ $ trifold geom TF6958 > london_cell.geojson
133
+ ```
134
+
135
+ The same operations are available from the standalone
136
+ [JavaScript SDK](js/trifold.js) and the [Cloudflare Worker](worker/cell-server.js)
137
+ (`GET /locate/-0.1276,51.5072?level=6` → `TF6958`). The Worker is an HTTP
138
+ adapter over the SDK. The Python and JavaScript implementations are cross-tested.
139
+
140
+ ### Derived grouping keys
141
+
142
+ Every triangle can also be projected into two grouping indexes without
143
+ changing its geometry or accounting identity:
144
+
145
+ | property | role | behavior |
146
+ |---|---|---|
147
+ | `rhombus_id` | exact grouping | two triangles per rhombus on the complete grid |
148
+ | `rhombus_hilbert` | sort/partition key | Hilbert order within ten nested base diamonds |
149
+ | `hex_id` | display grouping | six triangles in face interiors; seam and vertex exceptions |
150
+
151
+ Rhombi have an exact aperture-4 hierarchy: a parent rhombus is the union of
152
+ four child rhombi. Hex groups are defined independently at each level and do
153
+ not nest. The face-local coloring produces three- or six-triangle seam groups
154
+ and fixed one- or five-triangle vertex groups, so `hex_id` is a visualization
155
+ and grouping key rather than a uniform global hex grid.
156
+
157
+ Land-filtered and compacted exports can contain partial groups when member
158
+ triangles fall outside the coverage or are represented at another level.
159
+ Grouped features include `triangle_count` to make this explicit.
160
+
161
+ ---
162
+
163
+ ## 3. Grid products
164
+
165
+ Built against Natural Earth 1:50m land, base level 6 (~110 km edges):
166
+
167
+ | product | cells | GeoJSON | TopoJSON |
168
+ |---|---:|---:|---:|
169
+ | **uncompacted** — every level-6 cell touching land | 27,614 | 14 MB | 9 MB |
170
+ | **compacted** — interior cells merged up the quadtree as far as they stay wholly on land; coast stays at level 6 | 10,046 | 6 MB | 3.5 MB |
171
+
172
+ Both cover the identical 171.1M km² (149M km² of land + the seaward
173
+ overhang of coastal cells), verified to 0 invalid geometries. Per-cell
174
+ properties: `id` (compact), `path`, `addr64`, `rhombus_id`,
175
+ `rhombus_hilbert`, `hex_id`, `level`, `interior`,
176
+ `edge_km`, `area_km2`, `pole`, `xam`.
177
+
178
+ TopoJSON is the recommended interchange form for grids: every triangle
179
+ edge is shared by two cells, so arc deduplication cuts size ~40–60%. To
180
+ make arcs shared even between *different-sized* neighbours in the
181
+ compacted grid, edges are densified by recursive midpoint subdivision to a
182
+ fixed sub-lattice — a large cell's boundary passes through its small
183
+ neighbours' vertices bit-exactly.
184
+
185
+ ### Special cases
186
+
187
+ * **Antimeridian.** Cells crossing ±180° are written with *continuous*
188
+ longitudes (e.g. `176 → 184`). This intentionally deviates from RFC 7946
189
+ §3.1.9 ("should be split"): splitting would destroy triangle semantics
190
+ and TopoJSON arc sharing, and MapLibre/Leaflet/deck.gl all render
191
+ continuous longitudes correctly. Cells carry `xam: true` so you can
192
+ re-split for strict-RFC consumers if needed. Classification of these
193
+ cells runs against land copies translated ±360°.
194
+ * **Poles.** A pleasing accident of the icosahedron's geometry: in this
195
+ orientation both poles are *lattice vertices* (the south pole is exactly
196
+ the normalized midpoint of an icosahedron edge), so six triangles meet
197
+ at each pole. They are exported as meridian wedges reaching exactly ±90°
198
+ — like UTM-zone tips — and flagged `pole: "vertex"`. Classification near
199
+ the poles runs in polar azimuthal-equidistant frames, where lon/lat
200
+ pathologies do not exist.
201
+ * **No samples, no shortcuts.** Land/sea classification is exact polygon
202
+ containment in an appropriate frame, not point sampling.
203
+
204
+ ---
205
+
206
+ ## 4. Suitable uses and limitations
207
+
208
+ * **Lossless multi-resolution aggregation.** Sum level-9 statistics into
209
+ level-6 cells and the numbers are *exact* — no boundary slivers, no
210
+ overlap weighting. This differs from non-congruent hexagonal hierarchies.
211
+ * **Variable-resolution coverage** (the compacted mode): one dataset,
212
+ coarse where uniform, fine where it matters, with cells that retain
213
+ shared boundaries. Database range scans over `addr64` retrieve
214
+ any subtree as one interval.
215
+ * **Simplicial data structures.** Triangles are *the* primitive of
216
+ numerical geometry: FEM/FVM meshes, terrain TINs, barycentric
217
+ interpolation, subdivision surfaces. A triangular DGGS plugs into that
218
+ machinery directly; quads and hexes need conversion.
219
+ * **Geodesic properties.** Cells are quasi-equilateral
220
+ everywhere — no polar singularity, no latitude-dependent area collapse
221
+ (a lon/lat grid cell at 80°N has ~17% of its equatorial area; Trifold
222
+ cells vary ~±20% worldwide, smoothly).
223
+ * **Sampling designs and ecology-style survey grids**, where equal-ish
224
+ area and hierarchical refinement matter more than neighbour traversal.
225
+
226
+ ### Limitations
227
+
228
+ * **Neighbour-heavy algorithms.** A triangle has 3 edge-neighbours but 9
229
+ more vertex-neighbours, and alternating up/down orientation makes
230
+ "movement" semantics less uniform. Hexagonal grids provide 6 uniform
231
+ neighbours for diffusion, routing, cellular automata, and related
232
+ analyses. (Neighbour traversal across icosahedron face boundaries is
233
+ also unimplemented here — see roadmap.)
234
+ * **Choropleth presentation.** Triangle boundaries can be visually
235
+ prominent. Hexagonal grids may be easier to read for general-audience
236
+ choropleths.
237
+ * **Anisotropy-sensitive statistics.** Up- and down-pointing cells are
238
+ congruent but rotated 60°; kernel-based methods that assume identical
239
+ cell orientation need care.
240
+ * **Local analysis.** At city scale and below, a projected CRS and planar
241
+ grid may be simpler than a global DGGS.
242
+
243
+ ---
244
+
245
+ ## 5. The demo
246
+
247
+ `docs/index.html` (GitHub Pages-ready, https://jaakla.github.io/trifold/)
248
+ — a single self-contained landing page: the full introduction (concept,
249
+ addressing, comparison, use cases, serving) with the interactive viewer
250
+ embedded as its centerpiece:
251
+
252
+ * **7 systems**: Trifold T3 triangles, [A5](https://a5geo.org) pentagons,
253
+ H3 hexagons, S2 quads (s2sphere), rHEALPix (aperture 9,
254
+ near-equal-area), HTM octahedral triangles (a related astronomy grid,
255
+ built with T3's own machinery), and lon/lat rectangles — same land,
256
+ same styling and land mask;
257
+ * **globe ↔ flat** toggle (MapLibre GL v5 native globe and Mercator
258
+ projections);
259
+ * compacted ↔ uncompacted, three triangle resolutions, click-for-address.
260
+
261
+ A presentation can compare the systems in this order: lon/lat in Mercator
262
+ and globe projections, S2, H3, A5, rHEALPix, HTM, and Trifold compacted.
263
+ This sequence shows projection effects, area variation, parent-child
264
+ geometry, and compact addressing.
265
+
266
+ ---
267
+
268
+ ## 6. Comparison with other DGGS
269
+
270
+ | | **Trifold** (this) | **A5** (pentagon) | **H3** (hex) | **S2** (square) | **rHEALPix** | **Geohash / slippy** |
271
+ |---|---|---|---|---|---|---|
272
+ | cell shape | spherical triangle | equilateral pentagon | hexagon (+12 pentagons) | curvilinear quad | quad (squashed at caps) | lon/lat rect |
273
+ | aperture | 4 | 4 (logical) | 7 | 4 | 9 | 4 (slippy) / 32 (geohash) |
274
+ | **exact parent⊃child nesting** | **yes** | no (logical only, index-exact) | **no** (≈7 children, ragged) | yes (within face) | yes | yes (but planar) |
275
+ | equal area | ~±20%, smooth | **exactly equal** per level | ~±35% across res; pentagons differ | up to ~2× corner/centre | **exactly equal-area** | varies with latitude |
276
+ | neighbours | 3 edge + 9 vertex, mixed | 5, two distance classes | **6 uniform** | 4 + 4 | 4 + 4 | 4 + 4 |
277
+ | pole handling | vertex wedges | regular cells | regular cells | face vertices | polar caps | **singular / degenerate** |
278
+ | index arithmetic | uint64, prefix = subtree | uint64, Hilbert | uint64 | uint64, Hilbert | string/int | string prefix |
279
+ | ecosystem | this repository | introduced in 2025 | widely used (Uber, DuckDB, BigQuery…) | widely used (Google, S2geometry) | academic, OGC-adopted | widely used |
280
+ | typical uses | lossless hierarchy, simplicial/FEM work, multi-resolution coverage | equal-area statistics and visualization | neighbour operations, visualization, analytics joins | indexing, range queries, storage | equal-area statistics | tiling and prefix lookup |
281
+
282
+ Selection depends on the application: **H3 provides uniform neighbour
283
+ traversal and a mature ecosystem; S2 focuses on spatial indexing;
284
+ rHEALPix and [A5](https://a5geo.org) provide equal-area cells.** Trifold
285
+ focuses on exact hierarchical aggregation, variable-resolution tilings,
286
+ and pipelines based on triangular geometry. The demo provides a visual
287
+ comparison of these properties.
288
+
289
+ Kin and prior art: OGC DGGS Abstract Specification (Topic 21); ISEA3H /
290
+ DGGRID (icosahedral, aperture 3/4 hex); QTM (Dutton's Quaternary
291
+ Triangular Mesh, an octahedron-based related scheme);
292
+ SCENZ-Grid; HTM (Hierarchical Triangular Mesh, used in astronomy — also
293
+ triangular aperture-4 and octahedron-based; Trifold uses an icosahedron,
294
+ compact addressing, and web tooling. The demo includes
295
+ an octahedral HTM layer for comparison); and
296
+ [**A5**](https://a5geo.org) (Felix Palmer, 2025) — a dodecahedron-based
297
+ pentagonal DGGS with a different hierarchy and area trade-off. It trades
298
+ exact geometric nesting (its aperture-4 hierarchy is logical, with exact
299
+ *index* prefixes but only approximate parent/child geometry) for
300
+ **exactly equal-area cells** within each level via a Snyder-derived
301
+ equal-area projection. Both systems use 64-bit integer indexing. A5 is
302
+ included in the demo's comparison mode (`scripts/build_a5_layer.py`, using
303
+ the official
304
+ [`pya5`](https://pypi.org/project/pya5/) library).
305
+
306
+ ---
307
+
308
+ ## 7. Serving at scale
309
+
310
+ Embedded TopoJSON is used in the demo for datasets up to about 30k cells
311
+ or 10 MB. Larger datasets can use either of these serverless approaches:
312
+
313
+ **Pregenerated PMTiles** — `scripts/make_pmtiles.sh` converts grid products
314
+ to single-file vector-tile archives via tippecanoe and copies them to
315
+ `docs/data/` for GitHub Pages. `make_site.py` detects matching archives in
316
+ `data/` and uses them instead of embedding the corresponding TopoJSON.
317
+ Set `TRIFOLD_PMTILES_BASE_URL` when the archives are hosted separately. Level 8 (~28 km,
318
+ ~440k land cells) tiles to a few tens of MB and supports full-grid display.
319
+
320
+ **Dynamic generation** — `worker/cell-server.js`, deployable free with
321
+ `npx wrangler deploy`. No stored data: cells are regenerated from pure
322
+ math on every request and cached at the edge (`/cell/TF6958`,
323
+ `/locate/lon,lat?level=N`, `/children/…`, `/cells/a,b,c`). This supports
324
+ applications that know which addresses they need, for example from a
325
+ database join on `addr64`, and fetch geometry lazily. The two approaches
326
+ can be combined: PMTiles for full-grid display and the Worker for
327
+ interactive lookup.
328
+
329
+ ---
330
+
331
+ ## 8. Subproject: landcheck — offline land/sea lookup
332
+
333
+ A practical demonstration of exact nesting: the level-10 land
334
+ classification (~6.15M land-touching cells) collapses into 153,884
335
+ run-length intervals over the canonical cell index space — a **182 KB**
336
+ bundled dataset that answers *"is this point on land?"* in ~1–13 µs,
337
+ offline, in Python and JavaScript with identical results and a
338
+ calibrated confidence per answer (measured 99.82% agreement with exact
339
+ polygon containment; all residual error confined to self-flagged
340
+ `coast` answers). An optional second file refines coastal answers with
341
+ OSM simplified land polygons clipped per cell. See
342
+ [landcheck/](landcheck/) and the
343
+ [live in-browser demo](https://jaakla.github.io/trifold/landcheck.html)
344
+ (classify sample or your own points on a map, with measured lookup rate).
345
+
346
+ ---
347
+
348
+ ## 9. Repository layout
349
+
350
+ ```
351
+ src/trifold/ library: address.py · core.py · classify.py · grid.py · cli.py
352
+ scripts/ build_grids.py · build_comparison_dggs.py · build_a5_layer.py · build_more_dggs.py · make_site.py · make_pmtiles.sh
353
+ worker/ cell-server.js (Cloudflare Worker, zero-data cell API)
354
+ landcheck/ offline land/sea point lookup (Python + JS + 182 KB data)
355
+ docs/ index.html (landing page + demo — GitHub Pages ready) ·
356
+ t3-technical-reference.md · img/
357
+ data/ generated products (gitignored; see data/README.md)
358
+ tests/ test_address.py
359
+ ```
360
+
361
+ Quickstart:
362
+
363
+ ```bash
364
+ poetry install --all-extras
365
+ poetry run pytest tests/
366
+ poetry run python scripts/build_grids.py --levels 4 5 6
367
+ poetry run python scripts/build_comparison_dggs.py
368
+ poetry run python scripts/build_a5_layer.py
369
+ poetry run python scripts/build_more_dggs.py
370
+ poetry run python scripts/make_site.py # → docs/index.html
371
+ ```
372
+
373
+ After `eval "$(poetry env activate)"`, omit `poetry run`:
374
+
375
+ ```bash
376
+ pytest tests/
377
+ python scripts/build_grids.py --levels 4 5 6
378
+ python scripts/build_comparison_dggs.py
379
+ python scripts/build_a5_layer.py
380
+ python scripts/build_more_dggs.py # S2 + rHEALPix + HTM layers
381
+ python scripts/make_site.py # → docs/index.html
382
+ ```
383
+
384
+ ---
385
+
386
+ ## Development environment
387
+
388
+ Poetry is the recommended development environment:
389
+
390
+ ```bash
391
+ poetry install --all-extras
392
+ eval "$(poetry env activate)" # activate in the current zsh/bash session
393
+ ```
394
+
395
+ Poetry 2.x no longer includes `poetry shell` by default. It manages this
396
+ environment itself and may not install `pip`; do not run pip installation
397
+ commands while the prompt starts with `(trifold)`. Use `poetry add` to add
398
+ dependencies, or `poetry run COMMAND` without activating the environment.
399
+
400
+ Alternatively, use a standard virtual environment instead of Poetry:
401
+
402
+ ```bash
403
+ deactivate 2>/dev/null || true # leave the Poetry environment first
404
+ python -m venv .venv-pip
405
+ source .venv-pip/bin/activate
406
+ python -m ensurepip --upgrade
407
+ python -m pip install -e ".[build,dev]"
408
+ ```
409
+
410
+ `tippecanoe` is required for `scripts/make_pmtiles.sh` (OS-level tool):
411
+
412
+ ```bash
413
+ # macOS (Homebrew)
414
+ brew install tippecanoe
415
+
416
+ # Linux: build from source or use the docker image; the script assumes `tippecanoe` is on PATH
417
+ ```
418
+
419
+ PMTiles are built from generated GeoJSON files. Generate those files
420
+ first, then run `tippecanoe` through the wrapper:
421
+
422
+ ```bash
423
+ python scripts/build_grids.py --levels 6
424
+ ./scripts/make_pmtiles.sh # all discovered global_tri_L*; skips existing archives
425
+ python scripts/make_site.py
426
+ ```
427
+
428
+ The wrapper auto-discovers every `data/global_tri_L*_*.geojson` product —
429
+ nothing is hardcoded. By default it skips grids whose `.pmtiles` already
430
+ exists; pass `--force` to rebuild. Restrict to specific levels with
431
+ `--levels` (accepts `N`, `N-M`, `N-`, or `-M`):
432
+
433
+ ```bash
434
+ ./scripts/make_pmtiles.sh --levels 7 # just L7
435
+ ./scripts/make_pmtiles.sh -l 4-8 # L4 through L8
436
+ ./scripts/make_pmtiles.sh -l 6- --force # L6 and up, rebuild even if present
437
+ ./scripts/make_pmtiles.sh --help # full usage
438
+ ```
439
+
440
+ The wrapper writes archives under both `data/` and `docs/data/`. The site
441
+ generator detects matching PMTiles in `data/` and embeds
442
+ TopoJSON only for datasets without an archive.
443
+
444
+ The grid build requires the Natural Earth checkout described in
445
+ Natural Earth 1:50m land data. The default build downloads and verifies the
446
+ pinned v5.1.2 GeoJSON automatically. Use `--land PATH` to supply another
447
+ local dataset instead.
448
+
449
+ ## 10. Roadmap
450
+
451
+ * neighbour traversal across face boundaries (edge-adjacency tables)
452
+ * level 7–9 products + PMTiles in CI
453
+ * vectorized `locate` DuckDB UDF for `addr64` joins
454
+ * optional ISEA-style equal-area variant (snyder projection per face)
455
+ * polygon→cells fill (`polyfill` equivalent)
456
+
457
+ ## License
458
+
459
+ MIT. Land data: [Natural Earth](https://www.naturalearthdata.com/) (public
460
+ domain). Built with shapely, pyproj, geopandas, topojson, MapLibre GL,
461
+ topojson-client, H3 (comparison layer).
@@ -0,0 +1,60 @@
1
+ export type CellIdentity = { face: number; digits: number[] };
2
+ export type AddressLike = bigint | number | string | CellIdentity | [number, number[]];
3
+ export type PolygonFeature = {
4
+ type: "Feature";
5
+ properties: Record<string, string | number | boolean>;
6
+ geometry: { type: "Polygon"; coordinates: number[][][] };
7
+ };
8
+ export type PolygonFeatureCollection = {
9
+ type: "FeatureCollection";
10
+ features: PolygonFeature[];
11
+ };
12
+
13
+ export const EARTH_RADIUS_KM: number;
14
+ export const MAX_LEVEL: number;
15
+ export const EXPORT_DEPTH: number;
16
+
17
+ export function icosahedron(): { vertices: number[][]; faces: number[][] };
18
+ export function subdivide(triangle: number[][]): number[][][];
19
+ export function containsPoint(triangle: number[][], point: number[]): boolean;
20
+ export function encode64(face: number, digits: number[]): bigint;
21
+ export function decode64(address: bigint | number | string): CellIdentity;
22
+ export function parseAddress(address: AddressLike): CellIdentity;
23
+ export function toCompact(address: AddressLike): string;
24
+ export function toCompact(face: number, digits: number[]): string;
25
+ export function fromCompact(address: string): bigint;
26
+ export function toPath(address: AddressLike): string;
27
+ export function toPath(face: number, digits: number[]): string;
28
+ export function fromPath(address: string): bigint;
29
+ export function parent64(address: bigint | number | string): bigint;
30
+ export function children64(address: bigint | number | string): bigint[];
31
+ export function isAncestor(ancestor: bigint | number | string, descendant: bigint | number | string): boolean;
32
+ export function descendantRange(address: bigint | number | string): [bigint, bigint];
33
+ export function latticeTriangle(address: AddressLike): number[][];
34
+ export function latticeTriangle(face: number, digits: number[]): number[][];
35
+ export function rhombusCoords(address: AddressLike): {
36
+ diamond: number; level: number; x: number; y: number; orientation: number;
37
+ };
38
+ export function rhombusId(address: AddressLike): string;
39
+ export function rhombus64(address: AddressLike): bigint;
40
+ export function decodeRhombus64(address: bigint | number | string): {
41
+ diamond: number; level: number; x: number; y: number;
42
+ };
43
+ export function hexId(address: AddressLike): string;
44
+ export function cellTriangle(address: AddressLike): number[][];
45
+ export function cellTriangle(face: number, digits: number[]): number[][];
46
+ export function locate(lon: number, lat: number, level: number): CellIdentity;
47
+ export function locateAddress(lon: number, lat: number, level: number): bigint;
48
+ export function edgeKm(triangle: number[][]): number;
49
+ export function areaKm2(triangle: number[][]): number;
50
+ export function cellRing(address: AddressLike, options?: { depth?: number; precision?: number }): number[][];
51
+ export function cellMetrics(address: AddressLike): {
52
+ id: string; path: string; addr64: bigint; face: number; level: number;
53
+ rhombusId: string; rhombusHilbert: bigint; hexId: string;
54
+ edgeKm: number; areaKm2: number;
55
+ };
56
+ export function cellFeature(address: AddressLike, options?: { precision?: number }): PolygonFeature;
57
+ export function levelFeatureCollection(
58
+ level: number,
59
+ options?: { face?: number | null; maxLevel?: number },
60
+ ): PolygonFeatureCollection;
package/js/trifold.js ADDED
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Trifold JavaScript SDK.
3
+ *
4
+ * This module has no runtime dependencies and works in browsers, Node.js,
5
+ * service workers, and Cloudflare Workers. Addresses are represented as
6
+ * BigInt values in code and decimal strings in GeoJSON properties.
7
+ */
8
+
9
+ export const EARTH_RADIUS_KM = 6371.0088;
10
+ export const MAX_LEVEL = 27;
11
+ export const EXPORT_DEPTH = 8;
12
+
13
+ const LEVEL_BITS = 5n;
14
+ const PATH_BITS = 2n * BigInt(MAX_LEVEL);
15
+ const PATH_MASK = (1n << PATH_BITS) - 1n;
16
+ const LON_ROT = 7.3 * Math.PI / 180;
17
+ const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
18
+ const B32_INV = Object.fromEntries([...B32].map((char, index) => [char, index]));
19
+ Object.assign(B32_INV, { I: 1, L: 1, O: 0, U: 27 });
20
+
21
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
22
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
23
+ const cross = (a, b) => [
24
+ a[1] * b[2] - a[2] * b[1],
25
+ a[2] * b[0] - a[0] * b[2],
26
+ a[0] * b[1] - a[1] * b[0],
27
+ ];
28
+
29
+ function norm(point) {
30
+ const length = Math.hypot(point[0], point[1], point[2]);
31
+ return [point[0] / length, point[1] / length, point[2] / length];
32
+ }
33
+
34
+ function validateIdentity(face, digits) {
35
+ if (!Number.isInteger(face) || face < 0 || face >= 20) {
36
+ throw new RangeError(`face ${face} is outside 0..19`);
37
+ }
38
+ if (!Array.isArray(digits) || digits.length > MAX_LEVEL) {
39
+ throw new RangeError(`path level must be within 0..${MAX_LEVEL}`);
40
+ }
41
+ for (const digit of digits) {
42
+ if (!Number.isInteger(digit) || digit < 0 || digit > 3) {
43
+ throw new RangeError(`path digit ${digit} is outside 0..3`);
44
+ }
45
+ }
46
+ return { face, digits: [...digits] };
47
+ }
48
+
49
+ /** Return the rotated unit icosahedron used by Trifold. */
50
+ export function icosahedron() {
51
+ const phi = (1 + Math.sqrt(5)) / 2;
52
+ let vertices = [
53
+ [-1, phi, 0], [1, phi, 0], [-1, -phi, 0], [1, -phi, 0],
54
+ [0, -1, phi], [0, 1, phi], [0, -1, -phi], [0, 1, -phi],
55
+ [phi, 0, -1], [phi, 0, 1], [-phi, 0, -1], [-phi, 0, 1],
56
+ ].map(norm);
57
+ const cos = Math.cos(LON_ROT);
58
+ const sin = Math.sin(LON_ROT);
59
+ vertices = vertices.map(point => [
60
+ point[0] * cos - point[1] * sin,
61
+ point[0] * sin + point[1] * cos,
62
+ point[2],
63
+ ]);
64
+ const faces = [
65
+ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
66
+ [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
67
+ [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
68
+ [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
69
+ ];
70
+ return { vertices, faces };
71
+ }
72
+
73
+ const ICO = icosahedron();
74
+ const NORTH = [0, 0, 1];
75
+ const SOUTH = [0, 0, -1];
76
+ const DIAMONDS = [
77
+ [0, 1, [0, 5]], [2, 3, [0, 7]], [4, 7, [10, 11]],
78
+ [5, 15, [5, 9]], [6, 16, [4, 11]], [8, 17, [6, 10]],
79
+ [9, 18, [7, 8]], [10, 11, [3, 4]], [12, 13, [3, 6]],
80
+ [14, 19, [8, 9]],
81
+ ];
82
+ const FACE_DIAMOND = Array(20);
83
+ DIAMONDS.forEach(([faceA, faceB, edge], diamond) => {
84
+ FACE_DIAMOND[faceA] = { diamond, half: 0, edge };
85
+ FACE_DIAMOND[faceB] = { diamond, half: 1, edge };
86
+ });
87
+
88
+ /** Split a spherical triangle into its four aperture-4 children. */
89
+ export function subdivide(triangle) {
90
+ const [v0, v1, v2] = triangle;
91
+ const m01 = norm(add(v0, v1));
92
+ const m12 = norm(add(v1, v2));
93
+ const m20 = norm(add(v2, v0));
94
+ return [[v0, m01, m20], [m01, v1, m12], [m20, m12, v2], [m01, m12, m20]];
95
+ }
96
+
97
+ /** Test whether a unit vector lies inside a spherical triangle. */
98
+ export function containsPoint(triangle, point) {
99
+ return dot(cross(triangle[0], triangle[1]), point) >= -1e-14 &&
100
+ dot(cross(triangle[1], triangle[2]), point) >= -1e-14 &&
101
+ dot(cross(triangle[2], triangle[0]), point) >= -1e-14;
102
+ }
103
+
104
+ /** Encode a face and base-4 path as an addr64 BigInt. */
105
+ export function encode64(face, digits) {
106
+ const identity = validateIdentity(face, digits);
107
+ let path = 0n;
108
+ for (const digit of identity.digits) path = (path << 2n) | BigInt(digit);
109
+ path <<= 2n * BigInt(MAX_LEVEL - identity.digits.length);
110
+ return (BigInt(identity.face) << 59n) | (path << LEVEL_BITS) |
111
+ BigInt(identity.digits.length);
112
+ }
113
+
114
+ /** Decode an addr64 value to `{face, digits}`. */
115
+ export function decode64(address) {
116
+ if (typeof address === "number" && !Number.isSafeInteger(address)) {
117
+ throw new RangeError("numeric addr64 values must be safe integers; use BigInt or string");
118
+ }
119
+ const value = typeof address === "bigint" ? address : BigInt(address);
120
+ if (value < 0n || value >= (1n << 64n)) throw new RangeError("addr64 is outside uint64");
121
+ const face = Number((value >> 59n) & 31n);
122
+ const level = Number(value & 31n);
123
+ if (face >= 20 || level > MAX_LEVEL) throw new RangeError("invalid addr64 value");
124
+ const path = (value >> LEVEL_BITS) & PATH_MASK;
125
+ const digits = [];
126
+ for (let index = 0; index < level; index++) {
127
+ const shift = PATH_BITS - 2n * BigInt(index + 1);
128
+ digits.push(Number((path >> shift) & 3n));
129
+ }
130
+ return { face, digits };
131
+ }
132
+
133
+ /** Parse compact, path, addr64, or `{face, digits}` input. */
134
+ export function parseAddress(address) {
135
+ if (typeof address === "bigint" || typeof address === "number") return decode64(address);
136
+ if (Array.isArray(address) && address.length === 2) {
137
+ return validateIdentity(Number(address[0]), [...address[1]].map(Number));
138
+ }
139
+ if (address && typeof address === "object" && "face" in address && "digits" in address) {
140
+ return validateIdentity(Number(address.face), [...address.digits].map(Number));
141
+ }
142
+ if (typeof address !== "string") throw new TypeError("unsupported address value");
143
+ const text = decodeURIComponent(address).trim().toUpperCase();
144
+ if (text.startsWith("F")) {
145
+ const [head, tail = ""] = text.split("-");
146
+ if (!/^F\d{1,2}$/.test(head) || !/^[0-3]*$/.test(tail)) {
147
+ throw new Error(`invalid path address ${address}`);
148
+ }
149
+ return validateIdentity(Number(head.slice(1)), [...tail].map(Number));
150
+ }
151
+ if (!text.startsWith("T") || text.length < 3) {
152
+ if (/^\d+$/.test(text)) return decode64(BigInt(text));
153
+ throw new Error(`invalid compact address ${address}`);
154
+ }
155
+ const face = B32_INV[text[1]];
156
+ const level = B32_INV[text[2]];
157
+ if (face === undefined || level === undefined || face >= 20 || level > MAX_LEVEL) {
158
+ throw new Error(`invalid compact address ${address}`);
159
+ }
160
+ const bitCount = 2 * level;
161
+ const charCount = Math.ceil(bitCount / 5);
162
+ if (text.length !== 3 + charCount) throw new Error("invalid compact address length");
163
+ let bits = 0n;
164
+ for (const char of text.slice(3)) {
165
+ if (B32_INV[char] === undefined) throw new Error(`invalid base32 character ${char}`);
166
+ bits = (bits << 5n) | BigInt(B32_INV[char]);
167
+ }
168
+ bits >>= BigInt(charCount * 5 - bitCount);
169
+ const digits = [];
170
+ for (let index = level - 1; index >= 0; index--) {
171
+ digits.push(Number((bits >> BigInt(2 * index)) & 3n));
172
+ }
173
+ return validateIdentity(face, digits);
174
+ }
175
+
176
+ function identityFromArgs(addressOrFace, digits) {
177
+ return Array.isArray(digits)
178
+ ? validateIdentity(addressOrFace, digits)
179
+ : parseAddress(addressOrFace);
180
+ }
181
+
182
+ /** Convert an address to compact Crockford base32. */
183
+ export function toCompact(addressOrFace, digits) {
184
+ const identity = identityFromArgs(addressOrFace, digits);
185
+ const level = identity.digits.length;
186
+ const bitCount = 2 * level;
187
+ const charCount = Math.ceil(bitCount / 5);
188
+ let bits = 0n;
189
+ for (const digit of identity.digits) bits = (bits << 2n) | BigInt(digit);
190
+ bits <<= BigInt(charCount * 5 - bitCount);
191
+ let text = `T${B32[identity.face]}${B32[level]}`;
192
+ for (let index = charCount - 1; index >= 0; index--) {
193
+ text += B32[Number((bits >> BigInt(5 * index)) & 31n)];
194
+ }
195
+ return text;
196
+ }
197
+
198
+ export function fromCompact(address) {
199
+ if (typeof address !== "string" || !address.trim().toUpperCase().startsWith("T")) {
200
+ throw new Error("compact addresses start with T");
201
+ }
202
+ return encode64(...identityTuple(parseAddress(address)));
203
+ }
204
+
205
+ /** Convert an address to the `Fxx-digits` path form. */
206
+ export function toPath(addressOrFace, digits) {
207
+ const identity = identityFromArgs(addressOrFace, digits);
208
+ return `F${String(identity.face).padStart(2, "0")}-${identity.digits.join("")}`;
209
+ }
210
+
211
+ export function fromPath(address) {
212
+ if (typeof address !== "string" || !address.trim().toUpperCase().startsWith("F")) {
213
+ throw new Error("path addresses start with F");
214
+ }
215
+ return encode64(...identityTuple(parseAddress(address)));
216
+ }
217
+
218
+ function identityTuple(identity) {
219
+ return [identity.face, identity.digits];
220
+ }
221
+
222
+ export function parent64(address) {
223
+ const { face, digits } = decode64(address);
224
+ if (!digits.length) throw new RangeError("level-0 cell has no parent");
225
+ return encode64(face, digits.slice(0, -1));
226
+ }
227
+
228
+ export function children64(address) {
229
+ const { face, digits } = decode64(address);
230
+ if (digits.length >= MAX_LEVEL) throw new RangeError("cell is at the maximum level");
231
+ return [0, 1, 2, 3].map(digit => encode64(face, [...digits, digit]));
232
+ }
233
+
234
+ export function isAncestor(ancestor, descendant) {
235
+ const a = decode64(ancestor);
236
+ const b = decode64(descendant);
237
+ return a.face === b.face && a.digits.length <= b.digits.length &&
238
+ a.digits.every((digit, index) => digit === b.digits[index]);
239
+ }
240
+
241
+ export function descendantRange(address) {
242
+ const { face, digits } = decode64(address);
243
+ const value = encode64(face, digits);
244
+ const suffixBits = PATH_BITS - 2n * BigInt(digits.length);
245
+ const path = (value >> LEVEL_BITS) & PATH_MASK;
246
+ const highPath = path | (suffixBits ? (1n << suffixBits) - 1n : 0n);
247
+ return [value, (BigInt(face) << 59n) | (highPath << LEVEL_BITS) | BigInt(MAX_LEVEL)];
248
+ }
249
+
250
+ /** Return exact integer barycentric vertices for one triangle. */
251
+ export function latticeTriangle(addressOrFace, digits) {
252
+ const identity = identityFromArgs(addressOrFace, digits);
253
+ let vertices = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
254
+ for (const digit of identity.digits) {
255
+ const [v0, v1, v2] = vertices;
256
+ const doubled = vertices.map(vertex => vertex.map(value => 2 * value));
257
+ const midpoint = (a, b) => a.map((value, index) => value + b[index]);
258
+ const m01 = midpoint(v0, v1);
259
+ const m12 = midpoint(v1, v2);
260
+ const m20 = midpoint(v2, v0);
261
+ vertices = [
262
+ [doubled[0], m01, m20],
263
+ [m01, doubled[1], m12],
264
+ [m20, m12, doubled[2]],
265
+ [m01, m12, m20],
266
+ ][digit];
267
+ }
268
+ return vertices;
269
+ }
270
+
271
+ function hilbertXYToIndex(level, inputX, inputY) {
272
+ let x = inputX;
273
+ let y = inputY;
274
+ let index = 0n;
275
+ for (let scale = Math.floor((2 ** level) / 2); scale > 0; scale = Math.floor(scale / 2)) {
276
+ const rx = x & scale ? 1 : 0;
277
+ const ry = y & scale ? 1 : 0;
278
+ index += BigInt(scale) * BigInt(scale) * BigInt((3 * rx) ^ ry);
279
+ if (ry === 0) {
280
+ if (rx === 1) {
281
+ x = scale - 1 - x;
282
+ y = scale - 1 - y;
283
+ }
284
+ [x, y] = [y, x];
285
+ }
286
+ }
287
+ return index;
288
+ }
289
+
290
+ function hilbertIndexToXY(level, inputIndex) {
291
+ const size = 2 ** level;
292
+ let x = 0;
293
+ let y = 0;
294
+ let value = inputIndex;
295
+ for (let scale = 1; scale < size; scale *= 2) {
296
+ const rx = Number(1n & (value >> 1n));
297
+ const ry = Number(1n & (value ^ BigInt(rx)));
298
+ if (ry === 0) {
299
+ if (rx === 1) {
300
+ x = scale - 1 - x;
301
+ y = scale - 1 - y;
302
+ }
303
+ [x, y] = [y, x];
304
+ }
305
+ x += scale * rx;
306
+ y += scale * ry;
307
+ value >>= 2n;
308
+ }
309
+ return [x, y];
310
+ }
311
+
312
+ /** Map a triangle to `{diamond, level, x, y, orientation}`. */
313
+ export function rhombusCoords(address) {
314
+ const identity = parseAddress(address);
315
+ const size = 2 ** identity.digits.length;
316
+ const { diamond, half, edge } = FACE_DIAMOND[identity.face];
317
+ const faceVertices = ICO.faces[identity.face];
318
+ const edgeIndexes = edge.map(vertex => faceVertices.indexOf(vertex));
319
+ const points = latticeTriangle(identity).map(barycentric => {
320
+ let x = barycentric[edgeIndexes[0]];
321
+ let y = barycentric[edgeIndexes[1]];
322
+ if (half) [x, y] = [size - y, size - x];
323
+ return [x, y];
324
+ });
325
+ const x = Math.min(...points.map(point => point[0]));
326
+ const y = Math.min(...points.map(point => point[1]));
327
+ const orientation = points.some(point => point[0] === x && point[1] === y) ? 0 : 1;
328
+ return { diamond, level: identity.digits.length, x, y, orientation };
329
+ }
330
+
331
+ /** Return the human-readable ID shared by an exact triangle pair. */
332
+ export function rhombusId(address) {
333
+ const { diamond, level, x, y } = rhombusCoords(address);
334
+ return `R${String(diamond).padStart(2, "0")}-${String(level).padStart(2, "0")}-${x}-${y}`;
335
+ }
336
+
337
+ /** Return the Hilbert-linearized BigInt key shared by an exact triangle pair. */
338
+ export function rhombus64(address) {
339
+ const { diamond, level, x, y } = rhombusCoords(address);
340
+ const hilbert = hilbertXYToIndex(level, x, y) << BigInt(2 * (MAX_LEVEL - level));
341
+ return (BigInt(diamond) << 59n) | (hilbert << LEVEL_BITS) | BigInt(level);
342
+ }
343
+
344
+ /** Decode a rhombus64 key to `{diamond, level, x, y}`. */
345
+ export function decodeRhombus64(address) {
346
+ const value = typeof address === "bigint" ? address : BigInt(address);
347
+ if (value < 0n || value >= (1n << 63n)) throw new RangeError("rhombus64 is outside its 63-bit range");
348
+ const diamond = Number((value >> 59n) & 15n);
349
+ const level = Number(value & 31n);
350
+ if (diamond >= DIAMONDS.length || level > MAX_LEVEL) throw new RangeError("invalid rhombus64 value");
351
+ const hilbert = ((value >> LEVEL_BITS) & PATH_MASK) >> BigInt(2 * (MAX_LEVEL - level));
352
+ const [x, y] = hilbertIndexToXY(level, hilbert);
353
+ return { diamond, level, x, y };
354
+ }
355
+
356
+ function canonicalVertexId(face, level, barycentric) {
357
+ const faceVertices = ICO.faces[face];
358
+ const nonzero = barycentric.map((value, index) => value ? index : -1)
359
+ .filter(index => index >= 0);
360
+ if (nonzero.length === 1) {
361
+ return `HV${String(faceVertices[nonzero[0]]).padStart(2, "0")}-${String(level).padStart(2, "0")}`;
362
+ }
363
+ if (nonzero.length === 2) {
364
+ const [first, second] = nonzero;
365
+ const vertexA = faceVertices[first];
366
+ const vertexB = faceVertices[second];
367
+ const [low, high] = [vertexA, vertexB].sort((a, b) => a - b);
368
+ const highIndex = vertexA === high ? first : second;
369
+ return `HE${String(low).padStart(2, "0")}${String(high).padStart(2, "0")}-${String(level).padStart(2, "0")}-${barycentric[highIndex]}`;
370
+ }
371
+ return `HF${String(face).padStart(2, "0")}-${String(level).padStart(2, "0")}-${barycentric.join("-")}`;
372
+ }
373
+
374
+ /** Return a per-level display-group key with six-triangle face interiors. */
375
+ export function hexId(address) {
376
+ const identity = parseAddress(address);
377
+ const centers = latticeTriangle(identity)
378
+ .filter(vertex => (vertex[1] + 2 * vertex[2]) % 3 === 0);
379
+ if (centers.length !== 1) throw new Error("triangle lattice coloring must select one vertex");
380
+ return canonicalVertexId(identity.face, identity.digits.length, centers[0]);
381
+ }
382
+
383
+ /** Return the spherical triangle for an address. */
384
+ export function cellTriangle(addressOrFace, digits) {
385
+ const identity = identityFromArgs(addressOrFace, digits);
386
+ const [i, j, k] = ICO.faces[identity.face];
387
+ let triangle = [ICO.vertices[i], ICO.vertices[j], ICO.vertices[k]];
388
+ for (const digit of identity.digits) triangle = subdivide(triangle)[digit];
389
+ return triangle;
390
+ }
391
+
392
+ /** Locate a point and return `{face, digits}`. */
393
+ export function locate(lon, lat, level) {
394
+ if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
395
+ throw new RangeError("longitude must be within [-180, 180]");
396
+ }
397
+ if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
398
+ throw new RangeError("latitude must be within [-90, 90]");
399
+ }
400
+ if (!Number.isInteger(level) || level < 0 || level > MAX_LEVEL) {
401
+ throw new RangeError(`level must be within 0..${MAX_LEVEL}`);
402
+ }
403
+ const lambda = lon * Math.PI / 180;
404
+ const phi = lat * Math.PI / 180;
405
+ const point = [Math.cos(phi) * Math.cos(lambda), Math.cos(phi) * Math.sin(lambda), Math.sin(phi)];
406
+ let face = -1;
407
+ let triangle = null;
408
+ for (let index = 0; index < 20; index++) {
409
+ const candidate = cellTriangle(index, []);
410
+ if (containsPoint(candidate, point)) {
411
+ face = index;
412
+ triangle = candidate;
413
+ break;
414
+ }
415
+ }
416
+ if (face < 0) {
417
+ let best = -2;
418
+ for (let index = 0; index < 20; index++) {
419
+ const [i, j, k] = ICO.faces[index];
420
+ const centre = norm(add(add(ICO.vertices[i], ICO.vertices[j]), ICO.vertices[k]));
421
+ const score = dot(centre, point);
422
+ if (score > best) {
423
+ best = score;
424
+ face = index;
425
+ }
426
+ }
427
+ triangle = cellTriangle(face, []);
428
+ }
429
+ const digits = [];
430
+ for (let current = 0; current < level; current++) {
431
+ const children = subdivide(triangle);
432
+ let selected = -1;
433
+ let bestMargin = -2;
434
+ for (let digit = 0; digit < 4; digit++) {
435
+ const child = children[digit];
436
+ const margin = Math.min(
437
+ dot(cross(child[0], child[1]), point),
438
+ dot(cross(child[1], child[2]), point),
439
+ dot(cross(child[2], child[0]), point),
440
+ );
441
+ if (margin >= -1e-14) {
442
+ selected = digit;
443
+ break;
444
+ }
445
+ if (margin > bestMargin) {
446
+ bestMargin = margin;
447
+ selected = digit;
448
+ }
449
+ }
450
+ digits.push(selected);
451
+ triangle = children[selected];
452
+ }
453
+ return { face, digits };
454
+ }
455
+
456
+ export function locateAddress(lon, lat, level) {
457
+ const identity = locate(lon, lat, level);
458
+ return encode64(identity.face, identity.digits);
459
+ }
460
+
461
+ export function edgeKm(triangle) {
462
+ const edges = [[triangle[0], triangle[1]], [triangle[1], triangle[2]], [triangle[2], triangle[0]]];
463
+ const angles = edges.map(([a, b]) => Math.acos(Math.max(-1, Math.min(1, dot(a, b)))));
464
+ return angles.reduce((sum, angle) => sum + angle, 0) / angles.length * EARTH_RADIUS_KM;
465
+ }
466
+
467
+ export function areaKm2(triangle) {
468
+ const angle = (a, b, c) => {
469
+ const n1 = cross(a, b);
470
+ const n2 = cross(a, c);
471
+ return Math.acos(Math.max(-1, Math.min(1,
472
+ dot(n1, n2) / (Math.hypot(...n1) * Math.hypot(...n2)))));
473
+ };
474
+ const excess = angle(triangle[0], triangle[1], triangle[2]) +
475
+ angle(triangle[1], triangle[2], triangle[0]) +
476
+ angle(triangle[2], triangle[0], triangle[1]) - Math.PI;
477
+ return excess * EARTH_RADIUS_KM ** 2;
478
+ }
479
+
480
+ function edgePoints(a, b, halvings) {
481
+ if (halvings <= 0) return [a];
482
+ const midpoint = norm(add(a, b));
483
+ return [...edgePoints(a, midpoint, halvings - 1), ...edgePoints(midpoint, b, halvings - 1)];
484
+ }
485
+
486
+ function toLonLat(point) {
487
+ return [
488
+ Math.atan2(point[1], point[0]) * 180 / Math.PI,
489
+ Math.asin(Math.max(-1, Math.min(1, point[2]))) * 180 / Math.PI,
490
+ ];
491
+ }
492
+
493
+ function recenter(ring) {
494
+ const mean = ring.reduce((sum, point) => sum + point[0], 0) / ring.length;
495
+ let shift = 0;
496
+ while (mean + shift > 180) shift -= 360;
497
+ while (mean + shift <= -180) shift += 360;
498
+ return ring.map(([lon, lat]) => [lon + shift, lat]);
499
+ }
500
+
501
+ function unwrap(points) {
502
+ const ring = [];
503
+ let previous = null;
504
+ let offset = 0;
505
+ for (const point of points) {
506
+ let [lon, lat] = toLonLat(point);
507
+ lon += offset;
508
+ if (previous !== null) {
509
+ while (lon - previous > 180) { lon -= 360; offset -= 360; }
510
+ while (lon - previous < -180) { lon += 360; offset += 360; }
511
+ }
512
+ ring.push([lon, lat]);
513
+ previous = lon;
514
+ }
515
+ return recenter(ring);
516
+ }
517
+
518
+ function exportRing(points, triangle) {
519
+ const poleIndexes = points.map((point, index) => Math.abs(point[2]) >= 1 - 1e-9 ? index : -1)
520
+ .filter(index => index >= 0);
521
+ if (poleIndexes.length) {
522
+ const poleLatitude = points[poleIndexes[0]][2] > 0 ? 90 : -90;
523
+ const poleSet = new Set(poleIndexes);
524
+ const longitudes = {};
525
+ let previous = null;
526
+ let offset = 0;
527
+ for (let index = 0; index < points.length; index++) {
528
+ if (poleSet.has(index)) continue;
529
+ let [lon, lat] = toLonLat(points[index]);
530
+ lon += offset;
531
+ if (previous !== null) {
532
+ while (lon - previous > 180) { lon -= 360; offset -= 360; }
533
+ while (lon - previous < -180) { lon += 360; offset += 360; }
534
+ }
535
+ longitudes[index] = [lon, lat];
536
+ previous = lon;
537
+ }
538
+ const ring = [];
539
+ const count = points.length;
540
+ for (let index = 0; index < count; index++) {
541
+ if (!poleSet.has(index)) {
542
+ ring.push(longitudes[index]);
543
+ continue;
544
+ }
545
+ let before = (index - 1 + count) % count;
546
+ let after = (index + 1) % count;
547
+ while (poleSet.has(before)) before = (before - 1 + count) % count;
548
+ while (poleSet.has(after)) after = (after + 1) % count;
549
+ ring.push([longitudes[before][0], poleLatitude], [longitudes[after][0], poleLatitude]);
550
+ }
551
+ return { ring: recenter(ring), pole: "vertex" };
552
+ }
553
+ if (containsPoint(triangle, NORTH) || containsPoint(triangle, SOUTH)) {
554
+ const north = containsPoint(triangle, NORTH);
555
+ const poleLatitude = north ? 90 : -90;
556
+ const ring = points.map(toLonLat).sort((a, b) => a[0] - b[0]);
557
+ ring.push([180, poleLatitude], [-180, poleLatitude]);
558
+ return { ring, pole: "interior" };
559
+ }
560
+ return { ring: unwrap(points), pole: "" };
561
+ }
562
+
563
+ /** Return a closed cell boundary as continuous-longitude coordinates. */
564
+ export function cellRing(address, { depth = EXPORT_DEPTH, precision = 6 } = {}) {
565
+ const identity = parseAddress(address);
566
+ const triangle = cellTriangle(identity);
567
+ const halvings = Math.max(0, depth - identity.digits.length);
568
+ const points = [
569
+ ...edgePoints(triangle[0], triangle[1], halvings),
570
+ ...edgePoints(triangle[1], triangle[2], halvings),
571
+ ...edgePoints(triangle[2], triangle[0], halvings),
572
+ ];
573
+ const exported = exportRing(points, triangle);
574
+ const ring = exported.ring.map(([lon, lat]) => [
575
+ Number(lon.toFixed(precision)),
576
+ Number(lat.toFixed(precision)),
577
+ ]);
578
+ ring.push([...ring[0]]);
579
+ return ring;
580
+ }
581
+
582
+ export function cellMetrics(address) {
583
+ const identity = parseAddress(address);
584
+ const value = encode64(identity.face, identity.digits);
585
+ const triangle = cellTriangle(identity);
586
+ return {
587
+ id: toCompact(identity),
588
+ path: toPath(identity),
589
+ addr64: value,
590
+ rhombusId: rhombusId(identity),
591
+ rhombusHilbert: rhombus64(identity),
592
+ hexId: hexId(identity),
593
+ face: identity.face,
594
+ level: identity.digits.length,
595
+ edgeKm: edgeKm(triangle),
596
+ areaKm2: areaKm2(triangle),
597
+ };
598
+ }
599
+
600
+ /** Return a GeoJSON Feature for one address. */
601
+ export function cellFeature(address, { precision = 6 } = {}) {
602
+ const identity = parseAddress(address);
603
+ const value = encode64(identity.face, identity.digits);
604
+ const triangle = cellTriangle(identity);
605
+ const halvings = Math.max(0, EXPORT_DEPTH - identity.digits.length);
606
+ const points = [
607
+ ...edgePoints(triangle[0], triangle[1], halvings),
608
+ ...edgePoints(triangle[1], triangle[2], halvings),
609
+ ...edgePoints(triangle[2], triangle[0], halvings),
610
+ ];
611
+ const exported = exportRing(points, triangle);
612
+ const ring = exported.ring.map(([lon, lat]) => [
613
+ Number(lon.toFixed(precision)),
614
+ Number(lat.toFixed(precision)),
615
+ ]);
616
+ ring.push([...ring[0]]);
617
+ return {
618
+ type: "Feature",
619
+ properties: {
620
+ id: toCompact(identity),
621
+ path: toPath(identity),
622
+ addr64: value.toString(),
623
+ rhombus_id: rhombusId(identity),
624
+ rhombus_hilbert: rhombus64(identity).toString(),
625
+ hex_id: hexId(identity),
626
+ level: identity.digits.length,
627
+ pole: exported.pole,
628
+ },
629
+ geometry: { type: "Polygon", coordinates: [ring] },
630
+ };
631
+ }
632
+
633
+ /** Enumerate all cells on one face, or all faces, at a uniform level. */
634
+ export function levelFeatureCollection(level, { face = null, maxLevel = 5 } = {}) {
635
+ if (!Number.isInteger(level) || level < 0 || level > maxLevel) {
636
+ throw new RangeError(`level must be within 0..${maxLevel}`);
637
+ }
638
+ const faces = face === null ? [...Array(20).keys()] : [Number(face)];
639
+ faces.forEach(value => validateIdentity(value, []));
640
+ const features = [];
641
+ const generate = (currentFace, digits) => {
642
+ if (digits.length === level) {
643
+ features.push(cellFeature({ face: currentFace, digits }));
644
+ return;
645
+ }
646
+ for (let digit = 0; digit < 4; digit++) generate(currentFace, [...digits, digit]);
647
+ };
648
+ for (const currentFace of faces) generate(currentFace, []);
649
+ return { type: "FeatureCollection", features };
650
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "t3grid",
3
+ "version": "0.1.0",
4
+ "description": "Trifold T3: dependency-free JavaScript SDK for a hierarchical triangular DGGS with exact aperture-4 nesting",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./js/trifold.d.ts",
9
+ "default": "./js/trifold.js"
10
+ }
11
+ },
12
+ "types": "./js/trifold.d.ts",
13
+ "files": [
14
+ "js/trifold.js",
15
+ "js/trifold.d.ts"
16
+ ],
17
+ "keywords": [
18
+ "dggs",
19
+ "geospatial",
20
+ "triangular-grid",
21
+ "global-grid",
22
+ "icosahedron",
23
+ "h3-alternative",
24
+ "spatial-index"
25
+ ],
26
+ "homepage": "https://jaakla.github.io/trifold/",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/jaakla/trifold.git"
30
+ },
31
+ "license": "MIT"
32
+ }