signalk-underkeel 0.1.0 → 0.2.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/CHANGELOG.md +26 -0
- package/README.md +9 -3
- package/lib/engine/explain.js +8 -2
- package/lib/foundation/geometry.js +130 -14
- package/lib/ingest/cell-reader.js +4 -3
- package/lib/ingest/cell-source.js +71 -25
- package/lib/ingest/cells.js +28 -3
- package/lib/model/water-level.js +7 -1
- package/lib/spatial/pyramid.js +26 -11
- package/lib/spatial/store.js +3 -3
- package/lib/spatial/survey-shadow.js +208 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to signalk-underkeel are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Inland ENC cells (issue #50). A cell whose name and `DSID_INTU` give no usage band 1–6, as
|
|
10
|
+
Inland ENC cells give 7, takes its band from its compilation scale by the IHO S-65 ranges:
|
|
11
|
+
Rijkswaterstaat's Waddenzee cells, 1:2 000, are berthing. Before they were rejected ("usage
|
|
12
|
+
band unknown"). The Inland ENC low-water references (VERDAT 31, 36, 38) and approximate LAT
|
|
13
|
+
(42) count as low-water datums, so with no water-level source chart datum applies on them as
|
|
14
|
+
on LAT. Cells are found down to six folders below a folder holding the exchange set's
|
|
15
|
+
`CATALOG.031`, so an unzipped Inland ENC download (`ENC_ROOT/1R/7/<cell>/<cell>.000`) can be
|
|
16
|
+
set as the cells directory as it is.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Survey shadow: a charted shoal beyond the boat's buffer counts by the chance the chart is off
|
|
21
|
+
by its distance, and now also by the chance that the water charted closer is off as far: what
|
|
22
|
+
that water's survey measured lies under the boat unless it is. A CATZOC D drying flat 300 m
|
|
23
|
+
beyond 250 m of CATZOC B water no longer counts, nor do its no-coverage or unassessed flags;
|
|
24
|
+
in D water alone nothing changes. The explanation names the water that bounded it ("behind
|
|
25
|
+
30 m of water placed ±50 m"), and the navigation-safety schema carries it as
|
|
26
|
+
`chartPosition.shadow`. The UKC overlay does not apply it yet and stays at least as severe.
|
|
27
|
+
- Two cells, or two survey-quality zones, that meet along one line with their vertices split
|
|
28
|
+
differently, overlapping or up to 0.5 m apart now cover the leaves on that seam. Before,
|
|
29
|
+
every such leaf read no coverage or unassessed. The index is rebuilt once (pyramid version 11).
|
|
30
|
+
|
|
5
31
|
## [0.1.0] - 2026-10-25
|
|
6
32
|
|
|
7
33
|
### Added
|
package/README.md
CHANGED
|
@@ -94,10 +94,14 @@ cells.
|
|
|
94
94
|
The plugin reads unencrypted S-57 ENC cells as they are delivered: each `.000` base cell with
|
|
95
95
|
its `.001`, `.002`, … updates beside it. Copy the exchange set, for example its `ENC_ROOT`
|
|
96
96
|
folder, under one directory and set **ENC cells directory** to it; `.000` files are found down
|
|
97
|
-
to three folders deep
|
|
97
|
+
to three folders deep, and down to six below a folder holding the exchange set's `CATALOG.031`,
|
|
98
|
+
so an unzipped Inland ENC download (`<set>/ENC_ROOT/1R/7/1R7WAD07/1R7WAD07.000`) can be pointed
|
|
99
|
+
at as it is. The cells are read in place with GDAL compiled to WebAssembly
|
|
98
100
|
(gdal3.js), which the plugin installs with it, and the updates are applied as they are read.
|
|
99
101
|
Nothing is converted or written next to the cells. A cell without an `M_COVR` coverage layer
|
|
100
|
-
is rejected.
|
|
102
|
+
is rejected. A cell's usage band comes from the third character of its name, else from its
|
|
103
|
+
`DSID_INTU`, else from its compilation scale by the IHO ranges: Inland ENC cells, purpose 7 in
|
|
104
|
+
both, at 1:2 000 are berthing (6).
|
|
101
105
|
|
|
102
106
|
Land, shoreline and depth areas are placed by the cell's compilation scale (6 m at 1:12 000,
|
|
103
107
|
or inside an `M_CSCL` area that area's smaller scale) rather than by the survey's CATZOC (500 m
|
|
@@ -195,7 +199,9 @@ style.
|
|
|
195
199
|
|
|
196
200
|
The chart gives depths below chart datum; the water level is how far the sea stands above it.
|
|
197
201
|
|
|
198
|
-
- `none`: chart datum, with σ 0.3 m for the unknown tide.
|
|
202
|
+
- `none`: chart datum, with σ 0.3 m for the unknown tide. Low-water datums include the Inland
|
|
203
|
+
ENC low-water references (VERDAT 31, 36, 38) and approximate LAT (42, Rijkswaterstaat's
|
|
204
|
+
Waddenzee cells). On a chart whose datum is not a
|
|
199
205
|
low-water datum, such as the Finnish mean-water ENCs (VERDAT 3, 19 and 26), this is a degraded
|
|
200
206
|
level: the state is at best `alert`, route legs at best `unverified`, nothing `clear`, and
|
|
201
207
|
acknowledgements and own soundings are not used until a source is configured. The reason names
|
package/lib/engine/explain.js
CHANGED
|
@@ -33,7 +33,10 @@ function explain(tile, extras, v, wl) {
|
|
|
33
33
|
distance: Math.round(c.distance),
|
|
34
34
|
sigmaP: r2(c.sigmaP),
|
|
35
35
|
weight: Math.round(c.weight * 10000) / 10000,
|
|
36
|
-
source: c.source
|
|
36
|
+
source: c.source,
|
|
37
|
+
...(c.shadow && {
|
|
38
|
+
shadow: { distance: Math.round(c.shadow.distance), sigmaP: r2(c.shadow.sigmaP) }
|
|
39
|
+
})
|
|
37
40
|
}
|
|
38
41
|
}),
|
|
39
42
|
mean: finite(u.mean),
|
|
@@ -106,7 +109,10 @@ function sentence(e) {
|
|
|
106
109
|
const p = e.chartPosition;
|
|
107
110
|
if (p) {
|
|
108
111
|
const what = p.source === "topography" ? "chart drawing" : "survey";
|
|
109
|
-
|
|
112
|
+
const behind = p.shadow
|
|
113
|
+
? `, behind ${m(p.shadow.distance)} of water placed ±${m(2 * p.shadow.sigmaP)}`
|
|
114
|
+
: "";
|
|
115
|
+
terms[0] = `${terms[0]} ${m(p.distance)} away, placed ±${m(2 * p.sigmaP)} by its ${what}${behind} (${pct(p.weight)} % under the keel)`;
|
|
110
116
|
}
|
|
111
117
|
const mean = e.mean === null ? "−∞ m" : m(e.mean).replace(/^-/, "−");
|
|
112
118
|
return `${terms.join(" ")} → μ = ${mean}, σ = ${m(e.sigma)}, P = ${pct(e.risk)} %`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SEAM_TOLERANCE = void 0;
|
|
3
4
|
exports.geomBounds = geomBounds;
|
|
4
5
|
exports.shiftGeom = shiftGeom;
|
|
5
6
|
exports.pointInRing = pointInRing;
|
|
@@ -307,27 +308,142 @@ function collectEdges(poly, r, sides) {
|
|
|
307
308
|
return true;
|
|
308
309
|
}
|
|
309
310
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
311
|
+
* Gaps between areas no wider than this, in Mercator metres (at most as
|
|
312
|
+
* many metres on the ground), count as covered by `areasCoverRect`. Two
|
|
313
|
+
* cells, or two quality zones, that meet along one line are often drawn
|
|
314
|
+
* with their vertices split differently along it, or a few centimetres
|
|
315
|
+
* apart: Rijkswaterstaat's Inland ENC cells 1R7WAD07 and 1R7WAD10 meet so,
|
|
316
|
+
* and their CATZOC A1 and B zones leave a sliver under 10 cm. Read exactly,
|
|
317
|
+
* every tile on such a seam had no coverage or no assessed survey.
|
|
318
|
+
*/
|
|
319
|
+
exports.SEAM_TOLERANCE = 0.5;
|
|
320
|
+
/** Where segment a→b crosses segment c→d, as a fraction along a→b; undefined when it does not. */
|
|
321
|
+
function crossingAt(a, b, c, d) {
|
|
322
|
+
const rx = b[0] - a[0];
|
|
323
|
+
const ry = b[1] - a[1];
|
|
324
|
+
const sx = d[0] - c[0];
|
|
325
|
+
const sy = d[1] - c[1];
|
|
326
|
+
const den = rx * sy - ry * sx;
|
|
327
|
+
if (den === 0) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const qx = c[0] - a[0];
|
|
331
|
+
const qy = c[1] - a[1];
|
|
332
|
+
const t = (qx * sy - qy * sx) / den;
|
|
333
|
+
const u = (qx * ry - qy * rx) / den;
|
|
334
|
+
return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : undefined;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Whether the line SEAM_TOLERANCE off edge a→b on its `outward` side (+1
|
|
338
|
+
* left, −1 right) lies in the areas wherever the edge is in `r`. Along that
|
|
339
|
+
* line the areas only start or stop where it crosses one of their edges, so
|
|
340
|
+
* the midpoint between each pair of crossings decides its stretch.
|
|
341
|
+
*/
|
|
342
|
+
function outsideCovered(a, b, len, outward, polys, r) {
|
|
343
|
+
if (!segmentIntersectsRect(a, b, r)) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const [t0, t1] = [span.t0, span.t1];
|
|
347
|
+
const dx = b[0] - a[0];
|
|
348
|
+
const dy = b[1] - a[1];
|
|
349
|
+
const nx = (-dy / len) * exports.SEAM_TOLERANCE * outward;
|
|
350
|
+
const ny = (dx / len) * exports.SEAM_TOLERANCE * outward;
|
|
351
|
+
const p = [a[0] + dx * t0 + nx, a[1] + dy * t0 + ny];
|
|
352
|
+
const q = [a[0] + dx * t1 + nx, a[1] + dy * t1 + ny];
|
|
353
|
+
const ts = crossings(p, q, polys);
|
|
354
|
+
for (let k = 0; k + 1 < ts.length; k++) {
|
|
355
|
+
const m = (ts[k] + ts[k + 1]) / 2;
|
|
356
|
+
const x = p[0] + (q[0] - p[0]) * m;
|
|
357
|
+
const y = p[1] + (q[1] - p[1]) * m;
|
|
358
|
+
if (!polys.some((poly) => inPolygon(x, y, poly))) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
/** 0, 1 and every fraction along p→q where it crosses an edge of the areas, in order. */
|
|
365
|
+
function crossings(p, q, polys) {
|
|
366
|
+
const [minX, maxX] = [Math.min(p[0], q[0]), Math.max(p[0], q[0])];
|
|
367
|
+
const [minY, maxY] = [Math.min(p[1], q[1]), Math.max(p[1], q[1])];
|
|
368
|
+
const ts = [0, 1];
|
|
369
|
+
for (const poly of polys) {
|
|
370
|
+
for (const ring of [poly.outer, ...poly.holes]) {
|
|
371
|
+
const n = ring.length;
|
|
372
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
373
|
+
const c = ring[j];
|
|
374
|
+
const d = ring[i];
|
|
375
|
+
if (Math.max(c[0], d[0]) < minX ||
|
|
376
|
+
Math.min(c[0], d[0]) > maxX ||
|
|
377
|
+
Math.max(c[1], d[1]) < minY ||
|
|
378
|
+
Math.min(c[1], d[1]) > maxY) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const t = crossingAt(p, q, c, d);
|
|
382
|
+
if (t !== undefined) {
|
|
383
|
+
ts.push(t);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return ts.sort((x, y) => x - y);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* `areasCoverRect` across seams: every edge touching `r` that bounds its own
|
|
392
|
+
* area on one side has the areas, within SEAM_TOLERANCE, on the other, so no
|
|
393
|
+
* boundary of the union, seams aside, crosses `r`. Then an edge touching it
|
|
394
|
+
* puts it inside the union; with none, its centre decides.
|
|
395
|
+
*/
|
|
396
|
+
function coverAcrossSeams(polys, r) {
|
|
397
|
+
let touched = false;
|
|
398
|
+
for (const poly of polys) {
|
|
399
|
+
for (const ring of [poly.outer, ...poly.holes]) {
|
|
400
|
+
const n = ring.length;
|
|
401
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
402
|
+
const a = ring[j];
|
|
403
|
+
const b = ring[i];
|
|
404
|
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
405
|
+
if (len === 0 || !segmentIntersectsRect(a, b, r)) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const sides = interiorSides(a, b, len, poly);
|
|
409
|
+
// An edge with area on both sides of its own, or on neither (a spike), bounds nothing.
|
|
410
|
+
if (sides === LEFT || sides === RIGHT) {
|
|
411
|
+
touched = true;
|
|
412
|
+
if (!outsideCovered(a, b, len, sides === LEFT ? -1 : 1, polys, r)) {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const cx = (r.minX + r.maxX) / 2;
|
|
420
|
+
const cy = (r.minY + r.maxY) / 2;
|
|
421
|
+
return touched || polys.some((poly) => inPolygon(cx, cy, poly));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* True only when the areas together cover the closed rectangle, gaps no
|
|
425
|
+
* wider than SEAM_TOLERANCE aside. Where two polygons share each edge vertex
|
|
426
|
+
* for vertex, every edge that touches the rectangle has area on both sides;
|
|
427
|
+
* with no boundary of the union crossing the rectangle, its centre decides.
|
|
428
|
+
* Otherwise each such edge is followed across, `SEAM_TOLERANCE` out, so an
|
|
429
|
+
* edge split differently on each side, two areas that overlap, or a seam
|
|
430
|
+
* narrower than that still cover. Callers read false as "not covered".
|
|
316
431
|
*/
|
|
317
432
|
function areasCoverRect(geoms, r) {
|
|
318
433
|
const sides = new Map();
|
|
319
434
|
const polys = geoms.flatMap((g) => (g.type === "polygon" ? g.polygons : []));
|
|
435
|
+
let shared = true;
|
|
320
436
|
for (const poly of polys) {
|
|
321
437
|
if (!collectEdges(poly, r, sides)) {
|
|
322
|
-
|
|
438
|
+
shared = false;
|
|
439
|
+
break;
|
|
323
440
|
}
|
|
324
441
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
442
|
+
if (shared && [...sides.values()].every((s) => s === (LEFT | RIGHT))) {
|
|
443
|
+
const cx = (r.minX + r.maxX) / 2;
|
|
444
|
+
const cy = (r.minY + r.maxY) / 2;
|
|
445
|
+
return polys.some((poly) => inPolygon(cx, cy, poly));
|
|
329
446
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return polys.some((poly) => inPolygon(cx, cy, poly));
|
|
447
|
+
// One area alone has no seam: an edge of it touching the rectangle is the union's.
|
|
448
|
+
return polys.length > 1 && coverAcrossSeams(polys, r);
|
|
333
449
|
}
|
|
@@ -33,16 +33,17 @@ const positive = (n) => n !== undefined && n > 0 ? n : undefined;
|
|
|
33
33
|
function cellInfoFromDsid(props, fallbackName) {
|
|
34
34
|
const dsnm = typeof props.DSID_DSNM === "string" ? props.DSID_DSNM : fallbackName;
|
|
35
35
|
const name = dsnm.replace(/\.\d{3}$/, "");
|
|
36
|
+
const compilationScale = positive(numeric(props.DSPM_CSCL));
|
|
36
37
|
return {
|
|
37
38
|
name,
|
|
38
39
|
edition: numeric(props.DSID_EDTN) ?? 0,
|
|
39
40
|
update: numeric(props.DSID_UPDN) ?? 0,
|
|
40
41
|
issueDate: typeof props.DSID_ISDT === "string" ? props.DSID_ISDT : undefined,
|
|
41
|
-
band: (0, cells_1.
|
|
42
|
+
band: (0, cells_1.usageBandOf)(name, numeric(props.DSID_INTU), compilationScale),
|
|
42
43
|
verticalDatum: numeric(props.DSPM_VDAT),
|
|
43
44
|
soundingDatum: numeric(props.DSPM_SDAT),
|
|
44
45
|
source: typeof props.UK_SOURCE === "string" ? props.UK_SOURCE : undefined,
|
|
45
|
-
compilationScale
|
|
46
|
+
compilationScale
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
/** The layers of an exported cell directory, one `<LAYER>.geojson` each. */
|
|
@@ -91,7 +92,7 @@ function readCellLayers(layers, fallbackName, cellRef, bandOverride, where = fal
|
|
|
91
92
|
const partial = cellInfoFrom(layers, fallbackName);
|
|
92
93
|
const band = bandOverride ?? partial.band;
|
|
93
94
|
if (band === undefined) {
|
|
94
|
-
throw new Error(`cell ${partial.name}: usage band unknown
|
|
95
|
+
throw new Error(`cell ${partial.name}: usage band unknown: no band 1–6 in its name or DSID_INTU, and no compilation scale`);
|
|
95
96
|
}
|
|
96
97
|
const info = { ...partial, band };
|
|
97
98
|
const advisory = info.source === wfs_1.WFS_SOURCE ? true : undefined;
|
|
@@ -43,6 +43,14 @@ function readCell(source, cellRef) {
|
|
|
43
43
|
}
|
|
44
44
|
/** How deep below the cells directory S-57 cells are looked for: ENC_ROOT/<cell>/<cell>.000. */
|
|
45
45
|
const MAX_DEPTH = 4;
|
|
46
|
+
/**
|
|
47
|
+
* How many folders below an exchange set's root, the directory holding its
|
|
48
|
+
* CATALOG.031, a cell may sit (as MAX_DEPTH − 1 below the cells directory).
|
|
49
|
+
* S-57 leaves the layout under it to the producer: Rijkswaterstaat's Inland
|
|
50
|
+
* ENC sets hold ENC_ROOT/1R/7/1R7WAD07/1R7WAD07.000, three folders down.
|
|
51
|
+
*/
|
|
52
|
+
const EXCHANGE_SET_DEPTH = 6;
|
|
53
|
+
const isCatalogue = (name) => /^catalog\.031$/i.test(name);
|
|
46
54
|
function isDirectory(p) {
|
|
47
55
|
try {
|
|
48
56
|
return fs_1.default.statSync(p).isDirectory();
|
|
@@ -53,8 +61,12 @@ function isDirectory(p) {
|
|
|
53
61
|
}
|
|
54
62
|
/**
|
|
55
63
|
* The S-57 cells under `cellsDir`: every `.000` file down to MAX_DEPTH, such
|
|
56
|
-
* as in an ENC_ROOT exchange set
|
|
57
|
-
*
|
|
64
|
+
* as in an ENC_ROOT exchange set, and down to EXCHANGE_SET_DEPTH below any
|
|
65
|
+
* directory holding a CATALOG.031 within it. A name met twice keeps its first
|
|
66
|
+
* file in path order; one file met again through a link counts once. A
|
|
67
|
+
* directory is read again only when reached with more depth left than
|
|
68
|
+
* before, and never from within itself, so a linked loop ends where it comes
|
|
69
|
+
* back.
|
|
58
70
|
*/
|
|
59
71
|
function scanCellSources(cellsDir) {
|
|
60
72
|
const scan = { sources: [], skipped: [] };
|
|
@@ -62,7 +74,18 @@ function scanCellSources(cellsDir) {
|
|
|
62
74
|
return scan;
|
|
63
75
|
}
|
|
64
76
|
const seen = new Set();
|
|
77
|
+
const files = new Set();
|
|
78
|
+
/** Folders named like a cell, by real path: skipped unless a cell is read from under one. */
|
|
79
|
+
const named = new Map();
|
|
80
|
+
/** Real paths of the folders a `.000` was met in; a linked file's target may lie elsewhere. */
|
|
81
|
+
const holding = new Set();
|
|
65
82
|
const add = (source) => {
|
|
83
|
+
holding.add(fs_1.default.realpathSync(path_1.default.dirname(source)));
|
|
84
|
+
const real = fs_1.default.realpathSync(source);
|
|
85
|
+
if (files.has(real)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
files.add(real);
|
|
66
89
|
const name = sourceName(source);
|
|
67
90
|
if (seen.has(name)) {
|
|
68
91
|
scan.skipped.push(path_1.default.relative(cellsDir, source));
|
|
@@ -71,34 +94,57 @@ function scanCellSources(cellsDir) {
|
|
|
71
94
|
seen.add(name);
|
|
72
95
|
scan.sources.push(source);
|
|
73
96
|
};
|
|
74
|
-
|
|
97
|
+
/** Depth left when each real directory was read, and those being read now. */
|
|
98
|
+
const left = new Map();
|
|
99
|
+
const open = new Set();
|
|
100
|
+
const take = (dir, e, depth, limit) => {
|
|
101
|
+
const full = path_1.default.join(dir, e.name);
|
|
102
|
+
const directory = e.isDirectory() || (e.isSymbolicLink() && isDirectory(full));
|
|
103
|
+
if (directory) {
|
|
104
|
+
if ((0, cells_1.usageBandFromName)(e.name) !== undefined) {
|
|
105
|
+
named.set(path_1.default.relative(cellsDir, full), fs_1.default.realpathSync(full));
|
|
106
|
+
scan.skipped.push(path_1.default.relative(cellsDir, full));
|
|
107
|
+
}
|
|
108
|
+
if (depth < limit) {
|
|
109
|
+
walk(full, depth + 1, limit);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if ((0, exports.isEncFile)(e.name) && fs_1.default.existsSync(full)) {
|
|
113
|
+
add(full);
|
|
114
|
+
}
|
|
115
|
+
else if (e.isSymbolicLink() && !fs_1.default.existsSync(full)) {
|
|
116
|
+
// A dangling link is most likely a cell the user expects read.
|
|
117
|
+
scan.skipped.push(path_1.default.relative(cellsDir, full));
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/** Reads the entries of `dir`, at `depth`, and its directories down to `deepest` (deeper below a CATALOG.031). */
|
|
121
|
+
function walk(dir, depth, deepest) {
|
|
122
|
+
// A link back up an exchange set would otherwise raise the limit at every turn.
|
|
123
|
+
const real = fs_1.default.realpathSync(dir);
|
|
124
|
+
if (open.has(real) || (left.get(real) ?? -1) >= deepest - depth) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
left.set(real, deepest - depth);
|
|
128
|
+
open.add(real);
|
|
75
129
|
const entries = fs_1.default
|
|
76
130
|
.readdirSync(dir, { withFileTypes: true })
|
|
77
131
|
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
132
|
+
const limit = entries.some((e) => isCatalogue(e.name))
|
|
133
|
+
? Math.max(deepest, depth + EXCHANGE_SET_DEPTH)
|
|
134
|
+
: deepest;
|
|
78
135
|
for (const e of entries) {
|
|
79
|
-
if (e.name.startsWith(".")) {
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
const full = path_1.default.join(dir, e.name);
|
|
83
|
-
const directory = e.isDirectory() || (e.isSymbolicLink() && isDirectory(full));
|
|
84
|
-
if (directory) {
|
|
85
|
-
const before = scan.sources.length;
|
|
86
|
-
if (depth < MAX_DEPTH) {
|
|
87
|
-
walk(full, depth + 1);
|
|
88
|
-
}
|
|
89
|
-
if (scan.sources.length === before && (0, cells_1.usageBandFromName)(e.name) !== undefined) {
|
|
90
|
-
scan.skipped.push(path_1.default.relative(cellsDir, full));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
else if ((0, exports.isEncFile)(e.name) && fs_1.default.existsSync(full)) {
|
|
94
|
-
add(full);
|
|
95
|
-
}
|
|
96
|
-
else if (e.isSymbolicLink() && !fs_1.default.existsSync(full)) {
|
|
97
|
-
// A dangling link is most likely a cell the user expects read.
|
|
98
|
-
scan.skipped.push(path_1.default.relative(cellsDir, full));
|
|
136
|
+
if (!e.name.startsWith(".")) {
|
|
137
|
+
take(dir, e, depth, limit);
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
|
-
|
|
102
|
-
|
|
140
|
+
open.delete(real);
|
|
141
|
+
}
|
|
142
|
+
walk(cellsDir, 1, MAX_DEPTH);
|
|
143
|
+
// Decided once every path is read: a folder reached too deep by one path may be read by another.
|
|
144
|
+
const holds = (dir) => [...holding].some((d) => d === dir || d.startsWith(dir + path_1.default.sep));
|
|
145
|
+
scan.skipped = scan.skipped.filter((entry) => {
|
|
146
|
+
const real = named.get(entry);
|
|
147
|
+
return real === undefined || !holds(real);
|
|
148
|
+
});
|
|
103
149
|
return scan;
|
|
104
150
|
}
|
package/lib/ingest/cells.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.compilationSigma = compilationSigma;
|
|
8
8
|
exports.usageBandFromName = usageBandFromName;
|
|
9
|
+
exports.usageBandFromScale = usageBandFromScale;
|
|
10
|
+
exports.usageBandOf = usageBandOf;
|
|
9
11
|
exports.parseCatalogue = parseCatalogue;
|
|
10
12
|
/**
|
|
11
13
|
* Plotting accuracy of a chart compiled at scale 1:`scale`, as a 1σ in
|
|
@@ -20,9 +22,32 @@ function compilationSigma(scale) {
|
|
|
20
22
|
* undefined so the caller must supply the band explicitly.
|
|
21
23
|
*/
|
|
22
24
|
function usageBandFromName(name) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
return usageBand(Number(name.charAt(2)));
|
|
26
|
+
}
|
|
27
|
+
function usageBand(n) {
|
|
28
|
+
return n !== undefined && Number.isInteger(n) && n >= 1 && n <= 6 ? n : undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The usage band of a compilation scale 1:`scale`, by the IHO S-65 scale
|
|
32
|
+
* ranges: overview below 1:1 500 000, general to 1:350 000, coastal to
|
|
33
|
+
* 1:90 000, approach to 1:22 000, harbour to 1:4 000, berthing above it.
|
|
34
|
+
*/
|
|
35
|
+
function usageBandFromScale(scale) {
|
|
36
|
+
if (scale === undefined || !(scale > 0)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const limits = [1_500_000, 350_000, 90_000, 22_000, 4_000];
|
|
40
|
+
const i = limits.findIndex((limit) => scale >= limit);
|
|
41
|
+
return i < 0 ? 6 : i + 1;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A cell's usage band: the third character of its name, else the intended
|
|
45
|
+
* usage of its DSID record (`DSID_INTU`), else its compilation scale. Inland
|
|
46
|
+
* ENC cells carry 7 in both (Rijkswaterstaat's 1R7WAD07, 1:2 000), so they
|
|
47
|
+
* fall to the scale: berthing.
|
|
48
|
+
*/
|
|
49
|
+
function usageBandOf(name, intu, scale) {
|
|
50
|
+
return usageBandFromName(name) ?? usageBand(intu) ?? usageBandFromScale(scale);
|
|
26
51
|
}
|
|
27
52
|
function finiteOrUndefined(value) {
|
|
28
53
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
package/lib/model/water-level.js
CHANGED
|
@@ -165,8 +165,14 @@ function iso(t) {
|
|
|
165
165
|
/**
|
|
166
166
|
* S-57 VERDAT codes of low-water datums: the tide seldom leaves less water
|
|
167
167
|
* than charted, so chart datum with σ 0.3 m stands in for an unknown level.
|
|
168
|
+
* The Inland ENC encoding guide adds 31 (local low water reference level),
|
|
169
|
+
* 36 (the Danube Commission's low water level), 38 (the Dutch river low
|
|
170
|
+
* water reference level, OLR) and 42 (approximate LAT, Rijkswaterstaat's
|
|
171
|
+
* Waddenzee cells).
|
|
168
172
|
*/
|
|
169
|
-
const LOW_WATER_DATUMS = new Set([
|
|
173
|
+
const LOW_WATER_DATUMS = new Set([
|
|
174
|
+
1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 22, 23, 27, 31, 36, 38, 42
|
|
175
|
+
]);
|
|
170
176
|
function isLowWaterDatum(verdat) {
|
|
171
177
|
return LOW_WATER_DATUMS.has(verdat);
|
|
172
178
|
}
|
package/lib/spatial/pyramid.js
CHANGED
|
@@ -9,6 +9,7 @@ const geometry_1 = require("../foundation/geometry");
|
|
|
9
9
|
const node_store_1 = require("./node-store");
|
|
10
10
|
const walk_1 = require("./walk");
|
|
11
11
|
const chart_position_1 = require("./chart-position");
|
|
12
|
+
const survey_shadow_1 = require("./survey-shadow");
|
|
12
13
|
/** A footprint ring in WGS84, as a Mercator polygon. */
|
|
13
14
|
function footprintGeom(ring) {
|
|
14
15
|
const outer = ring.map((p) => (0, geo_1.toMercator)(p[0], p[1]));
|
|
@@ -87,6 +88,10 @@ function bathyRisk(w, bathy, sigmaD, bound) {
|
|
|
87
88
|
}
|
|
88
89
|
return (0, walk_1.riskAt)(w, bathy, sigmaD);
|
|
89
90
|
}
|
|
91
|
+
/** Whether node i's flags reach the footprint from `ground` metres beyond the buffer. */
|
|
92
|
+
function flagsReach(w, i, ground) {
|
|
93
|
+
return ground <= (0, chart_position_1.flagReach)(w.store.surveySigma(i)) && !(0, survey_shadow_1.flagsShadowed)(w.shadow, ground);
|
|
94
|
+
}
|
|
90
95
|
/**
|
|
91
96
|
* The weighted P a tile beyond the buffer adds, `ground` metres past it:
|
|
92
97
|
* its survey depth's P times the chance its survey is off that far, or its
|
|
@@ -101,11 +106,14 @@ function reachRisk(w, i, v, ground, bound) {
|
|
|
101
106
|
// tile just past the buffer would then read a higher P than within it.
|
|
102
107
|
const bathy = Math.max(w.store.bathyDMinOf(i), v.dMin);
|
|
103
108
|
let best;
|
|
109
|
+
// The shadow is only searched for a P that could raise the footprint's, past
|
|
110
|
+
// RISK_STOP_BELOW or the P found so far, as the walk only stops below one.
|
|
111
|
+
const floor = Math.max(w.maxRisk, exports.RISK_STOP_BELOW);
|
|
104
112
|
// A split node's flags hold NO_DEPTH when any leaf under it has no depth;
|
|
105
113
|
// its bathyDMin is Infinity only when none has any.
|
|
106
114
|
if (survey > 0 && bathy !== Infinity) {
|
|
107
|
-
const
|
|
108
|
-
const risk =
|
|
115
|
+
const p = bathyRisk(w, bathy, v.sigmaD, bound);
|
|
116
|
+
const { risk, weight, shadow } = (0, survey_shadow_1.shadedWeight)(w.shadow, ground, survey, p, floor);
|
|
109
117
|
best = {
|
|
110
118
|
risk,
|
|
111
119
|
dMin: bathy,
|
|
@@ -114,15 +122,16 @@ function reachRisk(w, i, v, ground, bound) {
|
|
|
114
122
|
distance: ground,
|
|
115
123
|
sigmaP: Math.min(chart_position_1.MAX_CHART_SIGMA, survey),
|
|
116
124
|
weight,
|
|
117
|
-
source: "survey"
|
|
125
|
+
source: "survey",
|
|
126
|
+
...(shadow && { shadow })
|
|
118
127
|
}
|
|
119
128
|
};
|
|
120
129
|
}
|
|
121
130
|
// Land reads −Infinity, which v.dMin then is too; float32 rounding as for the survey depth.
|
|
122
131
|
const drawn = Math.max(w.store.topoDMinOf(i), v.dMin);
|
|
123
132
|
if (topo > 0 && drawn !== Infinity) {
|
|
124
|
-
const
|
|
125
|
-
const risk =
|
|
133
|
+
const p = bathyRisk(w, drawn, v.sigmaD, bound);
|
|
134
|
+
const { risk, weight, shadow } = (0, survey_shadow_1.shadedWeight)(w.shadow, ground, topo, p, floor);
|
|
126
135
|
if (!best || risk > best.risk) {
|
|
127
136
|
best = {
|
|
128
137
|
risk,
|
|
@@ -131,7 +140,8 @@ function reachRisk(w, i, v, ground, bound) {
|
|
|
131
140
|
distance: ground,
|
|
132
141
|
sigmaP: Math.min(chart_position_1.MAX_CHART_SIGMA, topo),
|
|
133
142
|
weight,
|
|
134
|
-
source: "topography"
|
|
143
|
+
source: "topography",
|
|
144
|
+
...(shadow && { shadow })
|
|
135
145
|
}
|
|
136
146
|
};
|
|
137
147
|
}
|
|
@@ -140,7 +150,7 @@ function reachRisk(w, i, v, ground, bound) {
|
|
|
140
150
|
}
|
|
141
151
|
/** Counts a leaf, or a stand-in, beyond the buffer by its chart position. */
|
|
142
152
|
function takeBeyond(w, i, v, flags, ground, bound, z, x, y) {
|
|
143
|
-
if (
|
|
153
|
+
if (flagsReach(w, i, ground)) {
|
|
144
154
|
(0, walk_1.foldFlags)(w, v, flags);
|
|
145
155
|
}
|
|
146
156
|
if (!w.risk) {
|
|
@@ -172,7 +182,7 @@ function visitBeyond(w, i, ground, z, x, y) {
|
|
|
172
182
|
takeBeyond(w, i, v, v.flags, ground, false, z, x, y);
|
|
173
183
|
return;
|
|
174
184
|
}
|
|
175
|
-
const inFlags =
|
|
185
|
+
const inFlags = flagsReach(w, i, ground);
|
|
176
186
|
const bound = w.risk ? (reachRisk(w, i, v, ground, true)?.risk ?? 0) : 0;
|
|
177
187
|
const settled = bound <= Math.max(w.maxRisk, exports.RISK_STOP_BELOW);
|
|
178
188
|
if (settled && (!inFlags || (v.flags & PRECISE_FLAGS) === 0)) {
|
|
@@ -229,7 +239,7 @@ function negligible(w, i, gap) {
|
|
|
229
239
|
return false;
|
|
230
240
|
}
|
|
231
241
|
const ground = (gap - w.reachBase) / w.perMetre;
|
|
232
|
-
if (
|
|
242
|
+
if (flagsReach(w, i, ground)) {
|
|
233
243
|
return false;
|
|
234
244
|
}
|
|
235
245
|
if (!w.risk) {
|
|
@@ -330,6 +340,8 @@ function walkFor(store, geom, opts) {
|
|
|
330
340
|
// past the threshold: there P rises with σ, so the parent's widest σ
|
|
331
341
|
// bounds every child's P from above.
|
|
332
342
|
const clearAbove = Math.max(opts.clearAbove ?? (opts.risk ? -Infinity : Infinity), opts.risk?.threshold ?? -Infinity);
|
|
343
|
+
const reachBase = graded ? perMetre * bufferMetres : maxReach;
|
|
344
|
+
const toGround = (d) => Math.max(0, (d - reachBase) / perMetre);
|
|
333
345
|
return {
|
|
334
346
|
store,
|
|
335
347
|
geom,
|
|
@@ -337,7 +349,7 @@ function walkFor(store, geom, opts) {
|
|
|
337
349
|
hold: bounds,
|
|
338
350
|
// The plain walk's buffer, so the tiles within it are the same with chart position or not.
|
|
339
351
|
buffer: graded ? mercatorBuffer(bounds, bufferMetres) * exports.REACH_SLACK : maxReach,
|
|
340
|
-
reachBase
|
|
352
|
+
reachBase,
|
|
341
353
|
maxReach,
|
|
342
354
|
perMetre,
|
|
343
355
|
graded,
|
|
@@ -349,7 +361,10 @@ function walkFor(store, geom, opts) {
|
|
|
349
361
|
rect: { minX: 0, minY: 0, maxX: 0, maxY: 0 },
|
|
350
362
|
coveredDMin: Infinity,
|
|
351
363
|
cells: new Set(),
|
|
352
|
-
visits: 0
|
|
364
|
+
visits: 0,
|
|
365
|
+
shadow: graded
|
|
366
|
+
? (0, survey_shadow_1.shadowFor)(store, geom, (0, geo_1.expandRect)(bounds, maxReach), maxReach, toGround)
|
|
367
|
+
: undefined
|
|
353
368
|
};
|
|
354
369
|
}
|
|
355
370
|
/**
|
package/lib/spatial/store.js
CHANGED
|
@@ -41,11 +41,11 @@ const pyramid_blocks_1 = require("./pyramid-blocks");
|
|
|
41
41
|
* assessed water no longer burned; version 8: tiles split no finer than 1 mm
|
|
42
42
|
* at their cells' compilation scale; version 9: the drawn-depth column, depth areas placed by
|
|
43
43
|
* the chart's drawing; version 10: an M_CSCL area's drawing σ applied only on the leaves the
|
|
44
|
-
* area reaches
|
|
45
|
-
* older rules is rebuilt rather than reused.
|
|
44
|
+
* area reaches; version 11: coverage and quality zones covering a tile across seams no wider
|
|
45
|
+
* than SEAM_TOLERANCE), so an index built under older rules is rebuilt rather than reused.
|
|
46
46
|
*/
|
|
47
47
|
const MAGIC = "underkeel-pyramid";
|
|
48
|
-
exports.PYRAMID_VERSION =
|
|
48
|
+
exports.PYRAMID_VERSION = 11;
|
|
49
49
|
function columnsOf(count) {
|
|
50
50
|
return {
|
|
51
51
|
z: new Uint8Array(count),
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SurveyShadow = void 0;
|
|
4
|
+
exports.shadowFor = shadowFor;
|
|
5
|
+
exports.shadedWeight = shadedWeight;
|
|
6
|
+
exports.flagsShadowed = flagsShadowed;
|
|
7
|
+
const geo_1 = require("../foundation/geo");
|
|
8
|
+
const geometry_1 = require("../foundation/geometry");
|
|
9
|
+
const stats_1 = require("../foundation/stats");
|
|
10
|
+
const chart_position_1 = require("./chart-position");
|
|
11
|
+
const walk_1 = require("./walk");
|
|
12
|
+
/** The σ bound for a tile's subtree as the shadow reads it (MAX_CHART_SIGMA when not modelled). */
|
|
13
|
+
function levelOf(store, i, z, x, y) {
|
|
14
|
+
if (i < 0 || (store.isSplit(i) && store.standIn(i, z, x, y))) {
|
|
15
|
+
return chart_position_1.MAX_CHART_SIGMA;
|
|
16
|
+
}
|
|
17
|
+
const sigma = store.surveySigma(i);
|
|
18
|
+
if (sigma <= 0 || (store.values(i).flags & walk_1.REACH_FLAGS) !== 0) {
|
|
19
|
+
return chart_position_1.MAX_CHART_SIGMA;
|
|
20
|
+
}
|
|
21
|
+
return Math.min(sigma, chart_position_1.MAX_CHART_SIGMA);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The running maximum S(r) around one footprint, found nearest first: a
|
|
25
|
+
* best-first search over the pyramid that descends only nodes whose widest σ
|
|
26
|
+
* (or flags) could raise it, and stops once it reaches MAX_CHART_SIGMA or
|
|
27
|
+
* the distance asked. A walk asks for growing distances; the search resumes
|
|
28
|
+
* where it stopped.
|
|
29
|
+
*/
|
|
30
|
+
class SurveyShadow {
|
|
31
|
+
store;
|
|
32
|
+
geoms;
|
|
33
|
+
ground;
|
|
34
|
+
limit;
|
|
35
|
+
heap = [];
|
|
36
|
+
steps = [];
|
|
37
|
+
level = 0;
|
|
38
|
+
/** Every tile nearer than this has been read. */
|
|
39
|
+
searched = 0;
|
|
40
|
+
/**
|
|
41
|
+
* @param geoms the footprint, and its copies one world over where its reach crosses ±180°
|
|
42
|
+
* @param reach Mercator box holding every tile within the widest reach of the footprint
|
|
43
|
+
* @param ground ground metres beyond the buffer of a Mercator distance from the footprint
|
|
44
|
+
* @param limit Mercator distance beyond which no tile is read
|
|
45
|
+
*/
|
|
46
|
+
constructor(store, geoms, reaches, ground, limit) {
|
|
47
|
+
this.store = store;
|
|
48
|
+
this.geoms = geoms;
|
|
49
|
+
this.ground = ground;
|
|
50
|
+
this.limit = limit;
|
|
51
|
+
for (const reach of reaches) {
|
|
52
|
+
const range = (0, geo_1.tileRange)(store.minZoom, reach);
|
|
53
|
+
for (let x = range.minX; x <= range.maxX; x++) {
|
|
54
|
+
for (let y = range.minY; y <= range.maxY; y++) {
|
|
55
|
+
this.offer(store.minZoom, x, y);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Queues a tile within the limit, by its ground distance. */
|
|
61
|
+
offer(z, x, y) {
|
|
62
|
+
const rect = (0, geo_1.tileRect)(z, x, y);
|
|
63
|
+
let d = Infinity;
|
|
64
|
+
for (const g of this.geoms) {
|
|
65
|
+
d = Math.min(d, (0, geometry_1.geomRectDistance)(g, rect, this.limit));
|
|
66
|
+
}
|
|
67
|
+
if (d <= this.limit) {
|
|
68
|
+
this.push({ d: this.ground(d), z, x, y });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Reads tiles nearest first until every tile nearer than `g` is read. */
|
|
72
|
+
extend(g) {
|
|
73
|
+
while (this.heap.length > 0 && this.level < chart_position_1.MAX_CHART_SIGMA) {
|
|
74
|
+
const top = this.heap[0];
|
|
75
|
+
if (top.d >= g) {
|
|
76
|
+
this.searched = Math.max(this.searched, g);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.pop();
|
|
80
|
+
const { d, z, x, y } = top;
|
|
81
|
+
const i = this.store.find(z, x, y);
|
|
82
|
+
const level = levelOf(this.store, i, z, x, y);
|
|
83
|
+
if (level <= this.level) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (i >= 0 && this.store.isSplit(i) && !this.store.standIn(i, z, x, y)) {
|
|
87
|
+
this.offer(z + 1, 2 * x, 2 * y);
|
|
88
|
+
this.offer(z + 1, 2 * x + 1, 2 * y);
|
|
89
|
+
this.offer(z + 1, 2 * x, 2 * y + 1);
|
|
90
|
+
this.offer(z + 1, 2 * x + 1, 2 * y + 1);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
this.steps.push({ at: d, sigma: level });
|
|
94
|
+
this.level = level;
|
|
95
|
+
}
|
|
96
|
+
// Nothing left, or S(r) at the widest σ: no tile further out changes it.
|
|
97
|
+
this.searched = Infinity;
|
|
98
|
+
}
|
|
99
|
+
/** ρ(g) for a tile `g` ground metres beyond the buffer, and the water that set it. */
|
|
100
|
+
at(g) {
|
|
101
|
+
if (!(g > 0)) {
|
|
102
|
+
return { ratio: 0, distance: 0, sigmaP: 0 };
|
|
103
|
+
}
|
|
104
|
+
if (this.searched < g) {
|
|
105
|
+
this.extend(g);
|
|
106
|
+
}
|
|
107
|
+
let best = { ratio: 0, distance: 0, sigmaP: 0 };
|
|
108
|
+
let sigma = chart_position_1.MAX_CHART_SIGMA;
|
|
109
|
+
for (let k = 0; k < this.steps.length; k++) {
|
|
110
|
+
const step = this.steps[k];
|
|
111
|
+
if (step.at >= g) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
sigma = step.sigma;
|
|
115
|
+
const next = this.steps[k + 1];
|
|
116
|
+
// S(r) = sigma on (step.at, end]: r/S(r) is largest at the end.
|
|
117
|
+
const end = next !== undefined && next.at < g ? next.at : g;
|
|
118
|
+
if (end / sigma > best.ratio) {
|
|
119
|
+
best = { ratio: end / sigma, distance: end, sigmaP: sigma };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// No tile nearer than g at all: the water in between is not modelled.
|
|
123
|
+
if (this.steps.length === 0 || this.steps[0].at >= g) {
|
|
124
|
+
const ratio = g / sigma;
|
|
125
|
+
return ratio > best.ratio ? { ratio, distance: g, sigmaP: sigma } : best;
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
128
|
+
}
|
|
129
|
+
push(e) {
|
|
130
|
+
const a = this.heap;
|
|
131
|
+
a.push(e);
|
|
132
|
+
let i = a.length - 1;
|
|
133
|
+
while (i > 0) {
|
|
134
|
+
const p = (i - 1) >> 1;
|
|
135
|
+
if (a[p].d <= e.d) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
a[i] = a[p];
|
|
139
|
+
i = p;
|
|
140
|
+
}
|
|
141
|
+
a[i] = e;
|
|
142
|
+
}
|
|
143
|
+
pop() {
|
|
144
|
+
const a = this.heap;
|
|
145
|
+
const last = a.pop();
|
|
146
|
+
if (a.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let i = 0;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const l = 2 * i + 1;
|
|
152
|
+
const r = l + 1;
|
|
153
|
+
let m = i;
|
|
154
|
+
let md = last.d;
|
|
155
|
+
if (l < a.length && a[l].d < md) {
|
|
156
|
+
m = l;
|
|
157
|
+
md = a[l].d;
|
|
158
|
+
}
|
|
159
|
+
if (r < a.length && a[r].d < md) {
|
|
160
|
+
m = r;
|
|
161
|
+
}
|
|
162
|
+
if (m === i) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
a[i] = a[m];
|
|
166
|
+
i = m;
|
|
167
|
+
}
|
|
168
|
+
a[i] = last;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
exports.SurveyShadow = SurveyShadow;
|
|
172
|
+
/**
|
|
173
|
+
* The shadow around a footprint out to Mercator distance `limit`, its widest
|
|
174
|
+
* reach, within the box `reach`, with its copy one world over where that box
|
|
175
|
+
* crosses ±180°; not searched until a walk asks (`SurveyShadow`).
|
|
176
|
+
*/
|
|
177
|
+
function shadowFor(store, geom, reach, limit, ground) {
|
|
178
|
+
let shadow;
|
|
179
|
+
return () => (shadow ??= new SurveyShadow(store, (0, geo_1.worldShifts)(reach).map((s) => (s === 0 ? geom : (0, geometry_1.shiftGeom)(geom, s))), (0, geo_1.worldShifts)(reach).map((s) => (0, geo_1.shiftRect)(reach, s)), ground, limit));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A tile's chart position weight `ground` metres beyond the buffer, placed
|
|
183
|
+
* with σ, and the P it adds at P(UKC < 0) `p`, bounded by the shadow. A P
|
|
184
|
+
* at most `floor` is returned unbounded without searching: the caller only
|
|
185
|
+
* reads one above it.
|
|
186
|
+
*/
|
|
187
|
+
function shadedWeight(shadow, ground, sigma, p, floor) {
|
|
188
|
+
const weight = (0, chart_position_1.chartWeight)(ground, sigma);
|
|
189
|
+
const risk = p * weight;
|
|
190
|
+
// A tile at the buffer's edge counts in full: nothing lies between to shadow it.
|
|
191
|
+
if (!shadow || ground <= 0 || risk <= floor) {
|
|
192
|
+
return { risk, weight };
|
|
193
|
+
}
|
|
194
|
+
const s = shadow().at(ground);
|
|
195
|
+
const cap = (0, stats_1.normalCdf)(-s.ratio);
|
|
196
|
+
if (cap >= weight) {
|
|
197
|
+
return { risk, weight };
|
|
198
|
+
}
|
|
199
|
+
return { risk: p * cap, weight: cap, shadow: { distance: s.distance, sigmaP: s.sigmaP } };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether the water charted closer keeps a tile's flags from the footprint:
|
|
203
|
+
* they count within FLAG_SIGMAS·σ of its survey (`flagReach`), and here
|
|
204
|
+
* only where ρ(ground) is at most FLAG_SIGMAS too.
|
|
205
|
+
*/
|
|
206
|
+
function flagsShadowed(shadow, ground) {
|
|
207
|
+
return shadow !== undefined && shadow().at(ground).ratio > chart_position_1.FLAG_SIGMAS;
|
|
208
|
+
}
|
package/package.json
CHANGED