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 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
@@ -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
+ }