signalk-chiplog 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +66 -29
  3. package/index.js +43 -17
  4. package/lib/api.js +15 -0
  5. package/lib/{forecast-schedule.js → background-schedule.js} +11 -11
  6. package/lib/database.js +44 -0
  7. package/lib/entries.js +7 -2
  8. package/lib/event-watcher.js +1 -3
  9. package/lib/events.js +38 -3
  10. package/lib/export.js +8 -0
  11. package/lib/landmark-finder.js +209 -0
  12. package/lib/landmarks.js +210 -0
  13. package/lib/logbook-pdf.js +45 -7
  14. package/package.json +2 -1
  15. package/public/app.css +121 -0
  16. package/public/entry/sw.js +1 -0
  17. package/public/js/animation/camera.mjs +180 -0
  18. package/public/js/animation/formats.mjs +45 -0
  19. package/public/js/animation/mercator.mjs +50 -0
  20. package/public/js/animation/mp4.mjs +77 -0
  21. package/public/js/animation/player.mjs +120 -0
  22. package/public/js/animation/renderer.mjs +326 -0
  23. package/public/js/animation/schedule.mjs +49 -0
  24. package/public/js/animation/storyboard.mjs +195 -0
  25. package/public/js/animation/tiles.mjs +219 -0
  26. package/public/js/animation/timeline.mjs +186 -0
  27. package/public/js/components/AnimationExport.mjs +173 -0
  28. package/public/js/components/AnimationView.mjs +439 -0
  29. package/public/js/components/ExportView.mjs +3 -8
  30. package/public/js/components/PassageView.mjs +29 -8
  31. package/public/js/components/Timeline.mjs +30 -7
  32. package/public/js/context.mjs +7 -0
  33. package/public/js/days.mjs +10 -0
  34. package/public/js/format.mjs +11 -0
  35. package/public/js/i18n.mjs +129 -0
  36. package/public/js/landmarks.mjs +128 -0
  37. package/public/js/main.mjs +8 -5
  38. package/public/vendor/mediabunny-LICENSE +373 -0
  39. package/public/vendor/mediabunny.min.mjs +50 -0
@@ -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
+ };
@@ -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
- { key: 'position', width: 112 },
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
- ]).then(([i18n, format, lines, weather, tide]) => ({ i18n, format, lines, weather, tide }));
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
- return lines;
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: readingCells(line, context), remarkLines: shown, sketch, height };
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.1.0",
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
+ }
@@ -35,6 +35,7 @@ const SHELL = [
35
35
  '../js/format.mjs',
36
36
  '../js/i18n.mjs',
37
37
  '../js/ids.mjs',
38
+ '../js/landmarks.mjs',
38
39
  '../js/log-lines.mjs',
39
40
  '../js/status.mjs',
40
41
  '../js/components/common.mjs',