signalk-chiplog 2.0.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 (51) hide show
  1. package/CHANGELOG.md +186 -37
  2. package/README.md +340 -134
  3. package/index.js +80 -40
  4. package/lib/api.js +109 -1
  5. package/lib/background-schedule.js +67 -0
  6. package/lib/crew.js +185 -0
  7. package/lib/database.js +103 -1
  8. package/lib/departure-forecast.js +150 -0
  9. package/lib/detection.js +45 -14
  10. package/lib/entries.js +96 -48
  11. package/lib/event-watcher.js +1 -3
  12. package/lib/events.js +64 -16
  13. package/lib/export.js +20 -1
  14. package/lib/landmark-finder.js +209 -0
  15. package/lib/landmarks.js +210 -0
  16. package/lib/logbook-pdf.js +368 -17
  17. package/lib/observation-recorder.js +11 -5
  18. package/lib/tide-forecaster.js +23 -116
  19. package/lib/weather-forecaster.js +147 -0
  20. package/package.json +2 -1
  21. package/public/app.css +212 -2
  22. package/public/entry/entry.css +71 -0
  23. package/public/entry/js/components/CrewDialog.mjs +200 -0
  24. package/public/entry/js/main.mjs +114 -0
  25. package/public/entry/sw.js +2 -0
  26. package/public/js/animation/camera.mjs +180 -0
  27. package/public/js/animation/formats.mjs +45 -0
  28. package/public/js/animation/mercator.mjs +50 -0
  29. package/public/js/animation/mp4.mjs +77 -0
  30. package/public/js/animation/player.mjs +120 -0
  31. package/public/js/animation/renderer.mjs +326 -0
  32. package/public/js/animation/schedule.mjs +49 -0
  33. package/public/js/animation/storyboard.mjs +195 -0
  34. package/public/js/animation/tiles.mjs +219 -0
  35. package/public/js/animation/timeline.mjs +186 -0
  36. package/public/js/components/AnimationExport.mjs +173 -0
  37. package/public/js/components/AnimationView.mjs +439 -0
  38. package/public/js/components/CrewCard.mjs +22 -0
  39. package/public/js/components/ExportView.mjs +3 -8
  40. package/public/js/components/PassageView.mjs +49 -19
  41. package/public/js/components/Timeline.mjs +30 -7
  42. package/public/js/components/WeatherCard.mjs +134 -0
  43. package/public/js/context.mjs +7 -0
  44. package/public/js/days.mjs +10 -0
  45. package/public/js/format.mjs +15 -0
  46. package/public/js/i18n.mjs +246 -0
  47. package/public/js/landmarks.mjs +128 -0
  48. package/public/js/main.mjs +8 -5
  49. package/public/js/weather.mjs +193 -0
  50. package/public/vendor/mediabunny-LICENSE +373 -0
  51. package/public/vendor/mediabunny.min.mjs +50 -0
package/lib/events.js CHANGED
@@ -1,6 +1,13 @@
1
1
  const { withTransaction } = require('./database');
2
2
  const { ApiError, badRequest, conflict, notFound } = require('./errors');
3
- const { activeEntryId, entryNearArrival, openEntryByHand, requireEntryRow } = require('./entries');
3
+ const {
4
+ DEPARTURE_MANOEUVRES,
5
+ activeEntryId,
6
+ entryNearArrival,
7
+ openEntryByHand,
8
+ reopenableEntry,
9
+ requireEntryRow
10
+ } = require('./entries');
4
11
  const { toPosition, paginate } = require('./rows');
5
12
  const { isPlainObject } = require('./validation');
6
13
 
