signalk-underkeel 0.5.1 → 0.6.1

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,43 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.6.1] - 2026-11-13
6
+
7
+ ### Fixed
8
+
9
+ - The details panel reads in dark mode (#67): it had fixed light-theme colours and no
10
+ background of its own, so on Freeboard's dark background most of its text was dark grey on
11
+ dark. The status chip and widget also put white on amber (Alert, 2.4:1) and on green
12
+ (Normal, 3.7:1), below the 4.5:1 that small text needs.
13
+ - The SignalK plugin CI workflow is pinned to a commit again. Without the `@ref`, GitHub could
14
+ not read the workflow file, so no plugin CI ran for 0.6.0 and the plugin registry took 10
15
+ points from its score.
16
+
17
+ ### Changed
18
+
19
+ - The widget, the details panel and the setup steps share one set of colours in three
20
+ schemes: day, dusk (the device's dark mode) and night (Freeboard's night mode: black, and
21
+ nothing brighter than a dim grey). Every text colour reads at WCAG AA in each scheme.
22
+
23
+ ## [0.6.0] - 2026-11-10
24
+
25
+ ### Changed
26
+
27
+ - Charts Provider sharing its store after this plugin started, as it does at every server
28
+ start once its charts are loaded, no longer restarts the plugin when the store holds the
29
+ cells read at start; only the note that it was not running goes.
30
+ - The first chart box is built as soon as the first position arrives, not at the next 10 s
31
+ check. Until it is in use the reason says the box is being built and how far it has come,
32
+ for example `building the chart box around the vessel: reading cells 3/7`, and the cells
33
+ phase counts each cell as it is read instead of jumping from 0 to all.
34
+ - A charts provider still starting, or one that does not download an area, goes to the debug
35
+ log instead of the error log; one too old to be asked, or whose answer failed, stays an
36
+ error.
37
+ - Once a chart box is in use, the cells the next box can reach are read into the cell cache by
38
+ a child process at the lowest CPU priority, so moving the box over them reads none during the
39
+ rebuild: after a 6 km move the Waddenzee box rebuilt in 8.6 s against 34 s for its first
40
+ build (x86).
41
+
5
42
  ## [0.5.1] - 2026-11-10
6
43
 
7
44
  ### Changed
package/README.md CHANGED
@@ -144,8 +144,10 @@ phase, start and the last build's duration. At speed the box grows past its radi
144
144
  **Largest index box at speed** (0 sizes it from the RAM), so the vessel cannot reach the edge
145
145
  before the next box is built (docs/HOW-IT-WORKS.md §12). A radius under 2 km is raised to 2.
146
146
  Until the first position arrives the engine is `off`
147
- with the reason `waiting for the vessel position to build the chart box`. Set the box to 0 to
148
- index every cell.
147
+ with the reason `waiting for the vessel position to build the chart box`; the box is built
148
+ as soon as one does, and meanwhile the reason says how far it has come, for example
149
+ `building the chart box around the vessel: reading cells 3/7`. Set the box to 0 to index every
150
+ cell.
149
151
 
150
152
  At the first start the plugin builds a chart index from the cells in a child process and saves
151
153
  it under the plugin data directory, `~/.signalk/plugin-config-data/signalk-underkeel/index`
@@ -157,8 +159,11 @@ changed; `GET /status` names the cells that caused the rebuild. **Rebuild index
157
159
  forces a rebuild on every start. A cell folder may be a symbolic link. What the build read
158
160
  from each cell is kept, compressed, in `cell-cache` in the plugin data directory (at most 512 MB), so a
159
161
  chart box refresh reads again only the cells whose files changed: the 10 km Waddenzee box
160
- rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe; the cells are then
161
- read again.
162
+ rebuilds in 12–15 s instead of 30–34 s on x86. Once a box is in use, a child process at the
163
+ lowest CPU priority reads into it the cells the next box can reach, so a box moved over cells it
164
+ had not met reads none during the rebuild: after a 6 km move the Waddenzee box was rebuilt in
165
+ 8.6 s against 34 s for its first build (x86). A rebuild ends that process first. Deleting the
166
+ folder is safe; the cells are then read again.
162
167
 
163
168
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
164
169
  longer read: convert the original `.000` cells in the charts provider instead. Such a folder
@@ -270,6 +275,11 @@ Extensions API shows it without any change to Freeboard:
270
275
  | Widget grid | Under-keel status: state colour, minimum clearance, time to shoal and the reason |
271
276
  | Toolbar button (boat icon) | details panel: state, water level and source, a leg table per route, and **Open setup** for boat, water level and warning style (Settings) |
272
277
 
278
+ The widget, the panel and the setup steps follow Freeboard's look: light by day, dark when the
279
+ device is in dark mode, and a dim night scheme, black with nothing brighter than a dim grey,
280
+ while Freeboard's night mode is on, so a glance at the panel keeps the eyes adapted to the
281
+ dark. Every text colour reads at WCAG AA in each scheme.
282
+
273
283
  Routes shown on the chart are checked 1 s after each edit and every minute; the panel shows
274
284
  each result's age and marks it "out of date" after 3 minutes, when the chart draws its legs
275
285
  `unverified`. Stale status is shown as Unknown,
@@ -497,7 +507,10 @@ panel with its toolbar button, a hidden background page and the experimental
497
507
  - `runtime.js`, the background page, keeps the envelope, route results and tiles current;
498
508
  - `widget.js` and `panel.js` render the status and the route legs;
499
509
  - `ukc-shared.js` holds their pure logic (freshness, badges, labels), the part the Jest tests
500
- cover.
510
+ cover;
511
+ - `theme.css` holds every colour of the pages in the day, dusk and night schemes, and
512
+ `theme.js` picks the scheme from Freeboard's night mode (`nightMode`) or the device's dark
513
+ mode; a test holds each text colour to WCAG AA contrast in every scheme.
501
514
 
502
515
  The pages talk to the host through the vendored `signalk-plotterext-bus` client, copied into
503
516
  `public/vendor/` by the build. The [Plotter Extensions services RFC](docs/rfcs/plotter-extensions-services.md)
@@ -155,6 +155,7 @@ function buildManifest(version) {
155
155
  "signalk.stream",
156
156
  "routes",
157
157
  "units",
158
+ "nightMode",
158
159
  "ui",
159
160
  "events.publish",
160
161
  "x-freeboard.services.navigationSafety"
package/lib/box-status.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPending = void 0;
3
4
  exports.rebuildStatus = rebuildStatus;
5
+ exports.noIndexReason = noIndexReason;
6
+ exports.phaseText = phaseText;
4
7
  exports.boxSummary = boxSummary;
5
8
  const box_sizing_1 = require("./box-sizing");
6
9
  const chart_box_1 = require("./engine/chart-box");
10
+ const index_loader_1 = require("./index-loader");
7
11
  function rebuildStatus(r, now) {
8
12
  return {
9
13
  startedAt: new Date(r.startedAt).toISOString(),
@@ -13,6 +17,21 @@ function rebuildStatus(r, now) {
13
17
  ...(r.estimateMs === undefined ? {} : { estimateMs: r.estimateMs })
14
18
  };
15
19
  }
20
+ /** Whether `reason` is one of PENDING_REASONS, maybe with a note (`; …`) or detail (`: …`). */
21
+ const isPending = (reason) => [...index_loader_1.PENDING_REASONS].some((p) => reason.startsWith(p) && /^(?:$|[;:] )/.test(reason.slice(p.length)));
22
+ exports.isPending = isPending;
23
+ /**
24
+ * Why there is no index: `reason`, or what the first chart box build is doing while it runs,
25
+ * as the vessel position it waited for has come.
26
+ */
27
+ function noIndexReason(reason, box) {
28
+ const why = reason ?? "no chart index loaded";
29
+ if (box?.rebuilding && why.startsWith(index_loader_1.BOX_WAITING_REASON)) {
30
+ return box.phase ? `${index_loader_1.BOX_BUILDING_REASON}: ${box.phase}` : index_loader_1.BOX_BUILDING_REASON;
31
+ }
32
+ return why;
33
+ }
34
+ /** What a box build in progress is doing, with its count where it gave one. */
16
35
  function phaseText(r) {
17
36
  const of = r.done !== undefined && r.total !== undefined ? ` ${r.done}/${r.total}` : "";
18
37
  switch (r.phase) {
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.warmCells = warmCells;
7
+ exports.startWarming = startWarming;
8
+ exports.boxWarmer = boxWarmer;
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const worker_threads_1 = require("worker_threads");
12
+ const child_process_1 = require("./foundation/child-process");
13
+ const index_loader_1 = require("./index-loader");
14
+ const cell_cache_1 = require("./ingest/cell-cache");
15
+ const cell_source_1 = require("./ingest/cell-source");
16
+ const enc_read_process_1 = require("./ingest/enc-read-process");
17
+ const build_box_1 = require("./spatial/build-box");
18
+ /** Reads the cells of `job.around` the cache lacks into it; resolves with how many were read. */
19
+ async function warmCells(job, reader = enc_read_process_1.readInProcess) {
20
+ const cells = (0, index_loader_1.cellSources)(job.cellsDir).filter(cell_source_1.isEncFile);
21
+ const cache = cell_cache_1.LayerCache.open(job.cacheDir, cells);
22
+ if (!cache) {
23
+ return 0;
24
+ }
25
+ cache.hold((0, build_box_1.boxCells)(cells, job.hold, job.cacheDir));
26
+ const want = (0, build_box_1.boxCells)(cells, job.around, job.cacheDir).filter((f) => !cache.has(f));
27
+ let read = 0;
28
+ for (const file of want) {
29
+ const layers = (await reader([file])).get(file);
30
+ if (layers) {
31
+ cache.put(file, layers);
32
+ read++;
33
+ }
34
+ // One cell's layers held at a time.
35
+ await cache.flush();
36
+ }
37
+ return read;
38
+ }
39
+ /**
40
+ * Runs `job` in a child process from the compiled `script`, its heap capped at `heapMb`;
41
+ * `done` gets its reply, or why it ended without one, unless it was stopped.
42
+ */
43
+ function startWarming(job, heapMb, done, script = path_1.default.join(__dirname, "cell-warm.js")) {
44
+ const { child, stderr } = (0, child_process_1.forkLimited)(script, heapMb, true);
45
+ let stopped = false;
46
+ let reply;
47
+ const closed = new Promise((resolve) => {
48
+ child.once("close", (code) => {
49
+ if (!stopped) {
50
+ done(reply ?? { error: `cell cache warming exited with ${code ?? "a signal"}: ${stderr()}` });
51
+ }
52
+ resolve();
53
+ });
54
+ });
55
+ child.once("error", () => undefined);
56
+ child.on("message", (m) => (reply = m));
57
+ child.send(job, () => undefined);
58
+ return {
59
+ stop: () => {
60
+ stopped = true;
61
+ (0, child_process_1.killGroup)(child);
62
+ return closed;
63
+ }
64
+ };
65
+ }
66
+ /** One run at a time through `start`; a run that cannot start is reported to `error`. */
67
+ function boxWarmer(start, error) {
68
+ let running;
69
+ let stopped = false;
70
+ const end = () => {
71
+ const r = running;
72
+ running = undefined;
73
+ return r ? r.stop() : Promise.resolve();
74
+ };
75
+ return {
76
+ boxInUse: (bbox, reachKm) => {
77
+ if (stopped) {
78
+ return;
79
+ }
80
+ void end();
81
+ try {
82
+ running = start((0, build_box_1.growBbox)(bbox, reachKm), bbox);
83
+ }
84
+ catch (err) {
85
+ error(`filling the cell cache failed: ${err.message}`);
86
+ }
87
+ },
88
+ beforeBuild: end,
89
+ stop: () => {
90
+ stopped = true;
91
+ void end();
92
+ }
93
+ };
94
+ }
95
+ if (worker_threads_1.isMainThread && require.main === module && process.send) {
96
+ (0, child_process_1.exitWithParent)();
97
+ try {
98
+ // Behind the server and the look-ahead, whatever the cores.
99
+ os_1.default.setPriority(19);
100
+ }
101
+ catch {
102
+ // Not permitted: it runs at the server's priority.
103
+ }
104
+ process.once("message", (job) => {
105
+ warmCells(job)
106
+ .then((read) => ({ read }), (err) => ({ error: err.message }))
107
+ .then(child_process_1.sendToParent)
108
+ .finally(() => process.exit(0));
109
+ });
110
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
6
+ exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_BUILDING_REASON = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
7
7
  exports.cellSources = cellSources;
8
8
  exports.cellsMissing = cellsMissing;
9
9
  exports.savedCatalogue = savedCatalogue;
@@ -27,12 +27,15 @@ exports.BUILDING_REASON = "building chart index";
27
27
  exports.LOADING_REASON = "loading chart index";
28
28
  exports.WFS_WAITING_REASON = "waiting for the first Traficom WFS download";
29
29
  exports.BOX_WAITING_REASON = "waiting for the vessel position to build the chart box";
30
+ /** Why there is no index while the first chart box around the vessel is built. */
31
+ exports.BOX_BUILDING_REASON = "building the chart box around the vessel";
30
32
  /** Reasons that mean the index is on its way, not that loading it failed. */
31
33
  exports.PENDING_REASONS = new Set([
32
34
  exports.BUILDING_REASON,
33
35
  exports.LOADING_REASON,
34
36
  exports.WFS_WAITING_REASON,
35
- exports.BOX_WAITING_REASON
37
+ exports.BOX_WAITING_REASON,
38
+ exports.BOX_BUILDING_REASON
36
39
  ]);
37
40
  /**
38
41
  * A saved pyramid.bin larger than this is read and inflated off the event
package/lib/index.js CHANGED
@@ -66,7 +66,7 @@ function stopPlugin(app, clock, state) {
66
66
  * Restarts the plugin once the shared ENC cells it reads changed: a start compares them with the
67
67
  * saved index and rebuilds it when they did. A server without a restart callback logs instead.
68
68
  */
69
- function watchShared(app, clock, collection, restart) {
69
+ function watchShared(app, clock, collection, restart, sameCells) {
70
70
  return (0, shared_enc_1.watchSharedCells)(collection, clock, () => {
71
71
  if (restart) {
72
72
  restart();
@@ -74,7 +74,64 @@ function watchShared(app, clock, collection, restart) {
74
74
  else {
75
75
  app.error("the shared ENC cells changed; restart the plugin to index them");
76
76
  }
77
- });
77
+ }, sameCells ? { sameCells } : {});
78
+ }
79
+ /** The stamps of the S-57 cells under `dir`, as one string; undefined when it cannot be read. */
80
+ function cellsStamp(dir) {
81
+ try {
82
+ return (0, index_loader_1.sourceKeys)((0, index_loader_1.cellSources)(dir)).join("\n");
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /**
89
+ * Watches the charts provider's cells in use (`watchShared`). A provider publishing later the
90
+ * cells read at start restarts nothing: the reason shown loses its note that it had not started.
91
+ */
92
+ function watchAtStart(app, clock, state, plan, restart) {
93
+ const { config, load } = state;
94
+ const collection = config?.chartsProviderEnc;
95
+ if (!config || collection === undefined) {
96
+ return undefined;
97
+ }
98
+ const same = sameCellsAsStart(config.cellsDir, collection);
99
+ const renote = () => {
100
+ if ("load" in plan && state.load === load && load?.reason !== undefined) {
101
+ const fresh = (0, shared_enc_1.noteSharedCells)(plan.load, config);
102
+ state.load = fresh;
103
+ if (!fresh.index && fresh.reason !== undefined) {
104
+ state.monitor?.noIndex(fresh.reason);
105
+ }
106
+ }
107
+ };
108
+ const quiet = same &&
109
+ ((root) => {
110
+ const unchanged = same(root);
111
+ if (unchanged) {
112
+ renote();
113
+ }
114
+ return unchanged;
115
+ });
116
+ return watchShared(app, clock, collection, restart, quiet);
117
+ }
118
+ /**
119
+ * Whether a charts provider that publishes its store later than this plugin starts, as it does
120
+ * once its charts are loaded, publishes the cells read at start: the same directory, the same
121
+ * cells. Undefined when the provider was there at start, or nothing was read.
122
+ */
123
+ function sameCellsAsStart(cellsDir, collection) {
124
+ if ((0, shared_enc_1.sharedEncApi)() !== undefined || cellsDir === undefined) {
125
+ return undefined;
126
+ }
127
+ const stamp = cellsStamp(cellsDir);
128
+ if (stamp === undefined) {
129
+ return undefined;
130
+ }
131
+ return (root) => {
132
+ const dir = collection === shared_enc_1.ALL_COLLECTIONS ? root : path_1.default.join(root, collection);
133
+ return path_1.default.resolve(dir) === path_1.default.resolve(cellsDir) && cellsStamp(dir) === stamp;
134
+ };
78
135
  }
79
136
  /**
80
137
  * The admin form for this server: the essentials with expert settings
@@ -145,9 +202,7 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
145
202
  state.zoneErrors = errors;
146
203
  errors.forEach((e) => app.error(e));
147
204
  });
148
- shared = config.chartsProviderEnc
149
- ? watchShared(app, clock, config.chartsProviderEnc, restart && (() => restart(migration.config)))
150
- : undefined;
205
+ shared = watchAtStart(app, clock, state, plan, restart && (() => restart(migration.config)));
151
206
  (0, assets_1.mountAssets)(app);
152
207
  app.registerResourceProvider((0, manifest_1.manifestProvider)(VERSION));
153
208
  },
@@ -68,8 +68,8 @@ function writeQuietly(file, data) {
68
68
  fs_1.default.rmSync(tmp, { force: true });
69
69
  }
70
70
  }
71
- /** The layers in `bytes` filed under `key`; "stale" for another key, "corrupt" for a damaged entry. */
72
- function decodeEntry(bytes, key) {
71
+ /** The entry's header and where its body lies; "corrupt" for a damaged entry. */
72
+ function entryHead(bytes) {
73
73
  const end = bytes.length - SUM_BYTES;
74
74
  const headAt = MAGIC.length + 4;
75
75
  if (end < headAt || !bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
@@ -79,16 +79,27 @@ function decodeEntry(bytes, key) {
79
79
  return "corrupt";
80
80
  }
81
81
  const bodyAt = headAt + bytes.readUInt32LE(MAGIC.length);
82
- let head;
82
+ try {
83
+ return bodyAt > end
84
+ ? "corrupt"
85
+ : { head: JSON.parse(bytes.toString("utf8", headAt, bodyAt)), bodyAt, end };
86
+ }
87
+ catch {
88
+ return "corrupt";
89
+ }
90
+ }
91
+ /** The layers in `bytes` filed under `key`; "stale" for another key, "corrupt" for a damaged entry. */
92
+ function decodeEntry(bytes, key) {
93
+ const entry = entryHead(bytes);
94
+ if (entry === "corrupt") {
95
+ return entry;
96
+ }
97
+ const { head, bodyAt, end } = entry;
98
+ if (head.key !== key) {
99
+ return "stale";
100
+ }
83
101
  let raw;
84
102
  try {
85
- if (bodyAt > end) {
86
- return "corrupt";
87
- }
88
- head = JSON.parse(bytes.toString("utf8", headAt, bodyAt));
89
- if (head.key !== key) {
90
- return "stale";
91
- }
92
103
  raw = zlib_1.default.brotliDecompressSync(bytes.subarray(bodyAt, end));
93
104
  }
94
105
  catch {
@@ -188,6 +199,36 @@ class LayerCache {
188
199
  }
189
200
  return typeof layers === "string" ? undefined : layers;
190
201
  }
202
+ /**
203
+ * Whether an entry for `file` as its files are now is kept, checked whole but not
204
+ * decompressed; a damaged one is removed.
205
+ */
206
+ has(file) {
207
+ try {
208
+ const { name, key } = this.identify(file, (0, enc_wasm_1.encStamp)(file));
209
+ const entry = path_1.default.join(this.dir, name);
210
+ const found = entryHead(fs_1.default.readFileSync(entry));
211
+ if (found === "corrupt") {
212
+ fs_1.default.rmSync(entry, { force: true });
213
+ return false;
214
+ }
215
+ return found.head.key === key;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /** Keeps the entries of `files` from being dropped to make room, as if this build used them. */
222
+ hold(files) {
223
+ for (const file of files) {
224
+ try {
225
+ this.used.add(entryName(fs_1.default.realpathSync(file)));
226
+ }
227
+ catch {
228
+ // Gone: nothing to keep.
229
+ }
230
+ }
231
+ }
191
232
  /**
192
233
  * Files the layers just read from `file`, compressed off the event loop; `flush` waits for
193
234
  * the write. Layers of a cell whose files changed while it was read are not kept: they are
@@ -276,19 +317,20 @@ exports.LayerCache = LayerCache;
276
317
  * from, and filing those it reads. With every cell kept, gdal3.js is not started.
277
318
  */
278
319
  function cachedReader(reader, cache) {
279
- return async (files) => {
320
+ return async (files, onRead) => {
280
321
  const out = new Map();
281
322
  const read = [];
282
323
  for (const file of new Set(files)) {
283
324
  const layers = cache.get(file);
284
325
  if (layers) {
285
326
  out.set(file, layers);
327
+ onRead?.(file);
286
328
  }
287
329
  else {
288
330
  read.push(file);
289
331
  }
290
332
  }
291
- for (const [file, layers] of await reader(read)) {
333
+ for (const [file, layers] of await reader(read, onRead)) {
292
334
  out.set(file, layers);
293
335
  cache.put(file, layers);
294
336
  }
@@ -6,10 +6,11 @@ const worker_threads_1 = require("worker_threads");
6
6
  const child_process_1 = require("../foundation/child-process");
7
7
  const enc_wasm_1 = require("./enc-wasm");
8
8
  /** Reads on the calling thread; the WebAssembly memory stays with it. */
9
- const readInProcess = async (files) => {
9
+ const readInProcess = async (files, onRead) => {
10
10
  const out = new Map();
11
- for (const file of files) {
11
+ for (const file of new Set(files)) {
12
12
  out.set(file, await (0, enc_wasm_1.readEncLayers)(file));
13
+ onRead?.(file);
13
14
  }
14
15
  return out;
15
16
  };
@@ -20,7 +21,7 @@ exports.readInProcess = readInProcess;
20
21
  * that exits before every cell has come back fails the read.
21
22
  */
22
23
  function readInChild(heapMb, script = __filename) {
23
- return (files) => {
24
+ return (files, onRead) => {
24
25
  const want = [...new Set(files)];
25
26
  if (want.length === 0) {
26
27
  return Promise.resolve(new Map());
@@ -35,6 +36,7 @@ function readInChild(heapMb, script = __filename) {
35
36
  }
36
37
  else if (m.file !== undefined && m.layers) {
37
38
  out.set(m.file, (0, enc_wasm_1.sharedLayers)(new Map(Object.entries(m.layers))));
39
+ onRead?.(m.file);
38
40
  }
39
41
  });
40
42
  child.once("error", (err) => (failure ??= err));
package/lib/monitor.js CHANGED
@@ -11,7 +11,7 @@ const notify_1 = require("./engine/notify");
11
11
  const state_1 = require("./foundation/state");
12
12
  const integrity_1 = require("./model/integrity");
13
13
  const vessel_1 = require("./model/vessel");
14
- const index_loader_1 = require("./index-loader");
14
+ const box_status_1 = require("./box-status");
15
15
  const wfs_1 = require("./ingest/wfs");
16
16
  const water_level_service_1 = require("./water-level-service");
17
17
  const notifications_1 = require("./notifications");
@@ -262,7 +262,7 @@ class Monitor {
262
262
  if (!this.index) {
263
263
  return {
264
264
  state: "off",
265
- reason: this.indexReason ?? "no chart index loaded",
265
+ reason: (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.()),
266
266
  clearance: null,
267
267
  clearanceSigma: null,
268
268
  risk: null,
@@ -331,8 +331,8 @@ class Monitor {
331
331
  const now = this.clock.now();
332
332
  if (!this.index) {
333
333
  // The engine is off either way; a pending reason is a status, any other an error.
334
- const reason = this.indexReason ?? "no chart index loaded";
335
- if (index_loader_1.PENDING_REASONS.has(reason)) {
334
+ const reason = (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.());
335
+ if ((0, box_status_1.isPending)(reason)) {
336
336
  this.status.status(boxNote ? `${reason} · ${boxNote}` : reason, now);
337
337
  }
338
338
  else {
package/lib/shared-enc.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.AREA_ERROR_RETRY_MS = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
6
+ exports.AREA_ERROR_RETRY_MS = exports.PROVIDER_ABSENT = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
7
7
  exports.sharedEncApi = sharedEncApi;
8
8
  exports.sharedEncRoot = sharedEncRoot;
9
9
  exports.sharedCellsDir = sharedCellsDir;
@@ -97,14 +97,20 @@ function collectionsHint(api = sharedEncApi()) {
97
97
  .map((c) => `${c.id} (${c.kind === "chart-set" ? "chart set" : "converted chart"}, ${c.cellCount} cells)`)
98
98
  .join(", ")}.`;
99
99
  }
100
+ /**
101
+ * What is said of a charts provider whose store is not published: at a server start it is
102
+ * published only once the provider's charts are loaded, after this plugin has started.
103
+ */
104
+ exports.PROVIDER_ABSENT = "Charts Provider is not running or still starting";
100
105
  /**
101
106
  * Why the charts provider's `collection` holds no cells to read, as it tells it, or that it is
102
107
  * not running; undefined when it is ready, or cannot tell. A provider started later restarts
103
- * the plugin (`watchShared`), so the note is read again then.
108
+ * the plugin (`watchShared`) unless it shares the cells read at start; then only this note is
109
+ * read again (`watchAtStart` in index.ts).
104
110
  */
105
111
  function sharedCellsNote(collection, api = sharedEncApi()) {
106
112
  if (!api) {
107
- return "Charts Provider is not running";
113
+ return exports.PROVIDER_ABSENT;
108
114
  }
109
115
  if (collection === exports.ALL_COLLECTIONS) {
110
116
  return collectionsHint(api)?.startsWith("Charts Provider shares no")
@@ -166,18 +172,28 @@ function requestSharedArea(bbox, api = sharedEncApi()) {
166
172
  accepted: false,
167
173
  started: false,
168
174
  reason: err.message,
169
- retryAfterMs: exports.AREA_ERROR_RETRY_MS
175
+ retryAfterMs: exports.AREA_ERROR_RETRY_MS,
176
+ failed: true
170
177
  };
171
178
  }
172
179
  }
180
+ /** A comparison that throws counts as cells changed, so the plugin restarts rather than keep them. */
181
+ function safely(f) {
182
+ try {
183
+ return f();
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
173
189
  /**
174
190
  * Calls `onChange` once the cells of `collection` (any, for ALL_COLLECTIONS) changed and
175
191
  * settled. While the charts provider has not published its API, looks for it every `pollMs`,
176
192
  * and after `waitMs` every `slowPollMs` for as long as the watch runs, since it may start any
177
193
  * time; meanwhile the cells are read as found at start. An API that appears later counts as
178
- * a change: the provider may have written cells meanwhile, or keep its store elsewhere than
179
- * the default path the cells were read from, and the restart rebuilds the index only when
180
- * the cells it finds differ.
194
+ * a change, unless `sameCells` finds its store holds the cells read at start: the provider
195
+ * may have written cells meanwhile, or keep its store elsewhere than the default path the
196
+ * cells were read from. It is asked once subscribed, so a write after it is heard.
181
197
  */
182
198
  function watchSharedCells(collection, clock, onChange, opts = {}) {
183
199
  const settleMs = opts.settleMs ?? 5_000;
@@ -185,6 +201,7 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
185
201
  const pollMs = opts.pollMs ?? 5_000;
186
202
  const slowPollMs = opts.slowPollMs ?? 60_000;
187
203
  const started = clock.now();
204
+ const sameAsStart = (root) => safely(() => opts.sameCells?.(root) ?? false);
188
205
  let unsubscribe;
189
206
  let changedAt;
190
207
  let timer;
@@ -212,7 +229,9 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
212
229
  });
213
230
  if (late) {
214
231
  stopTimer();
215
- changed();
232
+ if (!sameAsStart(api.root)) {
233
+ changed();
234
+ }
216
235
  }
217
236
  return true;
218
237
  };
@@ -28,9 +28,14 @@ class EncReads {
28
28
  if (files.length === 0) {
29
29
  return;
30
30
  }
31
- const done = this.layers.size;
32
- (0, build_progress_1.reportProgress)({ phase: "cells", done, total: done + files.length });
33
- for (const [file, layers] of await this.reader(files)) {
31
+ const total = this.layers.size + new Set(files).size;
32
+ let done = this.layers.size;
33
+ (0, build_progress_1.reportProgress)({ phase: "cells", done, total });
34
+ const read = await this.reader(files, () => {
35
+ done = Math.min(total, done + 1);
36
+ (0, build_progress_1.reportProgress)({ phase: "cells", done, total });
37
+ });
38
+ for (const [file, layers] of read) {
34
39
  this.layers.set(file, layers);
35
40
  }
36
41
  (0, build_progress_1.reportProgress)({ phase: "cells", done: this.layers.size, total: this.layers.size });
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.statusReport = statusReport;
4
4
  const public_text_1 = require("./foundation/public-text");
5
5
  /** Body of `GET /status`: engine state, index provenance and look-ahead summary; `?summary` without the per-cell lists. */
6
- function indexSection(load, summary) {
6
+ function indexSection(load, monitor, summary) {
7
7
  const index = load?.index;
8
+ // Without an index the engine's reason is why, and follows a first box build as it runs.
9
+ const reason = !index && monitor?.last.state === "off" ? monitor.last.reason : load?.reason;
8
10
  return {
9
11
  loaded: index !== undefined,
10
12
  built: load?.built ?? false,
11
- reason: (0, public_text_1.publicReason)(load?.reason),
13
+ reason: (0, public_text_1.publicReason)(reason),
12
14
  nodes: index?.pyramid.count ?? 0,
13
15
  hazards: index?.hazards.size ?? 0,
14
16
  ...(summary ? {} : { cells: index?.catalogue.cells ?? [] })
@@ -38,7 +40,7 @@ function statusReport(s) {
38
40
  const wfs = s.wfs?.status();
39
41
  return {
40
42
  ...(s.monitor ? monitorSection(s.monitor, summary) : { engine: s.stopped, vessel: [] }),
41
- index: indexSection(s.load, summary),
43
+ index: indexSection(s.load, s.monitor, summary),
42
44
  livechart: s.livechart?.status() ?? null,
43
45
  wfs: wfs && s.wfs?.downloads ? { ...wfs, lastError: (0, public_text_1.publicReason)(wfs.lastError) } : null,
44
46
  chartBox: s.wfs ? boxSection(s.wfs) : null,