signalk-chiplog 2.1.0 → 2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +66 -29
  3. package/index.js +43 -17
  4. package/lib/api.js +15 -0
  5. package/lib/{forecast-schedule.js → background-schedule.js} +11 -11
  6. package/lib/database.js +44 -0
  7. package/lib/entries.js +7 -2
  8. package/lib/event-watcher.js +1 -3
  9. package/lib/events.js +38 -3
  10. package/lib/export.js +8 -0
  11. package/lib/landmark-finder.js +209 -0
  12. package/lib/landmarks.js +210 -0
  13. package/lib/logbook-pdf.js +45 -7
  14. package/package.json +2 -1
  15. package/public/app.css +121 -0
  16. package/public/entry/sw.js +1 -0
  17. package/public/js/animation/camera.mjs +180 -0
  18. package/public/js/animation/formats.mjs +45 -0
  19. package/public/js/animation/mercator.mjs +50 -0
  20. package/public/js/animation/mp4.mjs +77 -0
  21. package/public/js/animation/player.mjs +120 -0
  22. package/public/js/animation/renderer.mjs +326 -0
  23. package/public/js/animation/schedule.mjs +49 -0
  24. package/public/js/animation/storyboard.mjs +195 -0
  25. package/public/js/animation/tiles.mjs +219 -0
  26. package/public/js/animation/timeline.mjs +186 -0
  27. package/public/js/components/AnimationExport.mjs +173 -0
  28. package/public/js/components/AnimationView.mjs +439 -0
  29. package/public/js/components/ExportView.mjs +3 -8
  30. package/public/js/components/PassageView.mjs +29 -8
  31. package/public/js/components/Timeline.mjs +30 -7
  32. package/public/js/context.mjs +7 -0
  33. package/public/js/days.mjs +10 -0
  34. package/public/js/format.mjs +11 -0
  35. package/public/js/i18n.mjs +129 -0
  36. package/public/js/landmarks.mjs +128 -0
  37. package/public/js/main.mjs +8 -5
  38. package/public/vendor/mediabunny-LICENSE +373 -0
  39. package/public/vendor/mediabunny.min.mjs +50 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.0] - 2026-09-18
10
+
11
+ ### Added
12
+
13
+ - **Landmark bearings (amers)** — every position in the log is now also given the way a paper logbook gives one: a
14
+ distance and a bearing from the nearest landmark, "2,3 M ENE (065°) — Phare de Chauveau", under the coordinates in a
15
+ lighter grey, on the passage page and in the PDF logbook. The coordinates themselves are unchanged. Landmarks —
16
+ lighthouses, capes, named towers, harbours — are fetched from OpenStreetMap area by area and kept, so past passages
17
+ fill in as soon as their area is known and the same waters are never asked for twice; the bearing is computed when the
18
+ page is drawn, never stored. The landmark quoted is the one closest relative to its own range, so a lighthouse three
19
+ miles off wins over a marina alongside, and offshore the coordinates stay alone. A new setting, **Read each journal
20
+ line against the nearest landmark**, turns it off.
21
+ - **Animation** — a new page that replays passages on the map. Pick a start and an end date and every passage between
22
+ them plays in sequence, the time spent in port skipped, so a week's cruise takes seconds. The map follows the boat at
23
+ a scale chosen for each passage — a short hop stays readable instead of being magnified, a long crossing gets a wider
24
+ view without leaving the boat crawling across empty water — and a bubble on the chart shows the speed, the distance
25
+ covered since the animation began and the date and hour. Play, pause, four speeds (×0,5, ×1, ×2, ×4 — an hour of
26
+ sailing per second at ×1) and a slider over the animation's own time. A passage page links straight to it with its own
27
+ dates filled in.
28
+ - **Save an animation as an MP4** — Mobile (9:16), Portrait (3:4), Square (1:1), Landscape (4:3) or Widescreen (16:9),
29
+ encoded in the browser: nothing is uploaded, and the Signal K server renders nothing. Each frame's map is downloaded
30
+ just before that frame is drawn, so the film is the same whatever the connection was doing and no range is ever too
31
+ long to export. Needs a browser with WebCodecs (Chrome, Edge, Safari 17, Firefox 130 and later); without one the
32
+ animation still plays on screen.
33
+ - **Delete an alarm** — a logged alarm can now be removed from a passage's log, like a crew-entered line already could.
34
+ Deleting either the alarm or the line that later cleared it removes both, so no orphaned half is left behind.
35
+
9
36
  ## [2.1.0] - 2026-09-17
10
37
 
