signalk-chiplog 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/README.md +73 -34
- package/index.js +53 -17
- package/lib/api.js +15 -0
- package/lib/{forecast-schedule.js → background-schedule.js} +11 -11
- package/lib/database.js +44 -0
- package/lib/detection.js +9 -4
- package/lib/entries.js +7 -2
- package/lib/event-watcher.js +1 -3
- package/lib/events.js +38 -3
- package/lib/export.js +8 -0
- package/lib/influx-history.js +9 -4
- package/lib/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +45 -7
- package/lib/replay-job.js +1 -0
- package/package.json +3 -2
- package/public/app.css +121 -0
- package/public/entry/sw.js +1 -0
- package/public/js/animation/camera.mjs +180 -0
- package/public/js/animation/formats.mjs +45 -0
- package/public/js/animation/mercator.mjs +50 -0
- package/public/js/animation/mp4.mjs +77 -0
- package/public/js/animation/player.mjs +120 -0
- package/public/js/animation/renderer.mjs +326 -0
- package/public/js/animation/schedule.mjs +49 -0
- package/public/js/animation/storyboard.mjs +195 -0
- package/public/js/animation/tiles.mjs +219 -0
- package/public/js/animation/timeline.mjs +186 -0
- package/public/js/components/AnimationExport.mjs +173 -0
- package/public/js/components/AnimationView.mjs +439 -0
- package/public/js/components/ExportView.mjs +3 -8
- package/public/js/components/PassageView.mjs +29 -8
- package/public/js/components/Timeline.mjs +30 -7
- package/public/js/context.mjs +7 -0
- package/public/js/days.mjs +10 -0
- package/public/js/format.mjs +11 -0
- package/public/js/i18n.mjs +129 -0
- package/public/js/landmarks.mjs +128 -0
- package/public/js/main.mjs +8 -5
- package/public/vendor/mediabunny-LICENSE +373 -0
- package/public/vendor/mediabunny.min.mjs +50 -0
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
|
-
|
|
295
|
-
|
|
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`)
|
package/lib/influx-history.js
CHANGED
|
@@ -12,7 +12,9 @@ const { AUTOSTATE_SOURCE_PREFIX, MAX_AGE_MS, UNDERWAY_STATES } = require('./dete
|
|
|
12
12
|
// packets, a VPN not connected) can otherwise hang far longer than this --
|
|
13
13
|
// Node's fetch has no default timeout of its own -- for an error that is no
|
|
14
14
|
// clearer once it finally arrives, so `query` bounds every request itself.
|
|
15
|
-
|
|
15
|
+
// Configurable (`influxQueryTimeoutSeconds`): a Raspberry Pi under load can
|
|
16
|
+
// need longer than the default to answer a six-hour chunk.
|
|
17
|
+
const DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
|
|
16
18
|
|
|
17
19
|
// A resource-constrained host (a Raspberry Pi running both Signal K and
|
|
18
20
|
// InfluxDB) can be brought down by one query spanning weeks across every
|
|
@@ -34,7 +36,8 @@ const INFLUX_DEFAULTS = {
|
|
|
34
36
|
influxPort: 8086,
|
|
35
37
|
influxDatabase: '',
|
|
36
38
|
influxUsername: '',
|
|
37
|
-
influxPassword: ''
|
|
39
|
+
influxPassword: '',
|
|
40
|
+
influxQueryTimeoutSeconds: DEFAULT_QUERY_TIMEOUT_SECONDS
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
// Everything detection, track recording and observations read live (SPEC
|
|
@@ -107,9 +110,11 @@ function createInfluxHistory({
|
|
|
107
110
|
username,
|
|
108
111
|
password,
|
|
109
112
|
selfContext,
|
|
113
|
+
queryTimeoutSeconds = INFLUX_DEFAULTS.influxQueryTimeoutSeconds,
|
|
110
114
|
fetch = globalThis.fetch,
|
|
111
115
|
signal
|
|
112
116
|
}) {
|
|
117
|
+
const queryTimeoutMs = queryTimeoutSeconds * 1000;
|
|
113
118
|
// path -> [{ time, node }], ascending by time; `node` is what readSelfPath
|
|
114
119
|
// answers, built once here rather than on every read.
|
|
115
120
|
const series = new Map();
|
|
@@ -131,7 +136,7 @@ function createInfluxHistory({
|
|
|
131
136
|
epoch: 'ms',
|
|
132
137
|
q: statements.join(';')
|
|
133
138
|
});
|
|
134
|
-
const timeout = AbortSignal.timeout(
|
|
139
|
+
const timeout = AbortSignal.timeout(queryTimeoutMs);
|
|
135
140
|
let response;
|
|
136
141
|
try {
|
|
137
142
|
response = await fetch(url, {
|
|
@@ -146,7 +151,7 @@ function createInfluxHistory({
|
|
|
146
151
|
}
|
|
147
152
|
if (err.name === 'TimeoutError') {
|
|
148
153
|
throw new Error(
|
|
149
|
-
`InfluxDB at ${protocol}://${host}:${port} did not answer within ${
|
|
154
|
+
`InfluxDB at ${protocol}://${host}:${port} did not answer within ${queryTimeoutSeconds}s`,
|
|
150
155
|
{ cause: err }
|
|
151
156
|
);
|
|
152
157
|
}
|
|
@@ -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
|
+
};
|
package/lib/landmarks.js
ADDED
|
@@ -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
|
+
};
|
package/lib/logbook-pdf.js
CHANGED
|
@@ -14,6 +14,8 @@ const TABLE_HEAD_HEIGHT = 16;
|
|
|
14
14
|
const FOOTER_TOP = PAGE.height - PAGE.margin - 6;
|
|
15
15
|
const BODY_BOTTOM = FOOTER_TOP - 12;
|
|
16
16
|
const FONT_SIZE = 8;
|
|
17
|
+
// The landmark line under a position: set back by its size as well as its grey.
|
|
18
|
+
const LANDMARK_FONT_SIZE = 7;
|
|
17
19
|
const LINE_HEIGHT = 10;
|
|
18
20
|
const CELL_PADDING = 3;
|
|
19
21
|
const SKETCH_MAX_HEIGHT = 48;
|
|
@@ -35,7 +37,9 @@ const ALARM = [0.72, 0.1, 0.1];
|
|
|
35
37
|
|
|
36
38
|
const COLUMNS = [
|
|
37
39
|
{ key: 'time', width: 36 },
|
|
38
|
-
|
|
40
|
+
// Wide enough for the coordinates and, under them, most of a landmark
|
|
41
|
+
// bearing on one line (SPEC §4.13); the remarks column keeps the rest.
|
|
42
|
+
{ key: 'position', width: 142 },
|
|
39
43
|
{ key: 'course', width: 42 },
|
|
40
44
|
{ key: 'speed', width: 42 },
|
|
41
45
|
{ key: 'wind', width: 80 },
|
|
@@ -101,8 +105,16 @@ function loadShared() {
|
|
|
101
105
|
load('format.mjs'),
|
|
102
106
|
load('log-lines.mjs'),
|
|
103
107
|
load('weather.mjs'),
|
|
104
|
-
load('tide.mjs')
|
|
105
|
-
|
|
108
|
+
load('tide.mjs'),
|
|
109
|
+
load('landmarks.mjs')
|
|
110
|
+
]).then(([i18n, format, lines, weather, tide, landmarks]) => ({
|
|
111
|
+
i18n,
|
|
112
|
+
format,
|
|
113
|
+
lines,
|
|
114
|
+
weather,
|
|
115
|
+
tide,
|
|
116
|
+
landmarks
|
|
117
|
+
}));
|
|
106
118
|
return sharedModules;
|
|
107
119
|
}
|
|
108
120
|
|
|
@@ -411,7 +423,14 @@ function passageLines(bundle, context) {
|
|
|
411
423
|
].filter(Boolean)
|
|
412
424
|
});
|
|
413
425
|
}
|
|
414
|
-
|
|
426
|
+
// Every line's position is also read against the nearest amer (SPEC §4.13),
|
|
427
|
+
// printed in grey under the coordinates -- the same wording the passage page
|
|
428
|
+
// shows, from the same module.
|
|
429
|
+
const landmarks = bundle.landmarks ?? [];
|
|
430
|
+
return lines.map((line) => ({
|
|
431
|
+
...line,
|
|
432
|
+
landmark: context.landmarkLine(line.position ?? null, landmarks, { t, format })
|
|
433
|
+
}));
|
|
415
434
|
}
|
|
416
435
|
|
|
417
436
|
// Distance and engine/sail time of each calendar day, across all passages.
|
|
@@ -514,6 +533,7 @@ function readingCells(line, context) {
|
|
|
514
533
|
const cells = {
|
|
515
534
|
time: line.time ? format.time(line.time) : '',
|
|
516
535
|
position: format.position(line.position ?? null),
|
|
536
|
+
positionNote: line.landmark ?? '',
|
|
517
537
|
course: '',
|
|
518
538
|
speed: format.speed(readings?.sog),
|
|
519
539
|
wind: '',
|
|
@@ -535,8 +555,16 @@ function readingCells(line, context) {
|
|
|
535
555
|
return cells;
|
|
536
556
|
}
|
|
537
557
|
|
|
558
|
+
const POSITION_WIDTH = COLUMNS.find((column) => column.key === 'position').width;
|
|
559
|
+
|
|
538
560
|
function measure(line, context) {
|
|
539
561
|
const remarkLines = layoutRemarks(line.remarks, REMARKS_WIDTH - 2 * CELL_PADDING);
|
|
562
|
+
const cells = readingCells(line, context);
|
|
563
|
+
// The landmark line is narrower than the coordinates it sits under, so it
|
|
564
|
+
// wraps rather than running into the course column.
|
|
565
|
+
const positionLines = cells.positionNote
|
|
566
|
+
? wrapText(cells.positionNote, 'regular', LANDMARK_FONT_SIZE, POSITION_WIDTH - 2 * CELL_PADDING)
|
|
567
|
+
: [];
|
|
540
568
|
const sketch = sketchBox(line.strokes);
|
|
541
569
|
const maxLines = Math.floor(
|
|
542
570
|
(BODY_BOTTOM -
|
|
@@ -553,9 +581,9 @@ function measure(line, context) {
|
|
|
553
581
|
}
|
|
554
582
|
const height =
|
|
555
583
|
2 * CELL_PADDING +
|
|
556
|
-
Math.max(1, shown.length) * LINE_HEIGHT +
|
|
584
|
+
Math.max(1, shown.length, 1 + positionLines.length) * LINE_HEIGHT +
|
|
557
585
|
(sketch ? sketch.drawnHeight + (shown.length > 0 ? 2 : 0) : 0);
|
|
558
|
-
return { ...line, cells
|
|
586
|
+
return { ...line, cells, remarkLines: shown, positionLines, sketch, height };
|
|
559
587
|
}
|
|
560
588
|
|
|
561
589
|
// Pages as lists of measured rows; nothing is drawn yet, so the page count is
|
|
@@ -615,6 +643,14 @@ function drawRow(page, row, top) {
|
|
|
615
643
|
if (text) {
|
|
616
644
|
page.text(x + CELL_PADDING, baseline, text, { size: FONT_SIZE });
|
|
617
645
|
}
|
|
646
|
+
if (column.key === 'position') {
|
|
647
|
+
row.positionLines.forEach((text, index) => {
|
|
648
|
+
page.text(x + CELL_PADDING, baseline + (index + 1) * LINE_HEIGHT, text, {
|
|
649
|
+
size: LANDMARK_FONT_SIZE,
|
|
650
|
+
color: GREY
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
618
654
|
x += column.width;
|
|
619
655
|
}
|
|
620
656
|
row.remarkLines.forEach((words, index) => {
|
|
@@ -725,7 +761,8 @@ async function renderLogbookPdf(
|
|
|
725
761
|
format: formatModule,
|
|
726
762
|
lines: lineModule,
|
|
727
763
|
weather: weatherModule,
|
|
728
|
-
tide: tideModule
|
|
764
|
+
tide: tideModule,
|
|
765
|
+
landmarks: landmarkModule
|
|
729
766
|
} = await loadShared();
|
|
730
767
|
const t = i18n.createTranslator(language);
|
|
731
768
|
const format = formatModule.createFormatter({
|
|
@@ -748,6 +785,7 @@ async function renderLogbookPdf(
|
|
|
748
785
|
tideExtremes: tideModule.tideExtremes,
|
|
749
786
|
tankName: lineModule.tankName,
|
|
750
787
|
batteryName: lineModule.batteryName,
|
|
788
|
+
landmarkLine: landmarkModule.landmarkLine,
|
|
751
789
|
manoeuvreLabels: {}
|
|
752
790
|
};
|
|
753
791
|
|