@@ -20,8 +27,10 @@ const EVENT_TYPES = [
20
27
  // or from corrections, never posted by a client.
21
28
  const CLIENT_EVENT_TYPES = ['manoeuvre', 'text_annotation', 'handwritten_annotation'];
22
29
 
23
- // Logged with no passage open, these open one: the crew is leaving.
24
- const DEPARTURE_MANOEUVRES = new Set(['cast_off', 'anchor_up']);
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']);
25
34
 
26
35
  // An entry logged after the fact — typically replayed from the tablet's offline
27
36
  // queue — takes the position the track recorded at its time.
@@ -197,8 +206,9 @@ function createEvent(db, entryId, input, context) {
197
206
  }
198
207
 
199
208
  // An entry from the crew, attached to the passage it belongs to: the open one;
200
- // failing that, a departure manoeuvre opens one; failing that, the last
201
- // passage while the vessel is still at its arrival.
209
+ // failing that, a departure manoeuvre goes to the passage that just ended if
210
+ // a departure now would carry it on, and opens one otherwise; failing that,
211
+ // the last passage while the vessel is still at its arrival.
202
212
  function logCrewEvent(db, input, context) {
203
213
  validateContent(db, input);
204
214
  return withTransaction(db, () => {
@@ -212,14 +222,22 @@ function logCrewEvent(db, input, context) {
212
222
 
213
223
  let entryId = activeEntryId(db);
214
224
  let openedEntry = false;
215
- if (entryId === null && input.type === 'manoeuvre' && DEPARTURE_MANOEUVRES.has(input.subtype)) {
216
- entryId = openEntryByHand(db, {
217
- time,
218
- position,
219
- placeMatchRadius: context.placeMatchRadius,
220
- now: context.now
221
- });
222
- openedEntry = true;
225
+ if (
226
+ entryId === null &&
227
+ input.type === 'manoeuvre' &&
228
+ DEPARTURE_MANOEUVRES.includes(input.subtype)
229
+ ) {
230
+ // Detection reopens that passage once the vessel moves.
231
+ entryId = reopenableEntry(db, time, context.stopClosureMinutes * 60 * 1000)?.id ?? null;
232
+ if (entryId === null) {
233
+ entryId = openEntryByHand(db, {
234
+ time,
235
+ position,
236
+ placeMatchRadius: context.placeMatchRadius,
237
+ now: context.now
238
+ });
239
+ openedEntry = true;
240
+ }
223
241
  }
224
242
  entryId ??= entryNearArrival(db, position);
225
243
  if (entryId === null) {
@@ -264,10 +282,35 @@ function updateEvent(db, id, patch) {
264
282
  return toEvent(requireEventRow(db, id));
265
283
  }
266
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
+
267
310
  // Undoing the departure that opened a passage, before the vessel moved and
268
311
  // before anything else was logged in it, takes the passage away too.
269
312
  function deleteEvent(db, id) {
270
- requireEventRow(db, id);
313
+ const row = requireEventRow(db, id);
271
314
  withTransaction(db, () => {
272
315
  const opened = db
273
316
  .prepare(
@@ -278,15 +321,20 @@ function deleteEvent(db, id) {
278
321
  .get(id, id);
279
322
  if (opened) {
280
323
  db.prepare('DELETE FROM log_entries WHERE id = ?').run(opened.id);
281
- } else {
282
- 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);
283
329
  }
330
+ db.prepare('DELETE FROM events WHERE id = ?').run(id);
284
331
  });
285
332
  }
286
333
 
287
334
  module.exports = {
288
335
  EVENT_TYPES,
289
336
  CLIENT_EVENT_TYPES,
337
+ CRITICAL_STATES,
290
338
  listEvents,
291
339
  allEvents,
292
340
  createEvent,
package/lib/export.js CHANGED
@@ -2,13 +2,17 @@ const crypto = require('node:crypto');
2
2
  const fs = require('node:fs/promises');
3
3
  const path = require('node:path');
4
4
  const { getSchemaVersion } = require('./database');
5
+ const { listEntryCrew } = require('./crew');
5
6
  const { conflict } = require('./errors');
6
7
  const { toEntry } = require('./entries');
7
8
  const { allEvents } = require('./events');
8
9
  const { toCsv, toGpx } = require('./formats');
10
+ const { listEntryLandmarks } = require('./landmarks');
9
11
  const { renderLogbookPdf } = require('./logbook-pdf');
10
12
  const { allSegments } = require('./propulsion');
11
13
  const { allTrackPoints, allObservations } = require('./track');
14
+ const { getTideForecast } = require('./tide-forecaster');
15
+ const { getWeatherForecast } = require('./weather-forecaster');
12
16
 
13
17
  const EXPORT_FORMATS = {
14
18
  json: {
@@ -30,7 +34,11 @@ function bundleOf(db, row) {
30
34
  trackPoints: allTrackPoints(db, row.id),
31
35
  observations: allObservations(db, row.id),
32
36
  propulsion: allSegments(db, row.id),
33
- events: allEvents(db, row.id)
37
+ events: allEvents(db, row.id),
38
+ landmarks: listEntryLandmarks(db, row.id),
39
+ tide: getTideForecast(db, row.id),
40
+ weather: getWeatherForecast(db, row.id),
41
+ crew: listEntryCrew(db, row.id)
34
42
  };
35
43
  }
36
44
 
@@ -126,6 +134,17 @@ function passageFingerprint(db, row, pdf) {
126
134
  // version does not count: upgrading does not rewrite the whole drive.
127
135
  hash.update(JSON.stringify([pdf.language, pdf.timeZone, pdf.vesselName]));
128
136
  hash.update(JSON.stringify([summary('track_points'), summary('observations')]));
137
+ // The tide and weather forecasts each arrive once, after departure, and are printed.
138
+ for (const table of ['tide_forecasts', 'weather_forecasts']) {
139
+ const forecast = db.prepare(`SELECT fetched_at FROM ${table} WHERE entry_id = ?`).get(row.id);
140
+ hash.update(JSON.stringify(forecast?.fetched_at ?? null));
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
+ }
129
148
  for (const table of ['propulsion_segments', 'events']) {
130
149
  for (const item of db
131
150
  .prepare(`SELECT * FROM ${table} WHERE entry_id = ? ORDER BY id`)
@@ -0,0 +1,209 @@
1
+ const { cellBounds, nextPendingArea, saveArea } = require('./landmarks');
2
+
3
+ // Fills the gazetteer of amers (SPEC §4.13) from OpenStreetMap, one area at a
4
+ // time, on the same pattern as place names and the forecasts: `resolveNext()`
5
+ // does at most one request and says when the next is due. Offline is the
6
+ // normal state at sea, so a failure is retried patiently; an area already
7
+ // fetched is never asked for again.
8
+
9
+ const LANDMARK_DEFAULTS = {
10
+ landmarksEnabled: true,
11
+ overpassUrl: 'https://overpass-api.de/api/interpreter'
12
+ };
13
+
14
+ const METRES_PER_NM = 1852;
15
+
16
+ // Overpass is a shared, often busy service: ask it for one area at a time and
17
+ // leave room between requests.
18
+ const QUERY_TIMEOUT_S = 60;
19
+ const REQUEST_TIMEOUT_MS = 90 * 1000;
20
+ const NEXT_FETCH_MS = 10 * 1000;
21
+ const IDLE_MS = 5 * 60 * 1000;
22
+ const FIRST_RETRY_MS = 5 * 60 * 1000;
23
+ const MAX_RETRY_MS = 60 * 60 * 1000;
24
+
25
+ // What counts as an amer, in OpenStreetMap's tags. Deliberately narrow: the
26
+ // lateral and cardinal marks of a channel are named "6 c" or "L1" on the
27
+ // chart, which says nothing in a logbook, so only the features a position is
28
+ // traditionally read against are kept.
29
+ const SEAMARK_TYPES = [
30
+ 'light_major',
31
+ 'light_minor',
32
+ 'light_vessel',
33
+ 'landmark',
34
+ 'harbour',
35
+ 'beacon_isolated_danger',
36
+ 'beacon_safe_water'
37
+ ];
38
+
39
+ function kindOf(tags) {
40
+ const seamark = tags['seamark:type'];
41
+ if (tags.man_made === 'lighthouse' || seamark === 'light_major' || seamark === 'light_vessel') {
42
+ return 'lighthouse';
43
+ }
44
+ if (seamark === 'light_minor') {
45
+ return 'light';
46
+ }
47
+ if (tags.natural === 'cape') {
48
+ return 'cape';
49
+ }
50
+ if (seamark === 'landmark') {
51
+ return 'landmark';
52
+ }
53
+ if (seamark === 'beacon_isolated_danger' || seamark === 'beacon_safe_water') {
54
+ return 'beacon';
55
+ }
56
+ if (seamark === 'harbour' || tags.leisure === 'marina') {
57
+ return 'harbour';
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // A sectored light carries one range per sector ("seamark:light:2:range"); the
63
+ // greatest is how far the light is seen at all.
64
+ function lightRangeOf(tags) {
65
+ const ranges = Object.entries(tags)
66
+ .filter(([key]) => /^seamark:light(:\d+)?:range$/.test(key))
67
+ .map(([, value]) => Number.parseFloat(value))
68
+ .filter((range) => Number.isFinite(range) && range > 0);
69
+ return ranges.length === 0 ? null : Math.max(...ranges) * METRES_PER_NM;
70
+ }
71
+
72
+ // The name the crew would use: OpenStreetMap's own ("Phare du Cap-Ferret")
73
+ // rather than the chart's abbreviation in `seamark:name` ("Cap Ferret").
74
+ // A feature with neither is not an amer anyone can be told about.
75
+ function landmarkFrom(element) {
76
+ const tags = element.tags ?? {};
77
+ const name = (tags.name ?? tags['seamark:name'] ?? '').trim();
78
+ const kind = kindOf(tags);
79
+ // Ways and relations -- a lighthouse drawn as a building, a harbour as an
80
+ // area -- are asked for with their centre.
81
+ const position = element.type === 'node' ? element : element.center;
82
+ if (name === '' || !kind || !position) {
83
+ return null;
84
+ }
85
+ return {
86
+ osmType: element.type,
87
+ osmId: element.id,
88
+ name,
89
+ kind,
90
+ lat: position.lat,
91
+ lon: position.lon,
92
+ lightRange: lightRangeOf(tags)
93
+ };
94
+ }
95
+
96
+ function parseElements(elements = []) {
97
+ return elements.map(landmarkFrom).filter(Boolean);
98
+ }
99
+
100
+ function overpassQuery({ south, west, north, east }) {
101
+ const box = `(${south.toFixed(4)},${west.toFixed(4)},${north.toFixed(4)},${east.toFixed(4)})`;
102
+ return [
103
+ `[out:json][timeout:${QUERY_TIMEOUT_S}];`,
104
+ '(',
105
+ ` nwr["man_made"="lighthouse"]${box};`,
106
+ ` nwr["seamark:type"~"^(${SEAMARK_TYPES.join('|')})$"]${box};`,
107
+ ` nwr["natural"="cape"]${box};`,
108
+ ` nwr["leisure"="marina"]${box};`,
109
+ ');',
110
+ 'out tags center;'
111
+ ].join('\n');
112
+ }
113
+
114
+ function createLandmarkFinder({ db, settings, userAgent, fetch = globalThis.fetch }) {
115
+ const stopping = new AbortController();
116
+ let failures = 0;
117
+
118
+ // Overpass reports a busy dispatcher or a query timeout in the body, with
119
+ // HTTP 200 -- as an HTML page, or as a "remark" beside empty elements. Both
120
+ // mean "ask again later", not "there is nothing there".
121
+ async function lookup(cell) {
122
+ const response = await fetch(settings.overpassUrl, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'User-Agent': userAgent,
126
+ Accept: 'application/json',
127
+ 'Content-Type': 'application/x-www-form-urlencoded'
128
+ },
129
+ body: new URLSearchParams({ data: overpassQuery(cellBounds(cell)) }).toString(),
130
+ signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
131
+ });
132
+ if (response.status === 429 || response.status === 504 || response.status >= 500) {
133
+ throw new Error(`Overpass answered HTTP ${response.status}`);
134
+ }
135
+ if (!response.ok) {
136
+ return null;
137
+ }
138
+ let result;
139
+ try {
140
+ result = await response.json();
141
+ } catch {
142
+ throw new Error('Overpass answered something other than JSON');
143
+ }
144
+ if (typeof result?.remark === 'string' && /error|timeout/i.test(result.remark)) {
145
+ throw new Error(`Overpass reported: ${result.remark}`);
146
+ }
147
+ return Array.isArray(result?.elements) ? result.elements : [];
148
+ }
149
+
150
+ return {
151
+ async resolveNext() {
152
+ if (!settings.landmarksEnabled) {
153
+ return { outcome: 'disabled', retryInMs: IDLE_MS };
154
+ }
155
+ const pending = nextPendingArea(db);
156
+ if (!pending) {
157
+ failures = 0;
158
+ return { outcome: 'idle', retryInMs: IDLE_MS };
159
+ }
160
+
161
+ let elements;
162
+ try {
163
+ elements = await lookup(pending.cell);
164
+ } catch (error) {
165
+ if (stopping.signal.aborted) {
166
+ return { outcome: 'stopped' };
167
+ }
168
+ failures += 1;
169
+ return {
170
+ outcome: 'failed',
171
+ error,
172
+ retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
173
+ };
174
+ }
175
+ if (stopping.signal.aborted) {
176
+ return { outcome: 'stopped' };
177
+ }
178
+ failures = 0;
179
+
180
+ // A refusal Overpass will not answer differently (a rejected query) is
181
+ // final for this area: record it as covered rather than blocking every
182
+ // other one behind it.
183
+ const landmarks = elements === null ? [] : parseElements(elements);
184
+ saveArea(db, pending.cell, landmarks, new Date().toISOString());
185
+ return {
186
+ outcome: elements === null ? 'refused' : 'fetched',
187
+ count: landmarks.length,
188
+ areasLeft: pending.remaining - 1,
189
+ // Whatever is left to cover waits for the next slot: Overpass is not a
190
+ // service to run through as fast as it will answer.
191
+ retryInMs: NEXT_FETCH_MS
192
+ };
193
+ },
194
+
195
+ stop() {
196
+ stopping.abort();
197
+ }
198
+ };
199
+ }
200
+
201
+ module.exports = {
202
+ createLandmarkFinder,
203
+ kindOf,
204
+ landmarkFrom,
205
+ lightRangeOf,
206
+ overpassQuery,
207
+ parseElements,
208
+ LANDMARK_DEFAULTS
209
+ };
@@ -0,0 +1,210 @@
1
+ const { withTransaction } = require('./database');
2
+
3
+ // The gazetteer of amers (SPEC §4.13) and the bookkeeping of which areas it
4
+ // covers. Reading a position against a landmark -- the bearing and distance a
5
+ // logbook line shows -- happens in `public/js/landmarks.mjs`, shared with the
6
+ // webapp and the PDF; this module only stores and hands out the landmarks.
7
+
8
+ const METRES_PER_NM = 1852;
9
+ const METRES_PER_DEGREE = 111320;
10
+
11
+ // The widest range any kind of amer is named from, and so the margin a cell is
12
+ // fetched with. Kept in step with the largest entry of KIND_RANGE_M in
13
+ // public/js/landmarks.mjs, which a test checks.
14
+ const MAX_RELEVANT_M = 15 * METRES_PER_NM;
15
+
16
+ // Landmarks are fetched by half-degree cell -- about 30 nm of latitude, a
17
+ // day's sailing across, so a coastal cruise costs a handful of requests.
18
+ const CELL_DEGREES = 0.5;
19
+
20
+ // SQLite's CAST truncates towards zero, which is not FLOOR for a negative
21
+ // latitude or longitude; the offsets keep the value positive so it is. They
22
+ // are wide enough for any coordinate (lat / 0.5 >= -180, lon / 0.5 >= -360).
23
+ const LAT_OFFSET = 400;
24
+ const LON_OFFSET = 800;
25
+
26
+ function toRadians(degrees) {
27
+ return (degrees * Math.PI) / 180;
28
+ }
29
+
30
+ function toLandmark(row) {
31
+ return {
32
+ id: row.id,
33
+ name: row.name,
34
+ kind: row.kind,
35
+ position: { lat: row.lat, lon: row.lon },
36
+ lightRange: row.light_range,
37
+ osm: { type: row.osm_type, id: row.osm_id },
38
+ updatedAt: row.updated_at
39
+ };
40
+ }
41
+
42
+ // Every position a passage's journal shows a line for: its track, the readings
43
+ // and events on it, and its departure and arrival.
44
+ const POSITIONS = `
45
+ SELECT lat, lon FROM track_points WHERE entry_id = ?
46
+ UNION ALL
47
+ SELECT lat, lon FROM observations WHERE entry_id = ? AND lat IS NOT NULL
48
+ UNION ALL
49
+ SELECT lat, lon FROM events WHERE entry_id = ? AND lat IS NOT NULL
50
+ UNION ALL
51
+ SELECT start_lat, start_lon FROM log_entries WHERE id = ? AND start_lat IS NOT NULL
52
+ UNION ALL
53
+ SELECT end_lat, end_lon FROM log_entries WHERE id = ? AND end_lat IS NOT NULL
54
+ `;
55
+
56
+ function entryParams(entryId) {
57
+ return [entryId, entryId, entryId, entryId, entryId];
58
+ }
59
+
60
+ // The cells a passage has been in. Its bounding box would do for a coastal
61
+ // hop, but a long passage's box covers cells it never came near -- each of
62
+ // which would cost a request.
63
+ function entryCells(db, entryId) {
64
+ return db
65
+ .prepare(
66
+ `SELECT DISTINCT
67
+ CAST(lat / ${CELL_DEGREES} + ${LAT_OFFSET} AS INTEGER) - ${LAT_OFFSET} AS cell_lat,
68
+ CAST(lon / ${CELL_DEGREES} + ${LON_OFFSET} AS INTEGER) - ${LON_OFFSET} AS cell_lon
69
+ FROM (${POSITIONS})
70
+ ORDER BY cell_lat, cell_lon`
71
+ )
72
+ .all(...entryParams(entryId))
73
+ .map((row) => ({ cellLat: row.cell_lat, cellLon: row.cell_lon }));
74
+ }
75
+
76
+ function entryBounds(db, entryId) {
77
+ const row = db
78
+ .prepare(
79
+ `SELECT MIN(lat) AS min_lat, MAX(lat) AS max_lat, MIN(lon) AS min_lon, MAX(lon) AS max_lon
80
+ FROM (${POSITIONS})`
81
+ )
82
+ .get(...entryParams(entryId));
83
+ return row?.min_lat === null || row?.min_lat === undefined
84
+ ? null
85
+ : { minLat: row.min_lat, maxLat: row.max_lat, minLon: row.min_lon, maxLon: row.max_lon };
86
+ }
87
+
88
+ // How many degrees MAX_RELEVANT_M is, at the latitude it is measured from:
89
+ // a degree of longitude shortens towards the poles.
90
+ function margins(latitude) {
91
+ const latMargin = MAX_RELEVANT_M / METRES_PER_DEGREE;
92
+ const cosLat = Math.cos(toRadians(Math.min(89, Math.abs(latitude))));
93
+ return { latMargin, lonMargin: cosLat > 0.02 ? latMargin / cosLat : 180 };
94
+ }
95
+
96
+ // The area one cell is fetched for: the cell itself plus the margin, so a
97
+ // position anywhere inside it has every amer within range.
98
+ function cellBounds({ cellLat, cellLon }) {
99
+ const south = cellLat * CELL_DEGREES;
100
+ const north = south + CELL_DEGREES;
101
+ const west = cellLon * CELL_DEGREES;
102
+ const east = west + CELL_DEGREES;
103
+ const { latMargin, lonMargin } = margins(Math.max(Math.abs(south), Math.abs(north)));
104
+ return {
105
+ south: Math.max(-90, south - latMargin),
106
+ north: Math.min(90, north + latMargin),
107
+ west: Math.max(-180, west - lonMargin),
108
+ east: Math.min(180, east + lonMargin)
109
+ };
110
+ }
111
+
112
+ // The landmarks any line of a passage's journal could be read against: the
113
+ // area it sailed through, widened by the range an amer is named from. Near the
114
+ // antimeridian the longitude window would wrap, so latitude alone filters
115
+ // there and the distance test -- which runs at read time -- stays exact.
116
+ function listEntryLandmarks(db, entryId) {
117
+ const bounds = entryBounds(db, entryId);
118
+ if (!bounds) {
119
+ return [];
120
+ }
121
+ const { latMargin, lonMargin } = margins(
122
+ Math.max(Math.abs(bounds.minLat), Math.abs(bounds.maxLat))
123
+ );
124
+ const south = bounds.minLat - latMargin;
125
+ const north = bounds.maxLat + latMargin;
126
+ const west = bounds.minLon - lonMargin;
127
+ const east = bounds.maxLon + lonMargin;
128
+ const rows =
129
+ west < -180 || east > 180
130
+ ? db.prepare('SELECT * FROM landmarks WHERE lat BETWEEN ? AND ?').all(south, north)
131
+ : db
132
+ .prepare('SELECT * FROM landmarks WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?')
133
+ .all(south, north, west, east);
134
+ return rows.map(toLandmark);
135
+ }
136
+
137
+ function isAreaFetched(db, { cellLat, cellLon }) {
138
+ return (
139
+ db
140
+ .prepare('SELECT 1 FROM landmark_areas WHERE cell_lat = ? AND cell_lon = ?')
141
+ .get(cellLat, cellLon) !== undefined
142
+ );
143
+ }
144
+
145
+ // One fetched area, with the landmarks it found: written together, so a cell
146
+ // is only ever recorded as covered once its landmarks are in the table.
147
+ function saveArea(db, cell, landmarks, now) {
148
+ withTransaction(db, () => {
149
+ for (const landmark of landmarks) {
150
+ db.prepare(
151
+ `INSERT INTO landmarks
152
+ (osm_type, osm_id, name, kind, lat, lon, light_range, created_at, updated_at)
153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
154
+ ON CONFLICT (osm_type, osm_id) DO UPDATE SET
155
+ name = excluded.name,
156
+ kind = excluded.kind,
157
+ lat = excluded.lat,
158
+ lon = excluded.lon,
159
+ light_range = excluded.light_range,
160
+ updated_at = excluded.updated_at`
161
+ ).run(
162
+ landmark.osmType,
163
+ landmark.osmId,
164
+ landmark.name,
165
+ landmark.kind,
166
+ landmark.lat,
167
+ landmark.lon,
168
+ landmark.lightRange,
169
+ now,
170
+ now
171
+ );
172
+ }
173
+ db.prepare(
174
+ `INSERT INTO landmark_areas (cell_lat, cell_lon, fetched_at) VALUES (?, ?, ?)
175
+ ON CONFLICT (cell_lat, cell_lon) DO UPDATE SET fetched_at = excluded.fetched_at`
176
+ ).run(cell.cellLat, cell.cellLon, now);
177
+ });
178
+ }
179
+
180
+ // The next cell to fetch, newest passage first, and the passage that wants it.
181
+ // A passage whose cells are all covered is no longer pending -- unless it is
182
+ // still open, since it may yet sail into a new cell.
183
+ function nextPendingArea(db) {
184
+ const pending = db
185
+ .prepare('SELECT id, state FROM log_entries WHERE landmarks_pending = 1 ORDER BY id DESC')
186
+ .all();
187
+ for (const entry of pending) {
188
+ const missing = entryCells(db, entry.id).filter((cell) => !isAreaFetched(db, cell));
189
+ if (missing.length > 0) {
190
+ return { entryId: entry.id, cell: missing[0], remaining: missing.length };
191
+ }
192
+ if (entry.state !== 'active') {
193
+ db.prepare('UPDATE log_entries SET landmarks_pending = 0 WHERE id = ?').run(entry.id);
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ module.exports = {
200
+ CELL_DEGREES,
201
+ MAX_RELEVANT_M,
202
+ cellBounds,
203
+ entryBounds,
204
+ entryCells,
205
+ isAreaFetched,
206
+ listEntryLandmarks,
207
+ nextPendingArea,
208
+ saveArea,
209
+ toLandmark
210
+ };