11
38
  ### Added
@@ -222,7 +249,8 @@ First release.
222
249
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
223
250
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
224
251
 
225
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...HEAD
252
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...HEAD
253
+ [2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
226
254
  [2.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.0.0...v2.1.0
227
255
  [2.0.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...v2.0.0
228
256
  [1.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...v1.2.0
package/README.md CHANGED
@@ -11,6 +11,8 @@ sensors cannot know from a tablet at the helm: manoeuvres, notes and handwriting
11
11
  - **Automatic events**: alarms, autopilot changes, strong wind, falling barometer.
12
12
  - **Departure and arrival names**, looked up online and corrected once for good.
13
13
  - **Consultation webapp**: logbook by day, map, timeline, corrections, export.
14
+ - **Replay a range of passages** on the map — an hour of sailing per second — and save it as an MP4 for a phone, a
15
+ square post or a widescreen, rendered entirely in your browser.
14
16
  - **Tablet entry app**: big buttons for gloves and wet fingers, stylus handwriting, night mode, works through Wi-Fi
15
17
  dropouts.
16
18
  - **Abandon-ship copy**: JSON, CSV and GPX, downloadable or written to a USB drive.
@@ -169,6 +171,26 @@ Departures and arrivals are named automatically:
169
171
  3. **Corrections are remembered.** Renaming a departure or arrival in the webapp also renames that place for every later
170
172
  passage starting or ending nearby. Past passages keep the name they recorded.
171
173
 
174
+ ### Landmarks (amers)
175
+
176
+ Every position in the log is also given the way a paper logbook gives one — as a distance and a bearing **from a
177
+ landmark**, under the coordinates, in a lighter grey:
178
+
179
+ ```text
180
+ 46°08.88′N 001°12.90′W
181
+ 2,3 M ENE (065°) — Phare de Chauveau
182
+ ```
183
+
184
+ - **The landmarks come from OpenStreetMap**, fetched area by area through Overpass and kept: lighthouses and major
185
+ lights, capes, named towers and other seamark landmarks, minor lights, isolated-danger and safe-water beacons,
186
+ harbours and marinas. The numbered marks of a channel are left out — "6 c" says nothing in a logbook.
187
+ - **The nearest useful one wins**, not simply the nearest: each kind carries a range (15 nm for a lighthouse, 3 for a
188
+ harbour…), narrowed by the light's own range when known, and the landmark closest relative to its range is the one
189
+ quoted. Offshore, beyond them all, the coordinates stay alone.
190
+ - **Past passages fill in by themselves** once their area has been fetched — the bearing is worked out when the page or
191
+ the PDF is drawn, never stored.
192
+ - Turn **Read each journal line against the nearest landmark** off to keep the boat off Overpass entirely.
193
+
172
194
  ## The logbook webapp 💻
173
195
 
174
196
  Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading needs no more than read-only access.
@@ -189,13 +211,20 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
189
211
  current noted at departure), and the log: every reading and event in order, including handwritten notes. A passage in
190
212
  progress refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew
191
213
  logged themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be
192
- annotated.
214
+ annotated. Under each position, in grey, its bearing and distance from the nearest landmark.
193
215
  - **Corrections** (read/write access):
194
216
  - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
195
217
  - switch an engine period to sail or back;
196
218
  - close a passage in progress, e.g. to confirm an arrival;
197
219
  - merge with the previous or next passage;
198
220
  - delete a passage (admin).
221
+ - **Animation** — pick two dates and every passage between them replays on the map, one after another, the port time
222
+ skipped. The map follows the boat at a scale chosen for each passage — a short hop kept readable rather than
223
+ magnified, a long crossing allowed a wider view but never so wide the boat crawls across empty water — while a bubble
224
+ shows the speed, the distance covered since the start and the date. Play, pause and a slider over the animation's own
225
+ time, at ×0,5, ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4,
226
+ Square 1:1, Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the
227
+ Signal K server, and the map tiles are the only thing downloaded.
199
228
  - **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the
200
229
  abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time
201
230
  zone.
@@ -291,33 +320,35 @@ a regular Signal K user account.
291
320
 
292
321
  In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
293
322
 
294
- | Setting | Default | What it does |
295
- | ---------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
296
- | Stop duration within which a new departure continues the passage | 30 min | A passage closes when the boat stops; leaving again sooner reopens it, the stop kept as a stopover. |
297
- | Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
298
- | Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
299
- | Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
300
- | Track point interval | 15 s | A track point at least this often while moving. |
301
- | Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
302
- | Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
303
- | Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
304
- | Fetch the tide forecast at departure | on | Turn off to never send the departure position online; the passage page then shows no tide. |
305
- | Marine service | `https://marine-api.open-meteo.com/v1/marine` | Any Open-Meteo Marine-compatible service, e.g. a self-hosted one. Serves the tide, and the sea state and current of the weather forecast. |
306
- | Fetch the marine weather forecast at departure | on | Turn off to never send the departure position to the weather services; the passage page and the PDF then show no forecast. |
307
- | Weather service | `https://api.open-meteo.com/v1/forecast` | Any Open-Meteo-compatible forecast service, e.g. a self-hosted one. |
308
- | USB export directory | | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
309
- | Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
310
- | Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
311
- | Logbook language (PDF) | en | Language of the PDF logbooks on the USB drive (English or French). |
312
- | Ship's time zone (PDF) | the server's | Time zone of the PDF logbooks on the USB drive, e.g. `Europe/Paris`. |
313
- | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
314
- | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
315
- | InfluxDB host (retrospective analysis) | — | Local or remote host of the InfluxDB 1.x database signalk-to-influxdb writes to. Empty turns the retrospective analysis page off. |
316
- | InfluxDB port | 8086 | |
317
- | InfluxDB database | — | |
318
- | InfluxDB username / password | | Leave empty if the database needs none. |
319
- | InfluxDB protocol | http | `http` or `https`. |
320
- | InfluxDB vessel context | this server's own | Only needed running the replay from a different Signal K server than the one that wrote the history, e.g. development pointed at a production database. |
323
+ | Setting | Default | What it does |
324
+ | ---------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
325
+ | Stop duration within which a new departure continues the passage | 30 min | A passage closes when the boat stops; leaving again sooner reopens it, the stop kept as a stopover. |
326
+ | Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
327
+ | Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
328
+ | Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
329
+ | Track point interval | 15 s | A track point at least this often while moving. |
330
+ | Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
331
+ | Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
332
+ | Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
333
+ | Read each journal line against the nearest landmark | on | Fetches the landmarks of the areas sailed through from OpenStreetMap, so each position is also given as a bearing and distance from one. Turn off to keep the coordinates alone. |
334
+ | Landmark service (Overpass API) | `https://overpass-api.de/api/interpreter` | Any Overpass-compatible service, e.g. a self-hosted one. |
335
+ | Fetch the tide forecast at departure | on | Turn off to never send the departure position online; the passage page then shows no tide. |
336
+ | Marine service | `https://marine-api.open-meteo.com/v1/marine` | Any Open-Meteo Marine-compatible service, e.g. a self-hosted one. Serves the tide, and the sea state and current of the weather forecast. |
337
+ | Fetch the marine weather forecast at departure | on | Turn off to never send the departure position to the weather services; the passage page and the PDF then show no forecast. |
338
+ | Weather service | `https://api.open-meteo.com/v1/forecast` | Any Open-Meteo-compatible forecast service, e.g. a self-hosted one. |
339
+ | USB export directory | | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
340
+ | Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
341
+ | Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
342
+ | Logbook language (PDF) | en | Language of the PDF logbooks on the USB drive (English or French). |
343
+ | Ship's time zone (PDF) | the server's | Time zone of the PDF logbooks on the USB drive, e.g. `Europe/Paris`. |
344
+ | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
345
+ | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
346
+ | InfluxDB host (retrospective analysis) | — | Local or remote host of the InfluxDB 1.x database signalk-to-influxdb writes to. Empty turns the retrospective analysis page off. |
347
+ | InfluxDB port | 8086 | |
348
+ | InfluxDB database | | |
349
+ | InfluxDB username / password | | Leave empty if the database needs none. |
350
+ | InfluxDB protocol | http | `http` or `https`. |
351
+ | InfluxDB vessel context | this server's own | Only needed running the replay from a different Signal K server than the one that wrote the history, e.g. development pointed at a production database. |
321
352
 
322
353
  ## Signal K data used 🔌
323
354
 
@@ -378,6 +409,8 @@ at the time.
378
409
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
379
410
  the geocoding service — OpenStreetMap's public Nominatim by default. Nothing else is sent, and nothing at all when it
380
411
  is off.
412
+ - **Landmarks.** With them on, the area a passage sailed through — a half-degree box, not its track — is sent to the
413
+ Overpass service, OpenStreetMap's public instance by default, once per area ever. Nothing at all when it is off.
381
414
  - **Tide forecast.** With it on, the departure position of each passage is sent to the tide service — the public
382
415
  Open-Meteo by default — once, at departure. Nothing at all when it is off.
383
416
  - **Weather forecast.** With it on, the departure position of each passage is sent to the weather service and to the
@@ -486,7 +519,11 @@ between attempts; nothing is lost, it just takes longer to resolve.
486
519
  ## Limitations 🚧
487
520
 
488
521
  - **Not yet:** a places page, and editing manoeuvre shortcuts from the webapps.
489
- - **Offline charts** are not provided.
522
+ - **Offline charts** are not provided; the animation draws the tracks on a blank sea when there is no connection.
523
+ - **The MP4 export needs a browser with WebCodecs** — Chrome, Edge, Safari 17 or Firefox 130 and later. Without it the
524
+ animation still plays on screen, and the export button is simply not offered.
525
+ - **A long animation takes a while to export**: every frame waits for its map before it is drawn, so the video is the
526
+ same whatever the connection was doing, but a long range means a lot of tiles. Turning the seamarks off halves them.
490
527
  - **One vessel per Signal K server**, and no per-crew-member authorship.
491
528
 
492
529
  ## Development 🧑‍💻
package/index.js CHANGED
@@ -2,9 +2,10 @@ const { registerRoutes } = require('./lib/api');
2
2
  const { openDatabase } = require('./lib/database');
3
3
  const { createPassageDetector, DETECTION_DEFAULTS, TICK_INTERVAL_MS } = require('./lib/detection');
4
4
  const { ApiError } = require('./lib/errors');
5
- const { createForecastSchedule } = require('./lib/forecast-schedule');
5
+ const { createBackgroundSchedule } = require('./lib/background-schedule');
6
6
  const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib/event-watcher');
7
7
  const { INFLUX_DEFAULTS } = require('./lib/influx-history');
8
+ const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
8
9
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
9
10
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
10
11
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
@@ -26,6 +27,9 @@ const DEFAULT_PLACE_MATCH_RADIUS = 200;
26
27
  const FIRST_NAMING_DELAY_MS = 5 * 1000;
27
28
  const NAMING_ERROR_RETRY_MS = 5 * 60 * 1000;
28
29
  const FIRST_FORECAST_DELAY_MS = 5 * 1000;
30
+ // Landmarks are not needed for the passage under way, only for reading it back,
31
+ // so the first lookup waits for the busier start-up work to be done.
32
+ const FIRST_LANDMARK_DELAY_MS = 30 * 1000;
29
33
 
30
34
  const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
31
35
  const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
@@ -56,7 +60,7 @@ module.exports = function (app) {
56
60
  let settings = null;
57
61
  let detector = null;
58
62
  let namer = null;
59
- let forecasts = [];
63
+ let schedules = [];
60
64
  // The open passage detection last reported, to notice a new one.
61
65
  let lastActiveEntryId = null;
62
66
  let usbExport = null;
@@ -132,6 +136,19 @@ module.exports = function (app) {
132
136
  description: 'The public OpenStreetMap instance by default, or a self-hosted Nominatim',
133
137
  default: GEOCODING_DEFAULTS.geocodingUrl
134
138
  },
139
+ landmarksEnabled: {
140
+ type: 'boolean',
141
+ title: 'Read each journal line against the nearest landmark',
142
+ description:
143
+ 'Fetches the lighthouses, capes, towers and harbours of the areas sailed through from OpenStreetMap, so every position in the logbook is also given as a bearing and distance from the nearest one (© OpenStreetMap contributors, ODbL). Without it, only the coordinates are shown',
144
+ default: LANDMARK_DEFAULTS.landmarksEnabled
145
+ },
146
+ overpassUrl: {
147
+ type: 'string',
148
+ title: 'Landmark service (Overpass API)',
149
+ description: 'The public Overpass instance by default, or a self-hosted one',
150
+ default: LANDMARK_DEFAULTS.overpassUrl
151
+ },
135
152
  tidesEnabled: {
136
153
  type: 'boolean',
137
154
  title: 'Fetch the tide forecast at departure',
@@ -252,10 +269,11 @@ module.exports = function (app) {
252
269
  try {
253
270
  const outcome = detector.tick();
254
271
  usbExport.afterDetection(outcome);
255
- // A passage just opened: fetch its forecasts now, not at the next idle
256
- // poll or at the end of a retry delay left from an earlier failure.
272
+ // A passage just opened: fetch its forecasts and the landmarks of where
273
+ // it is starting from now, not at the next idle poll or at the end of a
274
+ // retry delay left from an earlier failure.
257
275
  if (outcome.activeEntryId !== null && outcome.activeEntryId !== lastActiveEntryId) {
258
- forecasts.forEach((schedule) => schedule.nudge());
276
+ schedules.forEach((schedule) => schedule.nudge());
259
277
  }
260
278
  lastActiveEntryId = outcome.activeEntryId;
261
279
  const usbError = usbExport.status().lastError;
@@ -344,6 +362,8 @@ module.exports = function (app) {
344
362
  placeMatchRadius: config.placeMatchRadius ?? DEFAULT_PLACE_MATCH_RADIUS,
345
363
  geocodingEnabled: config.geocodingEnabled ?? GEOCODING_DEFAULTS.geocodingEnabled,
346
364
  geocodingUrl: config.geocodingUrl || GEOCODING_DEFAULTS.geocodingUrl,
365
+ landmarksEnabled: config.landmarksEnabled ?? LANDMARK_DEFAULTS.landmarksEnabled,
366
+ overpassUrl: config.overpassUrl || LANDMARK_DEFAULTS.overpassUrl,
347
367
  tidesEnabled: config.tidesEnabled ?? TIDE_DEFAULTS.tidesEnabled,
348
368
  tideUrl: config.tideUrl || TIDE_DEFAULTS.tideUrl,
349
369
  weatherEnabled: config.weatherEnabled ?? WEATHER_DEFAULTS.weatherEnabled,
@@ -411,23 +431,29 @@ module.exports = function (app) {
411
431
  });
412
432
  namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
413
433
  // Same idea as geocoding: network calls fetching the tide and weather
414
- // forecasts near a recent departure, each on its own chain rather than
415
- // inside detection.
416
- const forecastOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
434
+ // forecasts near a recent departure, and the landmarks of the areas
435
+ // sailed through, each on its own chain rather than inside detection.
436
+ const networkOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
417
437
  const log = (level, message) => (level === 'error' ? app.error(message) : app.debug(message));
418
438
  lastActiveEntryId = null;
419
- forecasts = [
420
- createForecastSchedule({
421
- label: 'Tide',
422
- forecaster: createTideForecaster(forecastOptions),
439
+ schedules = [
440
+ createBackgroundSchedule({
441
+ label: 'Tide forecast',
442
+ resolver: createTideForecaster(networkOptions),
423
443
  log,
424
444
  firstDelayMs: FIRST_FORECAST_DELAY_MS
425
445
  }),
426
- createForecastSchedule({
427
- label: 'Weather',
428
- forecaster: createWeatherForecaster(forecastOptions),
446
+ createBackgroundSchedule({
447
+ label: 'Weather forecast',
448
+ resolver: createWeatherForecaster(networkOptions),
429
449
  log,
430
450
  firstDelayMs: FIRST_FORECAST_DELAY_MS
451
+ }),
452
+ createBackgroundSchedule({
453
+ label: 'Landmark lookup',
454
+ resolver: createLandmarkFinder(networkOptions),
455
+ log,
456
+ firstDelayMs: FIRST_LANDMARK_DELAY_MS
431
457
  })
432
458
  ];
433
459
  lastStatus = null;
@@ -455,8 +481,8 @@ module.exports = function (app) {
455
481
  clearTimeout(namingTimer);
456
482
  namer?.stop();
457
483
  namer = null;
458
- forecasts.forEach((schedule) => schedule.stop());
459
- forecasts = [];
484
+ schedules.forEach((schedule) => schedule.stop());
485
+ schedules = [];
460
486
  usbExport?.stop();
461
487
  usbExport = null;
462
488
  replayJob?.cancel();
package/lib/api.js CHANGED
@@ -4,6 +4,7 @@ const { ApiError, badRequest, conflict, notFound } = require('./errors');
4
4
  const entries = require('./entries');
5
5
  const events = require('./events');
6
6
  const { renderExport } = require('./export');
7
+ const { listEntryLandmarks } = require('./landmarks');
7
8
  const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
8
9
  const { toGeoJson, toGpx } = require('./formats');
9
10
  const manoeuvreTypes = require('./manoeuvre-types');
@@ -229,6 +230,20 @@ function registerRoutes(router, { getContext, logError }) {
229
230
  })
230
231
  );
231
232
 
233
+ // The amers a line of this passage's journal can be read against (SPEC
234
+ // §4.13); which one each line takes, and the bearing, is worked out where
235
+ // the line is shown. Empty until the area's landmarks have been fetched.
236
+ readonly.get(
237
+ '/api/entries/:id/landmarks',
238
+ handle(({ db }, req) => {
239
+ const id = entryId(req);
240
+ entries.requireEntryRow(db, id);
241
+ const { limit, offset } = v.parsePagination(req.query);
242
+ const items = listEntryLandmarks(db, id);
243
+ return { total: items.length, limit, offset, items: items.slice(offset, offset + limit) };
244
+ })
245
+ );
246
+
232
247
  readonly.get(
233
248
  '/api/entries/:id/tide',
234
249
  handle(({ db }, req) => {
@@ -1,12 +1,12 @@
1
- // Runs a departure forecaster (tide, weather) as its own chain of timeouts:
2
- // each `resolveNext()` says when the next is due. `nudge()` runs it now --
3
- // when a passage opens, rather than leaving it to the idle poll or a retry
4
- // delay. A nudge during a fetch runs it again as soon as that one ends,
5
- // never a second chain alongside.
1
+ // Runs a background resolver -- a departure forecast, a landmark lookup -- as
2
+ // its own chain of timeouts: each `resolveNext()` says when the next is due.
3
+ // `nudge()` runs it now -- when a passage opens, rather than leaving it to the
4
+ // idle poll or a retry delay. A nudge during a fetch runs it again as soon as
5
+ // that one ends, never a second chain alongside.
6
6
 
7
7
  const ERROR_RETRY_MS = 5 * 60 * 1000;
8
8
 
9
- function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
9
+ function createBackgroundSchedule({ label, resolver, log, firstDelayMs }) {
10
10
  let timer = null;
11
11
  let running = false;
12
12
  let nudged = false;
@@ -23,9 +23,9 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
23
23
  nudged = false;
24
24
  let result;
25
25
  try {
26
- result = await forecaster.resolveNext();
26
+ result = await resolver.resolveNext();
27
27
  } catch (err) {
28
- log('error', `${label} forecast failed: ${err.stack ?? err}`);
28
+ log('error', `${label} failed: ${err.stack ?? err}`);
29
29
  result = { retryInMs: ERROR_RETRY_MS };
30
30
  }
31
31
  running = false;
@@ -36,7 +36,7 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
36
36
  // Expected whenever the boat is out of reach of a network.
37
37
  log(
38
38
  'debug',
39
- `${label} forecast unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
39
+ `${label} unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
40
40
  );
41
41
  }
42
42
  schedule(nudged ? 0 : result.retryInMs);
@@ -59,9 +59,9 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
59
59
  stop() {
60
60
  stopped = true;
61
61
  clearTimeout(timer);
62
- forecaster.stop();
62
+ resolver.stop();
63
63
  }
64
64
  };
65
65
  }
66
66
 
67
- module.exports = { createForecastSchedule };
67
+ module.exports = { createBackgroundSchedule };
package/lib/database.js CHANGED
@@ -314,6 +314,50 @@ const MIGRATIONS = [
314
314
  CREATE UNIQUE INDEX idx_log_entry_crew_unique_member
315
315
  ON log_entry_crew (entry_id, crew_member_id)
316
316
  WHERE crew_member_id IS NOT NULL;
317
+ `,
318
+ `
319
+ -- Landmarks -- amers (SPEC §4.13): the named features a journal line's
320
+ -- position is read against, "1.2 nm NE (053°) of Phare du Cap-Ferret".
321
+ -- Fetched from OpenStreetMap area by area and kept: the bearing itself is
322
+ -- worked out at read time, so a past passage fills in as soon as its area
323
+ -- is known, and nothing here is part of the logbook's record.
324
+ CREATE TABLE landmarks (
325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326
+ osm_type TEXT NOT NULL CHECK (osm_type IN ('node', 'way', 'relation')),
327
+ osm_id INTEGER NOT NULL,
328
+ name TEXT NOT NULL,
329
+ kind TEXT NOT NULL CHECK (
330
+ kind IN ('lighthouse', 'light', 'cape', 'landmark', 'beacon', 'harbour')
331
+ ),
332
+ lat REAL NOT NULL,
333
+ lon REAL NOT NULL,
334
+ -- The nominal range of its light in metres, when OpenStreetMap gives one:
335
+ -- how far out this amer is still worth naming a position from.
336
+ light_range REAL,
337
+ created_at TEXT NOT NULL,
338
+ updated_at TEXT NOT NULL
339
+ );
340
+
341
+ CREATE UNIQUE INDEX idx_landmarks_osm ON landmarks (osm_type, osm_id);
342
+
343
+ -- Supports the bounding-box prefilter, as for places.
344
+ CREATE INDEX idx_landmarks_position ON landmarks (lat, lon);
345
+
346
+ -- The half-degree cells landmarks have already been fetched for. A fetch
347
+ -- covers its cell plus a margin, so every position inside the cell has all
348
+ -- the amers within range in the table -- one request per cell, ever.
349
+ CREATE TABLE landmark_areas (
350
+ cell_lat INTEGER NOT NULL,
351
+ cell_lon INTEGER NOT NULL,
352
+ fetched_at TEXT NOT NULL,
353
+ PRIMARY KEY (cell_lat, cell_lon)
354
+ );
355
+
356
+ -- Cleared once every cell a closed passage sailed through has been fetched.
357
+ -- An open passage stays pending: it keeps moving into new cells. Existing
358
+ -- passages default to pending, so their areas are fetched too.
359
+ ALTER TABLE log_entries ADD COLUMN landmarks_pending INTEGER NOT NULL DEFAULT 1
360
+ CHECK (landmarks_pending IN (0, 1));
317
361
  `
318
362
  ];
319
363
 
package/lib/entries.js CHANGED
@@ -216,13 +216,16 @@ function updateEntry(db, id, patch, { placeMatchRadius, now }) {
216
216
  next.distance = patch.distance;
217
217
  }
218
218
 
219
+ // landmarks_pending: an edited departure or arrival position may sit in an
220
+ // area no landmarks have been fetched for (SPEC §4.13); the lookup checks
221
+ // and clears it again.
219
222
  db.prepare(
220
223
  `UPDATE log_entries SET
221
224
  start_time = ?, end_time = ?,
222
225
  start_lat = ?, start_lon = ?, end_lat = ?, end_lon = ?,
223
226
  start_place_id = ?, end_place_id = ?, start_place_name = ?, end_place_name = ?,
224
227
  start_place_pending = ?, end_place_pending = ?,
225
- distance = ?, updated_at = ?
228
+ distance = ?, landmarks_pending = 1, updated_at = ?
226
229
  WHERE id = ?`
227
230
  ).run(
228
231
  next.start_time,
@@ -361,11 +364,13 @@ function mergeEntries(db, id, otherId, now) {
361
364
  // index would otherwise reject the update below.
362
365
  db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
363
366
 
367
+ // landmarks_pending: the merged passage now covers the later one's track
368
+ // as well, which may reach areas no landmarks have been fetched for.
364
369
  db.prepare(
365
370
  `UPDATE log_entries SET
366
371
  state = ?, end_time = ?, stopped_since = ?, closed_by = ?,
367
372
  end_lat = ?, end_lon = ?, end_place_id = ?, end_place_name = ?, end_place_pending = ?,
368
- distance = ?, updated_at = ?
373
+ distance = ?, landmarks_pending = 1, updated_at = ?
369
374
  WHERE id = ?`
370
375
  ).run(
371
376
  later.state,
@@ -1,14 +1,12 @@
1
1
  const { createFreshnessTracker } = require('./detection');
2
2
  const { activeEntryId, entryNearArrival } = require('./entries');
3
+ const { CRITICAL_STATES } = require('./events');
3
4
 
4
5
  const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
5
6
 
6
7
  const EVENT_DEFAULTS = { windSpeedThresholds: [20, 30], pressureDropThreshold: 4 };
7
8
 
8
9
  const CHECK_INTERVAL_MS = 1000;
9
-
10
- // Any notification reaching these states is critical, whatever its path.
11
- const CRITICAL_STATES = new Set(['alarm', 'emergency']);
12
10
  const NOTIFICATION_METADATA_KEYS = new Set([
13
11
  'meta',
14
12
  'values',
package/lib/events.js CHANGED
@@ -27,6 +27,11 @@ const EVENT_TYPES = [
27
27
  // or from corrections, never posted by a client.
28
28
  const CLIENT_EVENT_TYPES = ['manoeuvre', 'text_annotation', 'handwritten_annotation'];
29
29
 
30
+ // A `sk_alarm` event reaching one of these states is the alarm itself; any
31
+ // other state (usually `normal`) is what cleared it. Shared with
32
+ // event-watcher.js, which decides what is worth logging in the first place.
33
+ const CRITICAL_STATES = new Set(['alarm', 'emergency']);
34
+
30
35
  // An entry logged after the fact — typically replayed from the tablet's offline
31
36
  // queue — takes the position the track recorded at its time.
32
37
  const TRACK_POSITION_WINDOW_MS = 2 * 60 * 1000;
@@ -277,10 +282,35 @@ function updateEvent(db, id, patch) {
277
282
  return toEvent(requireEventRow(db, id));
278
283
  }
279
284
 
285
+ // An alarm is logged as two lines — the notification reaching `alarm` or
286
+ // `emergency`, and the one that later cleared it (SPEC §4.6) — so deleting
287
+ // either alone would leave the other referring to nothing. Its pair is the
288
+ // adjacent `sk_alarm` line for the same notification whose criticality
289
+ // differs: an escalation (`alarm` to `emergency`) is not a pair, both being
290
+ // critical, and is left alone.
291
+ function pairedAlarmEvent(db, row) {
292
+ if (row.type !== 'sk_alarm') {
293
+ return null;
294
+ }
295
+ const siblings = db
296
+ .prepare(
297
+ `SELECT * FROM events WHERE entry_id = ? AND type = 'sk_alarm' AND subtype = ?
298
+ ORDER BY time, id`
299
+ )
300
+ .all(row.entry_id, row.subtype);
301
+ const index = siblings.findIndex((sibling) => sibling.id === row.id);
302
+ const critical = CRITICAL_STATES.has(JSON.parse(row.payload).state);
303
+ const neighbour = critical ? siblings[index + 1] : siblings[index - 1];
304
+ if (!neighbour || CRITICAL_STATES.has(JSON.parse(neighbour.payload).state) === critical) {
305
+ return null;
306
+ }
307
+ return neighbour;
308
+ }
309
+
280
310
  // Undoing the departure that opened a passage, before the vessel moved and
281
311
  // before anything else was logged in it, takes the passage away too.
282
312
  function deleteEvent(db, id) {
283
- requireEventRow(db, id);
313
+ const row = requireEventRow(db, id);
284
314
  withTransaction(db, () => {
285
315
  const opened = db
286
316
  .prepare(
@@ -291,15 +321,20 @@ function deleteEvent(db, id) {
291
321
  .get(id, id);
292
322
  if (opened) {
293
323
  db.prepare('DELETE FROM log_entries WHERE id = ?').run(opened.id);
294
- } else {
295
- db.prepare('DELETE FROM events WHERE id = ?').run(id);
324
+ return;
325
+ }
326
+ const paired = pairedAlarmEvent(db, row);
327
+ if (paired) {
328
+ db.prepare('DELETE FROM events WHERE id = ?').run(paired.id);
296
329
  }
330
+ db.prepare('DELETE FROM events WHERE id = ?').run(id);
297
331
  });
298
332
  }
299
333
 
300
334
  module.exports = {
301
335
  EVENT_TYPES,
302
336
  CLIENT_EVENT_TYPES,
337
+ CRITICAL_STATES,
303
338
  listEvents,
304
339
  allEvents,
305
340
  createEvent,
package/lib/export.js CHANGED
@@ -7,6 +7,7 @@ const { conflict } = require('./errors');
7
7
  const { toEntry } = require('./entries');
8
8
  const { allEvents } = require('./events');
9
9
  const { toCsv, toGpx } = require('./formats');
10
+ const { listEntryLandmarks } = require('./landmarks');
10
11
  const { renderLogbookPdf } = require('./logbook-pdf');
11
12
  const { allSegments } = require('./propulsion');
12
13
  const { allTrackPoints, allObservations } = require('./track');
@@ -34,6 +35,7 @@ function bundleOf(db, row) {
34
35
  observations: allObservations(db, row.id),
35
36
  propulsion: allSegments(db, row.id),
36
37
  events: allEvents(db, row.id),
38
+ landmarks: listEntryLandmarks(db, row.id),
37
39
  tide: getTideForecast(db, row.id),
38
40
  weather: getWeatherForecast(db, row.id),
39
41
  crew: listEntryCrew(db, row.id)
@@ -137,6 +139,12 @@ function passageFingerprint(db, row, pdf) {
137
139
  const forecast = db.prepare(`SELECT fetched_at FROM ${table} WHERE entry_id = ?`).get(row.id);
138
140
  hash.update(JSON.stringify(forecast?.fetched_at ?? null));
139
141
  }
142
+ // The landmarks a passage is read against arrive area by area, well after
143
+ // the sailing, and the PDF prints them: a copy already on the drive is worth
144
+ // rewriting once they do.
145
+ for (const landmark of listEntryLandmarks(db, row.id)) {
146
+ hash.update(JSON.stringify([landmark.id, landmark.updatedAt]));
147
+ }
140
148
  for (const table of ['propulsion_segments', 'events']) {
141
149
  for (const item of db
142
150
  .prepare(`SELECT * FROM ${table} WHERE entry_id = ? ORDER BY id`)