landcheck 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/README.md +159 -0
- package/data/landsea_L10.tfls +0 -0
- package/js/landcheck.mjs +426 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# landcheck — offline land/sea lookup
|
|
2
|
+
|
|
3
|
+
This is created as Trifold application (also test and demonstration)
|
|
4
|
+
|
|
5
|
+
**Is this lat/long point on land or in the sea?** gets answered anywhere on
|
|
6
|
+
Earth in ~1–13 µs, fully offline, from a small **182 KB** bundled dataset —
|
|
7
|
+
with a confidence value for every answer. Works with Python and JavaScript.
|
|
8
|
+
|
|
9
|
+
**[Live in-browser demo](https://jaakla.github.io/trifold/landcheck.html)** —
|
|
10
|
+
classify sample or your own points (CSV / GeoJSON) on a map and watch the
|
|
11
|
+
measured lookup rate; the page embeds the real JS library and dataset.
|
|
12
|
+
|
|
13
|
+
Install: `pip install landcheck` / `npm install landcheck` (or use straight
|
|
14
|
+
from a repo checkout, as below).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from landcheck import LandCheck # installed; from a checkout, first:
|
|
18
|
+
# import sys; sys.path.insert(0, "landcheck/python")
|
|
19
|
+
|
|
20
|
+
lc = LandCheck()
|
|
21
|
+
lc.is_land(24.7536, 59.4370) # True (lon, lat — Tallinn)
|
|
22
|
+
lc.check(-0.1276, 51.5072)
|
|
23
|
+
# LandResult(land=True, kind='land', confidence=1.0, land_fraction=1.0,
|
|
24
|
+
# cell='TFA95BM', refined=False)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { LandCheck } from "./landcheck/js/landcheck.mjs";
|
|
29
|
+
const lc = await LandCheck.fromFile(); // NodeJs takes bundled data directly
|
|
30
|
+
// OR:
|
|
31
|
+
const lc = await LandCheck.fromUrl("/data/landsea_L10.tfls"); // browser needs to load data file online
|
|
32
|
+
lc.isLand(24.7536, 59.437); // true
|
|
33
|
+
lc.check(-0.1276, 51.5072);
|
|
34
|
+
// { land: true, kind: 'land', confidence: 1, landFraction: 1,
|
|
35
|
+
// cell: 'TFA95BM', refined: false }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
The Trifold level-10 grid (~7 km triangles, 21M cells globally) is
|
|
41
|
+
classified against Natural Earth 1:50m land: 6.15M cells touch land,
|
|
42
|
+
of which 168,833 are *coastal* (mixed land/sea) and the rest are wholly
|
|
43
|
+
interior. Because Trifold addresses sort hierarchically, every cell at
|
|
44
|
+
any level maps to a contiguous range in the canonical level-10 index
|
|
45
|
+
space (`face·4¹⁰ + path`), so the entire classification collapses to
|
|
46
|
+
**153,884 run-length intervals** — 182 KB compressed, including a 4-bit
|
|
47
|
+
land-area fraction for every coastal cell.
|
|
48
|
+
|
|
49
|
+
A lookup is: locate the point's level-10 triangle (pure float math, no
|
|
50
|
+
dependencies), then binary-search the runs.
|
|
51
|
+
|
|
52
|
+
| answer kind | meaning | `land` | `confidence` |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `land` | cell wholly inside land | `True` | 1.0 |
|
|
55
|
+
| `sea` | cell absent from dataset | `False` | 1.0 |
|
|
56
|
+
| `coast` | mixed cell | `land_fraction >= 0.5` | `max(f, 1−f)` |
|
|
57
|
+
| `coast` + `refined` | decided by OSM polygon test | exact | 0.99 |
|
|
58
|
+
|
|
59
|
+
**Measured accuracy** (30,000 uniform random points vs. exact polygon
|
|
60
|
+
containment in the source dataset): 99.82% agreement overall; `land` and
|
|
61
|
+
`sea` answers 100% correct; *all* residual error lives in `coast`
|
|
62
|
+
answers, which self-report their lower confidence (mean calibration
|
|
63
|
+
credit 0.997).
|
|
64
|
+
|
|
65
|
+
Caveats inherited from the source data: Natural Earth 1:50m treats **lakes as land and omits islets** below its resolution; `coast` cells flag exactly where such risk is concentrated.
|
|
66
|
+
|
|
67
|
+
## Optional coastal refinement (OSM)
|
|
68
|
+
|
|
69
|
+
For applications that need near-exact coastlines, a second dataset
|
|
70
|
+
(`coastal_osm_L10.tflr`, **12.7 MB**) stores OSM simplified land polygons
|
|
71
|
+
([osmdata.openstreetmap.de](https://osmdata.openstreetmap.de/data/land-polygons.html))
|
|
72
|
+
clipped to every triangle crossed by either the Natural Earth or OSM
|
|
73
|
+
coastline, quantized to a cell-local 16-bit grid (~0.1 m) with delta-varint
|
|
74
|
+
rings. When loaded, covered answers switch from the Natural Earth base or
|
|
75
|
+
bulk land-fraction guess to an exact point-in-polygon test. This has
|
|
76
|
+
**99.95% agreement** with full polygon containment on 4,000 random points inside the
|
|
77
|
+
original coastal-cell sample (~23 µs per refined lookup, Python):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
lc = LandCheck(refine_path="landcheck/data/coastal_osm_L10.tflr")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
await lc.loadRefinement("landcheck/data/coastal_osm_L10.tflr");
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Only covered coastline cells pay the polygon-test cost. OSM can override a
|
|
88
|
+
base `land` or `sea` answer when its coastline crosses a triangle that Natural
|
|
89
|
+
Earth classified differently.
|
|
90
|
+
|
|
91
|
+
Note on dataset semantics: the base layer keeps Natural Earth's view of
|
|
92
|
+
the world for `land`/`sea` kinds. NE and OSM systematically disagree
|
|
93
|
+
about Antarctica (OSM land polygons are clipped near the pole and draw
|
|
94
|
+
ice-shelf edges differently). With refinement enabled, OSM is authoritative
|
|
95
|
+
in every covered coastline cell.
|
|
96
|
+
|
|
97
|
+
Command-line one-off checks (uses refinement automatically when the
|
|
98
|
+
file is present):
|
|
99
|
+
|
|
100
|
+
```console
|
|
101
|
+
$ python landcheck/python/landcheck.py 24.7536 59.4370
|
|
102
|
+
LAND kind=land confidence=1.000 land_fraction=1.0 cell=TFAVKGR refined=False
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Performance
|
|
106
|
+
|
|
107
|
+
| operation | speed |
|
|
108
|
+
|---|---|
|
|
109
|
+
| Python scalar `is_land` | ~13 µs/point (77k/s) |
|
|
110
|
+
| Python batch `is_land_batch` (numpy) | ~2.8 µs/point (360k/s) |
|
|
111
|
+
| JavaScript `isLand` (Node) | ~0.8 µs/point (1.2M/s) |
|
|
112
|
+
| dataset load | ~30 ms |
|
|
113
|
+
|
|
114
|
+
The Python library is dependency-free (stdlib only); `is_land_batch`
|
|
115
|
+
optionally uses numpy + the trifold SDK. The JS library is a single
|
|
116
|
+
ES module, works with browser + Node.
|
|
117
|
+
|
|
118
|
+
## Files
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
build.py TFLS builder: compacted L10 grid GeoJSON -> landsea_L10.tfls
|
|
122
|
+
refine_build.py TFLR builder: land polygons + TFLS -> coastal_osm_L10.tflr
|
|
123
|
+
python/ landcheck.py (public API) · _fastloc.py (point location)
|
|
124
|
+
js/landcheck.mjs the JS library (same data, same answers)
|
|
125
|
+
data/ landsea_L10.tfls (bundled) · coastal_osm_L10.tflr (optional)
|
|
126
|
+
tests/ pytest + node:test suites, shared fixture (points.json)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Rebuild from a Trifold grid product:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python landcheck/build.py # needs data/global_tri_L10_compacted.geojson
|
|
133
|
+
python landcheck/refine_build.py --land osm_simplified_land_polygons.geojson
|
|
134
|
+
python landcheck/tests/make_fixture.py # refresh cross-language fixture
|
|
135
|
+
pytest landcheck/tests/ && node --test landcheck/tests/test_landcheck.mjs
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Format notes
|
|
139
|
+
|
|
140
|
+
Custom data format is used to ensure compactness.
|
|
141
|
+
|
|
142
|
+
**TFLS** (land/sea runs): 16-byte header + zlib stream of
|
|
143
|
+
`varint(gap), varint(length<<1 | coastal)` per run, then 4-bit land
|
|
144
|
+
fractions for coastal cells. **TFLR** (refinement): 12-byte header +
|
|
145
|
+
zlib stream of `varint(Δindex), varint(code)` per covered cell, where
|
|
146
|
+
code 0/1 = all sea/land and code n≥2 introduces n−1 quantized
|
|
147
|
+
zigzag-delta rings combined by the even-odd rule. Both formats are
|
|
148
|
+
level-agnostic (the level lives in the header), so the same tooling can
|
|
149
|
+
serve an L8 (~30 KB) or L12 (~3 MB) variant.
|
|
150
|
+
|
|
151
|
+
## Roadmap
|
|
152
|
+
|
|
153
|
+
* **Country detection**: the run-length layer maps
|
|
154
|
+
each cell to a country id instead of a land bit (runs split at borders;
|
|
155
|
+
border cells carry clipped boundary polygons like TFLR does for
|
|
156
|
+
coastlines). Probably can use same formats, same lookup path, same confidence model.
|
|
157
|
+
* Level-12 (~1.8 km trifolds) variant for higher-precision use.
|
|
158
|
+
* ~~Published packages~~ — packaged as `pip install landcheck` and
|
|
159
|
+
`npm install landcheck` (0.1.0); the core SDK is `pip/npm install t3grid`.
|
|
Binary file
|
package/js/landcheck.mjs
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* landcheck — offline land/sea lookup for lon/lat points (Trifold subproject).
|
|
3
|
+
*
|
|
4
|
+
* Loads the ~180 KB TFLS dataset (built by landcheck/build.py from the
|
|
5
|
+
* Trifold level-10 grid, ~7 km triangles, Natural Earth 1:50m land) and
|
|
6
|
+
* answers "is this point on land?" in microseconds, fully offline.
|
|
7
|
+
*
|
|
8
|
+
* import { LandCheck } from "./landcheck.mjs";
|
|
9
|
+
* const lc = await LandCheck.fromFile("../data/landsea_L10.tfls"); // Node
|
|
10
|
+
* const lc = await LandCheck.fromUrl("/data/landsea_L10.tfls"); // browser
|
|
11
|
+
* lc.isLand(24.75, 59.44) // true (Tallinn)
|
|
12
|
+
* lc.check(-0.1276, 51.5072) // {land, kind, confidence, landFraction, cell}
|
|
13
|
+
*
|
|
14
|
+
* Answer semantics (mirrors the Python library exactly):
|
|
15
|
+
* kind 'land' — cell wholly inside land -> land, confidence 1
|
|
16
|
+
* kind 'sea' — cell absent from the dataset -> sea, confidence 1
|
|
17
|
+
* kind 'coast' — mixed cell; bundled land fraction:
|
|
18
|
+
* land = fraction >= 0.5, confidence = max(f, 1 - f).
|
|
19
|
+
*
|
|
20
|
+
* With optional OSM refinement loaded, cells crossed by either source's
|
|
21
|
+
* coastline are decided by a clipped OSM polygon before the Natural Earth
|
|
22
|
+
* base classification is accepted.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const EPS = -1e-14;
|
|
26
|
+
const LON_ROT = (7.3 * Math.PI) / 180;
|
|
27
|
+
const REFINED_CONFIDENCE = 0.99; // OSM simplified polygons; quantization ~0.1 m
|
|
28
|
+
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford, as trifold.js
|
|
29
|
+
|
|
30
|
+
// ------------------------------------------------------- fast point location
|
|
31
|
+
// Pure-double re-statement of trifold.js locate(): normalized-sum midpoints,
|
|
32
|
+
// plane-side tests with the -1e-14 tolerance, first-match child order,
|
|
33
|
+
// max-margin fallback. Bit-identical to the SDK and the Python library;
|
|
34
|
+
// the test suite cross-checks all three.
|
|
35
|
+
const FACE_INDEXES = [
|
|
36
|
+
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
|
37
|
+
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
|
38
|
+
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
|
39
|
+
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function buildFaces() {
|
|
43
|
+
const phi = (1 + Math.sqrt(5)) / 2;
|
|
44
|
+
const raw = [
|
|
45
|
+
[-1, phi, 0], [1, phi, 0], [-1, -phi, 0], [1, -phi, 0],
|
|
46
|
+
[0, -1, phi], [0, 1, phi], [0, -1, -phi], [0, 1, -phi],
|
|
47
|
+
[phi, 0, -1], [phi, 0, 1], [-phi, 0, -1], [-phi, 0, 1],
|
|
48
|
+
];
|
|
49
|
+
const c = Math.cos(LON_ROT), s = Math.sin(LON_ROT);
|
|
50
|
+
const verts = raw.map(([x, y, z]) => {
|
|
51
|
+
const n = Math.sqrt(x * x + y * y + z * z);
|
|
52
|
+
x /= n; y /= n; z /= n;
|
|
53
|
+
return [c * x - s * y, s * x + c * y, z];
|
|
54
|
+
});
|
|
55
|
+
const faces = FACE_INDEXES.map(([i, j, k]) => [verts[i], verts[j], verts[k]]);
|
|
56
|
+
const cents = faces.map(([v0, v1, v2]) => {
|
|
57
|
+
const x = v0[0] + v1[0] + v2[0], y = v0[1] + v1[1] + v2[1], z = v0[2] + v1[2] + v2[2];
|
|
58
|
+
const n = Math.sqrt(x * x + y * y + z * z);
|
|
59
|
+
return [x / n, y / n, z / n];
|
|
60
|
+
});
|
|
61
|
+
return { faces, cents };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { faces: FACES, cents: CENTROIDS } = buildFaces();
|
|
65
|
+
|
|
66
|
+
function mid(a, b) {
|
|
67
|
+
const x = a[0] + b[0], y = a[1] + b[1], z = a[2] + b[2];
|
|
68
|
+
const n = Math.sqrt(x * x + y * y + z * z);
|
|
69
|
+
return [x / n, y / n, z / n];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function side(a, b, p) { // dot(cross(a, b), p)
|
|
73
|
+
return (a[1] * b[2] - a[2] * b[1]) * p[0]
|
|
74
|
+
+ (a[2] * b[0] - a[0] * b[2]) * p[1]
|
|
75
|
+
+ (a[0] * b[1] - a[1] * b[0]) * p[2];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inside(v0, v1, v2, p) {
|
|
79
|
+
return side(v0, v1, p) >= EPS && side(v1, v2, p) >= EPS && side(v2, v0, p) >= EPS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Canonical cell index `(face << 2*level) | pathBits` for a point. */
|
|
83
|
+
export function locateIndex(lon, lat, level) {
|
|
84
|
+
const lam = (lon * Math.PI) / 180, phi = (lat * Math.PI) / 180;
|
|
85
|
+
const cp = Math.cos(phi);
|
|
86
|
+
const p = [cp * Math.cos(lam), cp * Math.sin(lam), Math.sin(phi)];
|
|
87
|
+
|
|
88
|
+
let face = -1, tri = null;
|
|
89
|
+
for (let f = 0; f < 20; f++) {
|
|
90
|
+
const [v0, v1, v2] = FACES[f];
|
|
91
|
+
if (inside(v0, v1, v2, p)) { face = f; tri = FACES[f]; break; }
|
|
92
|
+
}
|
|
93
|
+
if (tri === null) { // numeric edge case: nearest face centroid
|
|
94
|
+
let best = -2;
|
|
95
|
+
for (let f = 0; f < 20; f++) {
|
|
96
|
+
const c = CENTROIDS[f];
|
|
97
|
+
const d = c[0] * p[0] + c[1] * p[1] + c[2] * p[2];
|
|
98
|
+
if (d > best) { best = d; face = f; }
|
|
99
|
+
}
|
|
100
|
+
tri = FACES[face];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let path = 0;
|
|
104
|
+
let [v0, v1, v2] = tri;
|
|
105
|
+
for (let l = 0; l < level; l++) {
|
|
106
|
+
const m01 = mid(v0, v1), m12 = mid(v1, v2), m20 = mid(v2, v0);
|
|
107
|
+
const children = [[v0, m01, m20], [m01, v1, m12], [m20, m12, v2], [m01, m12, m20]];
|
|
108
|
+
let digit = -1;
|
|
109
|
+
for (let d = 0; d < 4; d++) {
|
|
110
|
+
const [c0, c1, c2] = children[d];
|
|
111
|
+
if (inside(c0, c1, c2, p)) { digit = d; break; }
|
|
112
|
+
}
|
|
113
|
+
if (digit < 0) { // tolerance fallback: max min-margin, first max
|
|
114
|
+
let best = null;
|
|
115
|
+
for (let d = 0; d < 4; d++) {
|
|
116
|
+
const [c0, c1, c2] = children[d];
|
|
117
|
+
const m = Math.min(side(c0, c1, p), side(c1, c2, p), side(c2, c0, p));
|
|
118
|
+
if (best === null || m > best) { best = m; digit = d; }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
[v0, v1, v2] = children[digit];
|
|
122
|
+
path = path * 4 + digit; // stays well below 2^53 for level <= 10
|
|
123
|
+
}
|
|
124
|
+
return face * Math.pow(4, level) + path;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Unit-sphere triangle vertices of a canonical cell index. */
|
|
128
|
+
export function indexToTriangle(index, level) {
|
|
129
|
+
const span = Math.pow(4, level);
|
|
130
|
+
const face = Math.floor(index / span);
|
|
131
|
+
const path = index % span;
|
|
132
|
+
let [v0, v1, v2] = FACES[face];
|
|
133
|
+
for (let l = level - 1; l >= 0; l--) {
|
|
134
|
+
const digit = Math.floor(path / Math.pow(4, l)) & 3;
|
|
135
|
+
const m01 = mid(v0, v1), m12 = mid(v1, v2), m20 = mid(v2, v0);
|
|
136
|
+
if (digit === 0) { v1 = m01; v2 = m20; }
|
|
137
|
+
else if (digit === 1) { v0 = m01; v2 = m12; }
|
|
138
|
+
else if (digit === 2) { v0 = m20; v1 = m12; }
|
|
139
|
+
else { v0 = m01; v1 = m12; v2 = m20; }
|
|
140
|
+
}
|
|
141
|
+
return [v0, v1, v2];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Triangle ring in degrees, antimeridian-unwrapped (lons may exceed 180). */
|
|
145
|
+
export function indexToLonLatRing(index, level) {
|
|
146
|
+
const ring = indexToTriangle(index, level).map(([x, y, z]) => [
|
|
147
|
+
(Math.atan2(y, x) * 180) / Math.PI,
|
|
148
|
+
(Math.asin(Math.max(-1, Math.min(1, z))) * 180) / Math.PI,
|
|
149
|
+
]);
|
|
150
|
+
const lons = ring.map((p) => p[0]);
|
|
151
|
+
if (Math.max(...lons) - Math.min(...lons) > 180) {
|
|
152
|
+
return ring.map(([lon, lat]) => [lon < 0 ? lon + 360 : lon, lat]);
|
|
153
|
+
}
|
|
154
|
+
return ring;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Compact Trifold address (e.g. 'TFAVKGR') of a canonical cell index. */
|
|
158
|
+
export function indexToCompact(index, level) {
|
|
159
|
+
const span = Math.pow(4, level);
|
|
160
|
+
const face = Math.floor(index / span);
|
|
161
|
+
let path = index % span;
|
|
162
|
+
const bits = 2 * level;
|
|
163
|
+
const nchars = Math.ceil(bits / 5);
|
|
164
|
+
path *= Math.pow(2, nchars * 5 - bits); // right-pad to 5-bit boundary
|
|
165
|
+
let out = "T" + B32[face] + B32[level];
|
|
166
|
+
for (let i = nchars - 1; i >= 0; i--) {
|
|
167
|
+
out += B32[Math.floor(path / Math.pow(2, 5 * i)) & 31];
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------- TFLS decoding
|
|
173
|
+
async function inflate(compressed) {
|
|
174
|
+
if (typeof DecompressionStream === "function") {
|
|
175
|
+
const ds = new DecompressionStream("deflate"); // zlib wrapper
|
|
176
|
+
const stream = new Blob([compressed]).stream().pipeThrough(ds);
|
|
177
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
178
|
+
}
|
|
179
|
+
const zlib = await import("node:zlib"); // older Node fallback
|
|
180
|
+
return new Uint8Array(zlib.inflateSync(compressed));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class LandCheck {
|
|
184
|
+
/** Build from raw TFLS bytes (ArrayBuffer or Uint8Array). */
|
|
185
|
+
static async fromBytes(bytes) {
|
|
186
|
+
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
187
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
188
|
+
if (String.fromCharCode(...raw.subarray(0, 4)) !== "TFLS") {
|
|
189
|
+
throw new Error("not a TFLS file");
|
|
190
|
+
}
|
|
191
|
+
const version = raw[4];
|
|
192
|
+
if (version !== 1) throw new Error(`unsupported TFLS version ${version}`);
|
|
193
|
+
const level = raw[5];
|
|
194
|
+
const flags = raw[6];
|
|
195
|
+
const nRuns = view.getUint32(8, true);
|
|
196
|
+
const nCoast = view.getUint32(12, true);
|
|
197
|
+
const body = await inflate(raw.subarray(16));
|
|
198
|
+
|
|
199
|
+
const starts = new Uint32Array(nRuns);
|
|
200
|
+
const ends = new Uint32Array(nRuns);
|
|
201
|
+
const coastal = new Uint8Array(nRuns);
|
|
202
|
+
const coastBefore = new Uint32Array(nRuns);
|
|
203
|
+
let pos = 0, cursor = 0, coastSeen = 0;
|
|
204
|
+
const readVarint = () => {
|
|
205
|
+
let shift = 0, value = 0;
|
|
206
|
+
for (;;) {
|
|
207
|
+
const b = body[pos++];
|
|
208
|
+
value += (b & 0x7f) * Math.pow(2, shift); // values < 2^32, exact
|
|
209
|
+
if (!(b & 0x80)) return value;
|
|
210
|
+
shift += 7;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
for (let i = 0; i < nRuns; i++) {
|
|
214
|
+
cursor += readVarint();
|
|
215
|
+
const packed = readVarint();
|
|
216
|
+
const length = Math.floor(packed / 2);
|
|
217
|
+
starts[i] = cursor;
|
|
218
|
+
ends[i] = cursor + length;
|
|
219
|
+
coastBefore[i] = coastSeen;
|
|
220
|
+
if (packed & 1) { coastal[i] = 1; coastSeen += length; }
|
|
221
|
+
cursor += length;
|
|
222
|
+
}
|
|
223
|
+
if (coastSeen !== nCoast) throw new Error("coastal count mismatch");
|
|
224
|
+
const fractions = (flags & 1) ? body.subarray(pos) : null;
|
|
225
|
+
if (fractions && fractions.length < Math.ceil(nCoast / 2)) {
|
|
226
|
+
throw new Error("truncated fraction block");
|
|
227
|
+
}
|
|
228
|
+
return new LandCheck(level, starts, ends, coastal, coastBefore, fractions);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Load via fetch (browser or Node >= 18). */
|
|
232
|
+
static async fromUrl(url) {
|
|
233
|
+
const res = await fetch(url);
|
|
234
|
+
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
|
|
235
|
+
return LandCheck.fromBytes(await res.arrayBuffer());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Load from the filesystem (Node). Defaults to the bundled dataset. */
|
|
239
|
+
static async fromFile(path) {
|
|
240
|
+
const { readFile } = await import("node:fs/promises");
|
|
241
|
+
if (!path) {
|
|
242
|
+
const { fileURLToPath } = await import("node:url");
|
|
243
|
+
path = fileURLToPath(new URL("../data/landsea_L10.tfls", import.meta.url));
|
|
244
|
+
}
|
|
245
|
+
return LandCheck.fromBytes(await readFile(path));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
constructor(level, starts, ends, coastal, coastBefore, fractions) {
|
|
249
|
+
this.level = level;
|
|
250
|
+
this._starts = starts;
|
|
251
|
+
this._ends = ends;
|
|
252
|
+
this._coastal = coastal;
|
|
253
|
+
this._coastBefore = coastBefore;
|
|
254
|
+
this._fractions = fractions;
|
|
255
|
+
this._refine = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Load a TFLR coastal-refinement dataset (built by refine_build.py). */
|
|
259
|
+
async loadRefinement(source) {
|
|
260
|
+
let raw;
|
|
261
|
+
if (source instanceof Uint8Array || source instanceof ArrayBuffer) {
|
|
262
|
+
raw = source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
263
|
+
} else if (/^https?:|^\//.test(source) && typeof window !== "undefined") {
|
|
264
|
+
const res = await fetch(source);
|
|
265
|
+
if (!res.ok) throw new Error(`fetch ${source}: ${res.status}`);
|
|
266
|
+
raw = new Uint8Array(await res.arrayBuffer());
|
|
267
|
+
} else {
|
|
268
|
+
const { readFile } = await import("node:fs/promises");
|
|
269
|
+
raw = new Uint8Array(await readFile(source));
|
|
270
|
+
}
|
|
271
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
272
|
+
if (String.fromCharCode(...raw.subarray(0, 4)) !== "TFLR") {
|
|
273
|
+
throw new Error("not a TFLR file");
|
|
274
|
+
}
|
|
275
|
+
if (raw[4] !== 1) throw new Error(`unsupported TFLR version ${raw[4]}`);
|
|
276
|
+
if (raw[5] !== this.level) throw new Error("TFLR level mismatch");
|
|
277
|
+
const nCells = view.getUint32(8, true);
|
|
278
|
+
const body = await inflate(raw.subarray(12));
|
|
279
|
+
let pos = 0;
|
|
280
|
+
const readVarint = () => {
|
|
281
|
+
let shift = 0, value = 0;
|
|
282
|
+
for (;;) {
|
|
283
|
+
const b = body[pos++];
|
|
284
|
+
value += (b & 0x7f) * Math.pow(2, shift);
|
|
285
|
+
if (!(b & 0x80)) return value;
|
|
286
|
+
shift += 7;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const cells = new Map();
|
|
290
|
+
let index = 0;
|
|
291
|
+
for (let c = 0; c < nCells; c++) {
|
|
292
|
+
index += readVarint();
|
|
293
|
+
const code = readVarint();
|
|
294
|
+
if (code < 2) {
|
|
295
|
+
cells.set(index, code); // 0 = all sea, 1 = all land
|
|
296
|
+
} else {
|
|
297
|
+
const rings = [];
|
|
298
|
+
for (let r = 0; r < code - 1; r++) {
|
|
299
|
+
const nPts = readVarint();
|
|
300
|
+
const pts = new Int32Array(nPts * 2);
|
|
301
|
+
let x = 0, y = 0;
|
|
302
|
+
for (let i = 0; i < nPts; i++) {
|
|
303
|
+
const zx = readVarint(), zy = readVarint();
|
|
304
|
+
x += (zx >>> 1) ^ -(zx & 1);
|
|
305
|
+
y += (zy >>> 1) ^ -(zy & 1);
|
|
306
|
+
pts[2 * i] = x;
|
|
307
|
+
pts[2 * i + 1] = y;
|
|
308
|
+
}
|
|
309
|
+
rings.push(pts);
|
|
310
|
+
}
|
|
311
|
+
cells.set(index, rings);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
this._refine = cells;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_refinedLand(index, lon, lat) {
|
|
318
|
+
if (!this._refine) return null;
|
|
319
|
+
const entry = this._refine.get(index);
|
|
320
|
+
if (entry === undefined) return null;
|
|
321
|
+
if (typeof entry === "number") return entry === 1;
|
|
322
|
+
const ring = indexToLonLatRing(index, this.level);
|
|
323
|
+
const lons = ring.map((p) => p[0]), lats = ring.map((p) => p[1]);
|
|
324
|
+
const minx = Math.min(...lons), maxx = Math.max(...lons);
|
|
325
|
+
const miny = Math.min(...lats), maxy = Math.max(...lats);
|
|
326
|
+
if (maxx > 180 && lon < 0) lon += 360;
|
|
327
|
+
const qx = ((lon - minx) * 65535) / (maxx - minx);
|
|
328
|
+
const qy = ((lat - miny) * 65535) / (maxy - miny);
|
|
329
|
+
let inside = false; // even-odd rule over all rings
|
|
330
|
+
for (const pts of entry) {
|
|
331
|
+
const n = pts.length / 2;
|
|
332
|
+
let x1 = pts[2 * (n - 1)], y1 = pts[2 * (n - 1) + 1];
|
|
333
|
+
for (let i = 0; i < n; i++) {
|
|
334
|
+
const x2 = pts[2 * i], y2 = pts[2 * i + 1];
|
|
335
|
+
if ((y1 > qy) !== (y2 > qy) && qx < x1 + ((qy - y1) * (x2 - x1)) / (y2 - y1)) {
|
|
336
|
+
inside = !inside;
|
|
337
|
+
}
|
|
338
|
+
x1 = x2; y1 = y2;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return inside;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_findRun(index) {
|
|
345
|
+
const starts = this._starts;
|
|
346
|
+
let lo = 0, hi = starts.length; // bisect_right
|
|
347
|
+
while (lo < hi) {
|
|
348
|
+
const mid = (lo + hi) >>> 1;
|
|
349
|
+
if (starts[mid] <= index) lo = mid + 1; else hi = mid;
|
|
350
|
+
}
|
|
351
|
+
const run = lo - 1;
|
|
352
|
+
if (run < 0 || index >= this._ends[run]) return -1;
|
|
353
|
+
return run;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_fractionAt(run, index) {
|
|
357
|
+
if (!this._fractions) return null;
|
|
358
|
+
const n = this._coastBefore[run] + (index - this._starts[run]);
|
|
359
|
+
const byte = this._fractions[n >> 1];
|
|
360
|
+
const q = n & 1 ? byte >> 4 : byte & 0x0f;
|
|
361
|
+
return (q + 0.5) / 16;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Full answer: {land, kind, confidence, landFraction, cell}. */
|
|
365
|
+
check(lon, lat) {
|
|
366
|
+
if (!(lon >= -180 && lon <= 180)) throw new RangeError("longitude must be in [-180, 180]");
|
|
367
|
+
if (!(lat >= -90 && lat <= 90)) throw new RangeError("latitude must be in [-90, 90]");
|
|
368
|
+
const index = locateIndex(lon, lat, this.level);
|
|
369
|
+
const run = this._findRun(index);
|
|
370
|
+
const refined = this._refinedLand(index, lon, lat);
|
|
371
|
+
if (refined !== null) {
|
|
372
|
+
const fraction = run < 0 ? 0 :
|
|
373
|
+
(this._coastal[run] ? this._fractionAt(run, index) : 1);
|
|
374
|
+
return { land: refined, kind: "coast", confidence: REFINED_CONFIDENCE,
|
|
375
|
+
landFraction: fraction, cell: indexToCompact(index, this.level), refined: true };
|
|
376
|
+
}
|
|
377
|
+
if (run < 0) {
|
|
378
|
+
return { land: false, kind: "sea", confidence: 1, landFraction: 0, cell: null, refined: false };
|
|
379
|
+
}
|
|
380
|
+
const cell = indexToCompact(index, this.level);
|
|
381
|
+
if (!this._coastal[run]) {
|
|
382
|
+
return { land: true, kind: "land", confidence: 1, landFraction: 1, cell, refined: false };
|
|
383
|
+
}
|
|
384
|
+
const fraction = this._fractionAt(run, index);
|
|
385
|
+
if (fraction === null) {
|
|
386
|
+
return { land: true, kind: "coast", confidence: 0.5, landFraction: null, cell, refined: false };
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
land: fraction >= 0.5,
|
|
390
|
+
kind: "coast",
|
|
391
|
+
confidence: Math.max(fraction, 1 - fraction),
|
|
392
|
+
landFraction: fraction,
|
|
393
|
+
cell,
|
|
394
|
+
refined: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Best land/sea bool for one point. */
|
|
399
|
+
isLand(lon, lat) {
|
|
400
|
+
const index = locateIndex(lon, lat, this.level);
|
|
401
|
+
const refined = this._refinedLand(index, lon, lat);
|
|
402
|
+
if (refined !== null) return refined;
|
|
403
|
+
const run = this._findRun(index);
|
|
404
|
+
if (run < 0) return false;
|
|
405
|
+
if (!this._coastal[run]) return true;
|
|
406
|
+
const fraction = this._fractionAt(run, index);
|
|
407
|
+
return fraction === null ? true : fraction >= 0.5;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Dataset summary (for diagnostics). */
|
|
411
|
+
get stats() {
|
|
412
|
+
let interior = 0, coast = 0;
|
|
413
|
+
for (let i = 0; i < this._starts.length; i++) {
|
|
414
|
+
const n = this._ends[i] - this._starts[i];
|
|
415
|
+
if (this._coastal[i]) coast += n; else interior += n;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
level: this.level,
|
|
419
|
+
runs: this._starts.length,
|
|
420
|
+
interiorCells: interior,
|
|
421
|
+
coastalCells: coast,
|
|
422
|
+
hasFractions: !!this._fractions,
|
|
423
|
+
hasRefinement: !!this._refine,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "landcheck",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline land/sea point lookup: 182 KB global dataset, microsecond answers with confidence, built on the Trifold T3 triangular DGGS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./js/landcheck.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"js/landcheck.mjs",
|
|
11
|
+
"data/landsea_L10.tfls"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"land",
|
|
15
|
+
"sea",
|
|
16
|
+
"ocean",
|
|
17
|
+
"point-in-polygon",
|
|
18
|
+
"geospatial",
|
|
19
|
+
"offline",
|
|
20
|
+
"dggs",
|
|
21
|
+
"coastline"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://jaakla.github.io/trifold/landcheck.html",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/jaakla/trifold.git",
|
|
27
|
+
"directory": "landcheck"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|