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 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. The cells are read in place with GDAL compiled to WebAssembly
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. On a chart whose datum is not a
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
@@ -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
- terms[0] = `${terms[0]} ${m(p.distance)} away, placed ±${m(2 * p.sigmaP)} by its ${what} (${pct(p.weight)} % under the keel)`;
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
- * True only when the areas together cover the closed rectangle. Every edge
311
- * that touches it must have area on both sides, which holds where two
312
- * polygons share the edge vertex for vertex; with no boundary of the union
313
- * crossing the rectangle, its centre decides. An edge split differently on
314
- * each side, or two areas that overlap rather than meet, returns false:
315
- * callers read false as "not covered", so it costs precision, never safety.
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
- return false;
438
+ shared = false;
439
+ break;
323
440
  }
324
441
  }
325
- for (const s of sides.values()) {
326
- if (s !== (LEFT | RIGHT)) {
327
- return false;
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
- const cx = (r.minX + r.maxX) / 2;
331
- const cy = (r.minY + r.maxY) / 2;
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.usageBandFromName)(name),
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: positive(numeric(props.DSPM_CSCL))
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; set it explicitly`);
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. A name met twice keeps its first file in
57
- * path order.
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
- const walk = (dir, depth) => {
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
- continue;
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
- walk(cellsDir, 1);
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
  }
@@ -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
- const c = name.charAt(2);
24
- const band = Number(c);
25
- return Number.isInteger(band) && band >= 1 && band <= 6 ? band : undefined;
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;
@@ -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([1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 22, 23, 27]);
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
  }
@@ -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 weight = (0, chart_position_1.chartWeight)(ground, survey);
108
- const risk = bathyRisk(w, bathy, v.sigmaD, bound) * weight;
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 weight = (0, chart_position_1.chartWeight)(ground, topo);
125
- const risk = bathyRisk(w, drawn, v.sigmaD, bound) * weight;
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 (ground <= (0, chart_position_1.flagReach)(w.store.surveySigma(i))) {
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 = ground <= (0, chart_position_1.flagReach)(w.store.surveySigma(i));
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 (ground <= (0, chart_position_1.flagReach)(w.store.surveySigma(i))) {
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: graded ? perMetre * bufferMetres : maxReach,
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
  /**
@@ -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), so an index built under
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 = 10;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-underkeel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Under-keel clearance awareness for Signal K: look-ahead grounding risk, route checks and a living chart.",
5
5
  "main": "lib/index.js",
6
6
  "files": [