signalk-chiplog 1.0.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 (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. package/public/vendor/preact-htm.mjs +1 -0
package/lib/formats.js ADDED
@@ -0,0 +1,215 @@
1
+ // Rendering for human-facing exports. This is the presentation layer the data
2
+ // model defers unit conversion to: the database stays in Signal K SI units.
3
+
4
+ const KNOTS_PER_MPS = 3600 / 1852;
5
+ const METRES_PER_NM = 1852;
6
+
7
+ function convert(value, fn, digits) {
8
+ return value === null || value === undefined ? null : Number(fn(value).toFixed(digits));
9
+ }
10
+
11
+ const radiansToDegrees = (rad) => (rad * 180) / Math.PI;
12
+ const wrap360 = (degrees) => ((degrees % 360) + 360) % 360;
13
+
14
+ const toKnots = (value) => convert(value, (mps) => mps * KNOTS_PER_MPS, 1);
15
+
16
+ function toBearing(value) {
17
+ const degrees = convert(value, (rad) => wrap360(radiansToDegrees(rad)), 0);
18
+ return degrees === 360 ? 0 : degrees;
19
+ }
20
+
21
+ const toSignedAngle = (value) =>
22
+ convert(value, (rad) => wrap360(radiansToDegrees(rad) + 180) - 180, 0);
23
+ const toHectopascals = (value) => convert(value, (pa) => pa / 100, 1);
24
+ const toCelsius = (value) => convert(value, (k) => k - 273.15, 1);
25
+ const toNauticalMiles = (value) => convert(value, (m) => m / METRES_PER_NM, 1);
26
+ const toHours = (value) => convert(value, (s) => s / 3600, 1);
27
+ const toMetres = (value) => convert(value, (m) => m, 1);
28
+ const toCoordinate = (value) => convert(value, (deg) => deg, 6);
29
+
30
+ function toGeoJson(entry, trackPoints) {
31
+ const coordinates = trackPoints.map((point) => [point.lon, point.lat]);
32
+ let geometry = null;
33
+ if (coordinates.length === 1) {
34
+ geometry = { type: 'Point', coordinates: coordinates[0] };
35
+ } else if (coordinates.length > 1) {
36
+ geometry = { type: 'LineString', coordinates };
37
+ }
38
+ return {
39
+ type: 'Feature',
40
+ geometry,
41
+ properties: {
42
+ entryId: entry.id,
43
+ startTime: entry.startTime,
44
+ endTime: entry.endTime,
45
+ startPlaceName: entry.startPlaceName,
46
+ endPlaceName: entry.endPlaceName,
47
+ coordTimes: trackPoints.map((point) => point.time)
48
+ }
49
+ };
50
+ }
51
+
52
+ const XML_ESCAPES = { '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' };
53
+
54
+ function escapeXml(value) {
55
+ return String(value).replace(/[<>&'"]/g, (char) => XML_ESCAPES[char]);
56
+ }
57
+
58
+ function trackName(entry) {
59
+ const places = [entry.startPlaceName, entry.endPlaceName].filter(Boolean).join(' - ');
60
+ return places || `Passage of ${entry.startTime.slice(0, 10)}`;
61
+ }
62
+
63
+ function toGpx(bundles) {
64
+ const tracks = bundles.map(({ entry, trackPoints }) => {
65
+ const points = trackPoints
66
+ .map(
67
+ (point) =>
68
+ ` <trkpt lat="${point.lat}" lon="${point.lon}"><time>${point.time}</time></trkpt>`
69
+ )
70
+ .join('\n');
71
+ return [
72
+ ' <trk>',
73
+ ` <name>${escapeXml(trackName(entry))}</name>`,
74
+ ' <trkseg>',
75
+ points,
76
+ ' </trkseg>',
77
+ ' </trk>'
78
+ ]
79
+ .filter((line) => line !== '')
80
+ .join('\n');
81
+ });
82
+
83
+ return [
84
+ '<?xml version="1.0" encoding="UTF-8"?>',
85
+ '<gpx version="1.1" creator="signalk-chiplog" xmlns="http://www.topografix.com/GPX/1/1">',
86
+ ...tracks,
87
+ '</gpx>',
88
+ ''
89
+ ].join('\n');
90
+ }
91
+
92
+ const CSV_COLUMNS = [
93
+ 'entry_id',
94
+ 'time_utc',
95
+ 'kind',
96
+ 'type',
97
+ 'subtype',
98
+ 'place',
99
+ 'lat',
100
+ 'lon',
101
+ 'sog_kn',
102
+ 'cog_deg',
103
+ 'heading_deg',
104
+ 'stw_kn',
105
+ 'tws_kn',
106
+ 'twd_deg',
107
+ 'aws_kn',
108
+ 'awa_deg',
109
+ 'depth_m',
110
+ 'pressure_hpa',
111
+ 'air_temp_c',
112
+ 'water_temp_c',
113
+ 'trip_log_nm',
114
+ 'engine_runtime_h',
115
+ 'comment'
116
+ ];
117
+
118
+ const KIND_ORDER = { departure: 0, observation: 1, event: 2, arrival: 3 };
119
+
120
+ function csvCell(value) {
121
+ if (value === null || value === undefined) {
122
+ return '';
123
+ }
124
+ if (typeof value !== 'string') {
125
+ return String(value);
126
+ }
127
+ // Spreadsheets execute cells starting with these characters as formulas;
128
+ // comments and place names are free text typed by the crew.
129
+ const text = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
130
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
131
+ }
132
+
133
+ function positionColumns(position) {
134
+ return {
135
+ lat: toCoordinate(position?.lat),
136
+ lon: toCoordinate(position?.lon)
137
+ };
138
+ }
139
+
140
+ // One chronological line per departure, observation, event and arrival: the
141
+ // shape of a paper logbook, readable without the plugin.
142
+ function toCsv(bundles) {
143
+ const rows = [];
144
+
145
+ for (const { entry, observations, events } of bundles) {
146
+ rows.push({
147
+ entry_id: entry.id,
148
+ time_utc: entry.startTime,
149
+ kind: 'departure',
150
+ place: entry.startPlaceName,
151
+ ...positionColumns(entry.startPosition)
152
+ });
153
+
154
+ for (const observation of observations) {
155
+ rows.push({
156
+ entry_id: entry.id,
157
+ time_utc: observation.time,
158
+ kind: 'observation',
159
+ type: observation.reason,
160
+ ...positionColumns(observation.position),
161
+ sog_kn: toKnots(observation.sog),
162
+ cog_deg: toBearing(observation.cog),
163
+ heading_deg: toBearing(observation.heading),
164
+ stw_kn: toKnots(observation.stw),
165
+ tws_kn: toKnots(observation.tws),
166
+ twd_deg: toBearing(observation.twd),
167
+ aws_kn: toKnots(observation.aws),
168
+ awa_deg: toSignedAngle(observation.awa),
169
+ depth_m: toMetres(observation.depth),
170
+ pressure_hpa: toHectopascals(observation.pressure),
171
+ air_temp_c: toCelsius(observation.airTemp),
172
+ water_temp_c: toCelsius(observation.waterTemp),
173
+ trip_log_nm: toNauticalMiles(observation.tripLog),
174
+ engine_runtime_h: toHours(observation.engineRuntime)
175
+ });
176
+ }
177
+
178
+ for (const event of events) {
179
+ rows.push({
180
+ entry_id: entry.id,
181
+ time_utc: event.time,
182
+ kind: 'event',
183
+ type: event.type,
184
+ subtype: event.subtype,
185
+ ...positionColumns(event.position),
186
+ comment: event.comment
187
+ });
188
+ }
189
+
190
+ if (entry.endTime) {
191
+ rows.push({
192
+ entry_id: entry.id,
193
+ time_utc: entry.endTime,
194
+ kind: 'arrival',
195
+ place: entry.endPlaceName,
196
+ ...positionColumns(entry.endPosition)
197
+ });
198
+ }
199
+ }
200
+
201
+ rows.sort(
202
+ (a, b) =>
203
+ a.time_utc.localeCompare(b.time_utc) ||
204
+ a.entry_id - b.entry_id ||
205
+ KIND_ORDER[a.kind] - KIND_ORDER[b.kind]
206
+ );
207
+
208
+ const lines = [CSV_COLUMNS.join(',')];
209
+ for (const row of rows) {
210
+ lines.push(CSV_COLUMNS.map((column) => csvCell(row[column])).join(','));
211
+ }
212
+ return `${lines.join('\r\n')}\r\n`;
213
+ }
214
+
215
+ module.exports = { toGeoJson, toGpx, toCsv };
@@ -0,0 +1,84 @@
1
+ const { badRequest, conflict, notFound } = require('./errors');
2
+ const { paginate } = require('./rows');
3
+
4
+ const KEY_PATTERN = /^[a-z0-9_]{1,40}$/;
5
+
6
+ function toManoeuvreType(row) {
7
+ return {
8
+ key: row.key,
9
+ label: row.label,
10
+ icon: row.icon,
11
+ sortOrder: row.sort_order,
12
+ builtin: row.builtin === 1,
13
+ enabled: row.enabled === 1
14
+ };
15
+ }
16
+
17
+ function requireTypeRow(db, key) {
18
+ const row = db.prepare('SELECT * FROM manoeuvre_types WHERE key = ?').get(key);
19
+ if (!row) {
20
+ throw notFound('manoeuvre_type', key);
21
+ }
22
+ return row;
23
+ }
24
+
25
+ function listManoeuvreTypes(db, { limit, offset }) {
26
+ return paginate(db, {
27
+ table: 'manoeuvre_types',
28
+ orderBy: 'sort_order, key',
29
+ limit,
30
+ offset,
31
+ map: toManoeuvreType
32
+ });
33
+ }
34
+
35
+ function createManoeuvreType(db, { key, label, icon, sortOrder, enabled }) {
36
+ if (!KEY_PATTERN.test(key)) {
37
+ throw badRequest('key must be 1-40 characters of lowercase letters, digits or underscores');
38
+ }
39
+ if (db.prepare('SELECT 1 FROM manoeuvre_types WHERE key = ?').get(key)) {
40
+ throw conflict('manoeuvre_type_exists', `A manoeuvre type with key ${key} already exists`);
41
+ }
42
+
43
+ const order =
44
+ sortOrder ??
45
+ db.prepare('SELECT COALESCE(MAX(sort_order), 0) + 10 AS next FROM manoeuvre_types').get().next;
46
+ db.prepare(
47
+ `INSERT INTO manoeuvre_types (key, label, icon, sort_order, builtin, enabled)
48
+ VALUES (?, ?, ?, ?, 0, ?)`
49
+ ).run(key, label, icon ?? null, order, enabled === false ? 0 : 1);
50
+
51
+ return toManoeuvreType(requireTypeRow(db, key));
52
+ }
53
+
54
+ function updateManoeuvreType(db, key, patch) {
55
+ const row = requireTypeRow(db, key);
56
+ db.prepare(
57
+ 'UPDATE manoeuvre_types SET label = ?, icon = ?, sort_order = ?, enabled = ? WHERE key = ?'
58
+ ).run(
59
+ patch.label ?? row.label,
60
+ patch.icon !== undefined ? patch.icon : row.icon,
61
+ patch.sortOrder ?? row.sort_order,
62
+ patch.enabled === undefined ? row.enabled : Number(patch.enabled),
63
+ key
64
+ );
65
+ return toManoeuvreType(requireTypeRow(db, key));
66
+ }
67
+
68
+ function deleteManoeuvreType(db, key) {
69
+ const row = requireTypeRow(db, key);
70
+ if (row.builtin === 1) {
71
+ throw conflict(
72
+ 'builtin_manoeuvre_type',
73
+ `${key} is built in and cannot be deleted; disable it instead`
74
+ );
75
+ }
76
+ db.prepare('DELETE FROM manoeuvre_types WHERE key = ?').run(key);
77
+ }
78
+
79
+ module.exports = {
80
+ listManoeuvreTypes,
81
+ createManoeuvreType,
82
+ updateManoeuvreType,
83
+ deleteManoeuvreType
84
+ };
@@ -0,0 +1,152 @@
1
+ const OBSERVATION_DEFAULTS = { observationIntervalMinutes: 60 };
2
+
3
+ // Navigation, wind and depth arrive several times a second; barometer and
4
+ // temperatures can be minutes apart.
5
+ const FAST_MAX_AGE_MS = 2 * 60 * 1000;
6
+ const SLOW_MAX_AGE_MS = 15 * 60 * 1000;
7
+
8
+ const COLUMNS = [
9
+ 'lat',
10
+ 'lon',
11
+ 'sog',
12
+ 'cog',
13
+ 'heading',
14
+ 'stw',
15
+ 'twd',
16
+ 'tws',
17
+ 'awa',
18
+ 'aws',
19
+ 'depth',
20
+ 'pressure',
21
+ 'air_temp',
22
+ 'water_temp',
23
+ 'trip_log',
24
+ 'engine_runtime'
25
+ ];
26
+
27
+ function iso(ms) {
28
+ return new Date(ms).toISOString();
29
+ }
30
+
31
+ function finiteOrNull(value) {
32
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
33
+ }
34
+
35
+ function normalizeAngle(radians) {
36
+ return ((radians % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
37
+ }
38
+
39
+ function createObservationRecorder({ db, readSelfPath, readFresh, settings }) {
40
+ const intervalMs = settings.observationIntervalMinutes * 60 * 1000;
41
+ let latest = {};
42
+
43
+ const fast = (path, now) => finiteOrNull(readFresh(path, now, FAST_MAX_AGE_MS));
44
+ const slow = (path, now) => finiteOrNull(readFresh(path, now, SLOW_MAX_AGE_MS));
45
+ // A counter's last value stays a true reading while it does not move: engine
46
+ // hours with the engine off, the trip log at anchor.
47
+ const counter = (path) => finiteOrNull(readSelfPath(path)?.value);
48
+
49
+ function engineRuntime() {
50
+ const engines = Object.keys(readSelfPath('propulsion') ?? {});
51
+ const ordered = engines.includes('main') ? ['main', ...engines] : engines;
52
+ for (const id of ordered) {
53
+ const runtime = counter(`propulsion.${id}.runTime`);
54
+ if (runtime !== null) {
55
+ return runtime;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Called every detection cycle, not only when a snapshot is due: freshness is
62
+ // judged by timestamps changing between reads, so a sensor read once an hour
63
+ // would look current as long as it had changed at any point in that hour.
64
+ function sense(now) {
65
+ const position = readFresh('navigation.position', now, FAST_MAX_AGE_MS);
66
+ const headingTrue = fast('navigation.headingTrue', now);
67
+ const headingMagnetic = fast('navigation.headingMagnetic', now);
68
+ const variation = slow('navigation.magneticVariation', now);
69
+ const belowSurface = fast('environment.depth.belowSurface', now);
70
+ const belowTransducer = fast('environment.depth.belowTransducer', now);
71
+
72
+ const hasPosition =
73
+ position && Number.isFinite(position.latitude) && Number.isFinite(position.longitude);
74
+
75
+ latest = {
76
+ lat: hasPosition ? position.latitude : null,
77
+ lon: hasPosition ? position.longitude : null,
78
+ sog: fast('navigation.speedOverGround', now),
79
+ cog: fast('navigation.courseOverGroundTrue', now),
80
+ heading:
81
+ headingTrue ??
82
+ (headingMagnetic !== null && variation !== null
83
+ ? normalizeAngle(headingMagnetic + variation)
84
+ : null),
85
+ stw: fast('navigation.speedThroughWater', now),
86
+ twd: fast('environment.wind.directionTrue', now),
87
+ tws: fast('environment.wind.speedTrue', now),
88
+ awa: fast('environment.wind.angleApparent', now),
89
+ aws: fast('environment.wind.speedApparent', now),
90
+ depth: belowSurface ?? belowTransducer,
91
+ pressure: slow('environment.outside.pressure', now),
92
+ air_temp: slow('environment.outside.temperature', now),
93
+ water_temp: slow('environment.water.temperature', now),
94
+ trip_log: counter('navigation.trip.log'),
95
+ engine_runtime: engineRuntime()
96
+ };
97
+ }
98
+
99
+ function record(entryId, reason, time) {
100
+ if (COLUMNS.every((column) => latest[column] === null)) {
101
+ return null;
102
+ }
103
+ const { lastInsertRowid } = db
104
+ .prepare(
105
+ `INSERT INTO observations (entry_id, time, reason, ${COLUMNS.join(', ')})
106
+ VALUES (?, ?, ?, ${COLUMNS.map(() => '?').join(', ')})`
107
+ )
108
+ .run(entryId, time, reason, ...COLUMNS.map((column) => latest[column]));
109
+ return Number(lastInsertRowid);
110
+ }
111
+
112
+ // Periodic snapshots fall on clock boundaries — on the hour by default — as
113
+ // on a paper log. Any snapshot already in the slot, such as one taken for an
114
+ // event, stands in for the periodic one.
115
+ function recordPeriodic(entry, now) {
116
+ const { lastTime } = db
117
+ .prepare('SELECT MAX(time) AS lastTime FROM observations WHERE entry_id = ?')
118
+ .get(entry.id);
119
+ const lastSlot = Math.floor(Date.parse(lastTime ?? entry.start_time) / intervalMs);
120
+ if (Math.floor(now / intervalMs) > lastSlot) {
121
+ record(entry.id, 'periodic', iso(now));
122
+ }
123
+ }
124
+
125
+ return {
126
+ sense,
127
+
128
+ // Runs inside detection's transaction, with what this cycle did to passages.
129
+ afterDetection(now, { opened, closed }) {
130
+ if (closed && !closed.late) {
131
+ record(closed.id, 'entry_end', iso(now));
132
+ }
133
+ if (opened) {
134
+ record(opened, 'entry_start', iso(now));
135
+ return;
136
+ }
137
+ const entry = db
138
+ .prepare("SELECT id, start_time FROM log_entries WHERE state = 'active'")
139
+ .get();
140
+ if (entry) {
141
+ recordPeriodic(entry, now);
142
+ }
143
+ },
144
+
145
+ recordEvent(entryId, time, now) {
146
+ sense(now);
147
+ return record(entryId, 'event', time);
148
+ }
149
+ };
150
+ }
151
+
152
+ module.exports = { createObservationRecorder, OBSERVATION_DEFAULTS };
@@ -0,0 +1,208 @@
1
+ const { withTransaction } = require('./database');
2
+ const { findNearestPlace } = require('./places');
3
+
4
+ const GEOCODING_DEFAULTS = {
5
+ geocodingEnabled: true,
6
+ geocodingUrl: 'https://nominatim.openstreetmap.org'
7
+ };
8
+
9
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
10
+ // Nominatim's usage policy allows at most one request per second.
11
+ const NEXT_LOOKUP_MS = 2 * 1000;
12
+ const IDLE_MS = 60 * 1000;
13
+ // Offline is the normal state at sea; retry patiently rather than hammering.
14
+ const FIRST_RETRY_MS = 5 * 60 * 1000;
15
+ const MAX_RETRY_MS = 60 * 60 * 1000;
16
+
17
+ const HARBOUR_TYPES = new Set(['marina', 'harbour', 'port']);
18
+ const SETTLEMENT_KEYS = [
19
+ 'city',
20
+ 'town',
21
+ 'village',
22
+ 'hamlet',
23
+ 'isolated_dwelling',
24
+ 'locality',
25
+ 'island',
26
+ 'municipality'
27
+ ];
28
+ const DISTRICT_KEYS = ['quarter', 'suburb', 'neighbourhood', 'city_district'];
29
+
30
+ function coordinatesName({ lat, lon }) {
31
+ const latitude = `${Math.abs(lat).toFixed(4)}${lat >= 0 ? 'N' : 'S'}`;
32
+ const longitude = `${Math.abs(lon).toFixed(4)}${lon >= 0 ? 'E' : 'W'}`;
33
+ return `${latitude} ${longitude}`;
34
+ }
35
+
36
+ // Name for a departure or arrival as it happens: a known place, or failing
37
+ // that a name from coordinates left pending until geocoding answers.
38
+ function initialPlaceName(db, position, radius) {
39
+ if (!position) {
40
+ return { id: null, name: null, pending: 0 };
41
+ }
42
+ const place = findNearestPlace(db, position, radius);
43
+ return place
44
+ ? { id: place.id, name: place.name, pending: 0 }
45
+ : { id: null, name: coordinatesName(position), pending: 1 };
46
+ }
47
+
48
+ // Near a marina Nominatim tends to answer with the quay's road, and at sea with
49
+ // a bare administrative boundary: neither is a logbook place name. The
50
+ // settlement, with its district when there is one, is.
51
+ function pickName(result) {
52
+ if (result.name && HARBOUR_TYPES.has(result.type)) {
53
+ return result.name;
54
+ }
55
+ const address = result.address ?? {};
56
+ const settlement = SETTLEMENT_KEYS.map((key) => address[key]).find(Boolean);
57
+ if (!settlement) {
58
+ return null;
59
+ }
60
+ const district = DISTRICT_KEYS.map((key) => address[key]).find(Boolean);
61
+ return district && district !== settlement ? `${settlement} (${district})` : settlement;
62
+ }
63
+
64
+ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch }) {
65
+ const stopping = new AbortController();
66
+ let failures = 0;
67
+
68
+ function nextPending() {
69
+ return db
70
+ .prepare(
71
+ `SELECT id, 'start' AS side, start_lat AS lat, start_lon AS lon FROM log_entries
72
+ WHERE start_place_pending = 1 AND start_lat IS NOT NULL
73
+ UNION ALL
74
+ SELECT id, 'end' AS side, end_lat AS lat, end_lon AS lon FROM log_entries
75
+ WHERE end_place_pending = 1 AND end_lat IS NOT NULL
76
+ ORDER BY id DESC, side DESC
77
+ LIMIT 1`
78
+ )
79
+ .get();
80
+ }
81
+
82
+ // Only a name still pending for the position that was looked up is replaced:
83
+ // a name typed, or a position corrected, while the lookup was out stays.
84
+ function settle({ id, side, lat, lon }, place) {
85
+ const prefix = side === 'start' ? 'start' : 'end';
86
+ const stillAsLookedUp = `id = ? AND ${prefix}_place_pending = 1 AND ${prefix}_lat = ? AND ${prefix}_lon = ?`;
87
+ if (place) {
88
+ db.prepare(
89
+ `UPDATE log_entries
90
+ SET ${prefix}_place_name = ?, ${prefix}_place_id = ?, ${prefix}_place_pending = 0
91
+ WHERE ${stillAsLookedUp}`
92
+ ).run(place.name, place.id, id, lat, lon);
93
+ } else {
94
+ db.prepare(`UPDATE log_entries SET ${prefix}_place_pending = 0 WHERE ${stillAsLookedUp}`).run(
95
+ id,
96
+ lat,
97
+ lon
98
+ );
99
+ }
100
+ }
101
+
102
+ // The places table is the geocoding cache: the next departure or arrival
103
+ // within the radius is named from it, with no request.
104
+ function rememberGeocoded(name, { lat, lon }) {
105
+ const now = new Date().toISOString();
106
+ const { lastInsertRowid } = db
107
+ .prepare(
108
+ `INSERT INTO places (name, lat, lon, source, created_at, updated_at)
109
+ VALUES (?, ?, ?, 'geocoding', ?, ?)`
110
+ )
111
+ .run(name, lat, lon, now, now);
112
+ return { id: Number(lastInsertRowid), name };
113
+ }
114
+
115
+ async function lookup({ lat, lon }) {
116
+ const base = settings.geocodingUrl.endsWith('/')
117
+ ? settings.geocodingUrl
118
+ : `${settings.geocodingUrl}/`;
119
+ const url = new URL('reverse', base);
120
+ url.searchParams.set('format', 'jsonv2');
121
+ url.searchParams.set('lat', String(lat));
122
+ url.searchParams.set('lon', String(lon));
123
+ url.searchParams.set('zoom', '17');
124
+ url.searchParams.set('addressdetails', '1');
125
+
126
+ const response = await fetch(url, {
127
+ headers: { 'User-Agent': userAgent, Accept: 'application/json' },
128
+ signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
129
+ });
130
+ // Rate limiting, server trouble or a refused client are worth retrying
131
+ // later; anything else about this position is a final answer.
132
+ if (
133
+ response.status === 401 ||
134
+ response.status === 403 ||
135
+ response.status === 429 ||
136
+ response.status >= 500
137
+ ) {
138
+ throw new Error(`Geocoding service answered HTTP ${response.status}`);
139
+ }
140
+ if (!response.ok) {
141
+ return null;
142
+ }
143
+ const result = await response.json();
144
+ return result.error ? null : pickName(result);
145
+ }
146
+
147
+ return {
148
+ async resolveNext() {
149
+ if (!settings.geocodingEnabled) {
150
+ return { outcome: 'disabled', retryInMs: IDLE_MS };
151
+ }
152
+ const pending = nextPending();
153
+ if (!pending) {
154
+ return { outcome: 'idle', retryInMs: IDLE_MS };
155
+ }
156
+
157
+ // A lookup for a nearby departure or arrival may already have named it.
158
+ const known = findNearestPlace(db, pending, settings.placeMatchRadius);
159
+ if (known) {
160
+ settle(pending, known);
161
+ return { outcome: 'known', retryInMs: 0 };
162
+ }
163
+
164
+ let name;
165
+ try {
166
+ name = await lookup(pending);
167
+ } catch (error) {
168
+ if (stopping.signal.aborted) {
169
+ return { outcome: 'stopped' };
170
+ }
171
+ failures += 1;
172
+ return {
173
+ outcome: 'failed',
174
+ error,
175
+ retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
176
+ };
177
+ }
178
+ if (stopping.signal.aborted) {
179
+ return { outcome: 'stopped' };
180
+ }
181
+ failures = 0;
182
+
183
+ if (!name) {
184
+ settle(pending, null);
185
+ return { outcome: 'no_result', retryInMs: NEXT_LOOKUP_MS };
186
+ }
187
+ withTransaction(db, () => {
188
+ const place =
189
+ findNearestPlace(db, pending, settings.placeMatchRadius) ??
190
+ rememberGeocoded(name, pending);
191
+ settle(pending, place);
192
+ });
193
+ return { outcome: 'resolved', name, retryInMs: NEXT_LOOKUP_MS };
194
+ },
195
+
196
+ stop() {
197
+ stopping.abort();
198
+ }
199
+ };
200
+ }
201
+
202
+ module.exports = {
203
+ coordinatesName,
204
+ initialPlaceName,
205
+ pickName,
206
+ createPlaceNamer,
207
+ GEOCODING_DEFAULTS
208
+ };