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.
- package/CHANGELOG.md +29 -1
- package/README.md +66 -29
- package/index.js +43 -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/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/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +45 -7
- package/package.json +2 -1
- 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
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-chiplog",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"husky": "^9.1.7",
|
|
72
72
|
"leaflet": "^1.9.4",
|
|
73
73
|
"lint-staged": "^17.5.1",
|
|
74
|
+
"mediabunny": "1.58.0",
|
|
74
75
|
"preact": "^10.29.8",
|
|
75
76
|
"prettier": "^3.9.6"
|
|
76
77
|
}
|
package/public/app.css
CHANGED
|
@@ -557,6 +557,15 @@ main {
|
|
|
557
557
|
min-width: 14rem;
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
+
/* Where the position is in relation to the nearest landmark, under it: a
|
|
561
|
+
reading in its own right, not a heading, so it keeps the coordinates’
|
|
562
|
+
place and only its colour sets it back. */
|
|
563
|
+
.timeline-landmark {
|
|
564
|
+
display: block;
|
|
565
|
+
color: var(--muted);
|
|
566
|
+
font-size: 0.85em;
|
|
567
|
+
}
|
|
568
|
+
|
|
560
569
|
.timeline-event .timeline-time {
|
|
561
570
|
font-weight: 600;
|
|
562
571
|
}
|
|
@@ -896,3 +905,115 @@ main {
|
|
|
896
905
|
color: var(--muted);
|
|
897
906
|
font-weight: 500;
|
|
898
907
|
}
|
|
908
|
+
|
|
909
|
+
/* Passage animation */
|
|
910
|
+
|
|
911
|
+
.card-header-actions {
|
|
912
|
+
display: flex;
|
|
913
|
+
flex-wrap: wrap;
|
|
914
|
+
align-items: baseline;
|
|
915
|
+
gap: 0.8rem;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.animation-stage {
|
|
919
|
+
display: flex;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.animation-canvas {
|
|
924
|
+
display: block;
|
|
925
|
+
max-width: 100%;
|
|
926
|
+
border-radius: 8px;
|
|
927
|
+
/* The sea the renderer paints, so a tile that has not arrived is not a hole. */
|
|
928
|
+
background: #aad3df;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.animation-controls {
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-wrap: wrap;
|
|
934
|
+
align-items: center;
|
|
935
|
+
gap: 0.6rem;
|
|
936
|
+
margin-top: 0.75rem;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/* Wide enough that swapping Play for Pause does not shift the slider. */
|
|
940
|
+
.animation-play {
|
|
941
|
+
min-width: 7.5rem;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
.animation-slider {
|
|
945
|
+
flex: 1 1 12rem;
|
|
946
|
+
min-width: 8rem;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* These change every frame; proportional digits jitter. */
|
|
950
|
+
.animation-clock,
|
|
951
|
+
.animation-facts dd {
|
|
952
|
+
font-variant-numeric: tabular-nums;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.animation-speeds {
|
|
956
|
+
display: flex;
|
|
957
|
+
gap: 0.3rem;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.animation-speed {
|
|
961
|
+
padding: 0.35rem 0.6rem;
|
|
962
|
+
background: none;
|
|
963
|
+
color: var(--accent);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.animation-speed[aria-pressed='true'] {
|
|
967
|
+
background: var(--accent);
|
|
968
|
+
color: var(--accent-contrast);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.animation-facts {
|
|
972
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
973
|
+
margin: 0.75rem 0 0;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.animation-seamarks {
|
|
977
|
+
display: flex;
|
|
978
|
+
align-items: center;
|
|
979
|
+
gap: 0.4rem;
|
|
980
|
+
margin-top: 0.75rem;
|
|
981
|
+
color: var(--muted);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.animation-export-controls {
|
|
985
|
+
display: flex;
|
|
986
|
+
flex-wrap: wrap;
|
|
987
|
+
align-items: baseline;
|
|
988
|
+
gap: 1rem;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.animation-export-format {
|
|
992
|
+
display: flex;
|
|
993
|
+
flex-direction: column;
|
|
994
|
+
gap: 0.25rem;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
.animation-export-format select {
|
|
998
|
+
font: inherit;
|
|
999
|
+
padding: 0.4rem 0.6rem;
|
|
1000
|
+
border-radius: 8px;
|
|
1001
|
+
border: 1px solid var(--border);
|
|
1002
|
+
background: var(--surface);
|
|
1003
|
+
color: var(--text);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.animation-progress {
|
|
1007
|
+
margin-top: 1rem;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.animation-progress progress {
|
|
1011
|
+
width: 100%;
|
|
1012
|
+
height: 0.6rem;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
@media (max-width: 600px) {
|
|
1016
|
+
.animation-facts {
|
|
1017
|
+
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
|
1018
|
+
}
|
|
1019
|
+
}
|