signalk-chiplog 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +186 -37
  2. package/README.md +340 -134
  3. package/index.js +80 -40
  4. package/lib/api.js +109 -1
  5. package/lib/background-schedule.js +67 -0
  6. package/lib/crew.js +185 -0
  7. package/lib/database.js +103 -1
  8. package/lib/departure-forecast.js +150 -0
  9. package/lib/detection.js +45 -14
  10. package/lib/entries.js +96 -48
  11. package/lib/event-watcher.js +1 -3
  12. package/lib/events.js +64 -16
  13. package/lib/export.js +20 -1
  14. package/lib/landmark-finder.js +209 -0
  15. package/lib/landmarks.js +210 -0
  16. package/lib/logbook-pdf.js +368 -17
  17. package/lib/observation-recorder.js +11 -5
  18. package/lib/tide-forecaster.js +23 -116
  19. package/lib/weather-forecaster.js +147 -0
  20. package/package.json +2 -1
  21. package/public/app.css +212 -2
  22. package/public/entry/entry.css +71 -0
  23. package/public/entry/js/components/CrewDialog.mjs +200 -0
  24. package/public/entry/js/main.mjs +114 -0
  25. package/public/entry/sw.js +2 -0
  26. package/public/js/animation/camera.mjs +180 -0
  27. package/public/js/animation/formats.mjs +45 -0
  28. package/public/js/animation/mercator.mjs +50 -0
  29. package/public/js/animation/mp4.mjs +77 -0
  30. package/public/js/animation/player.mjs +120 -0
  31. package/public/js/animation/renderer.mjs +326 -0
  32. package/public/js/animation/schedule.mjs +49 -0
  33. package/public/js/animation/storyboard.mjs +195 -0
  34. package/public/js/animation/tiles.mjs +219 -0
  35. package/public/js/animation/timeline.mjs +186 -0
  36. package/public/js/components/AnimationExport.mjs +173 -0
  37. package/public/js/components/AnimationView.mjs +439 -0
  38. package/public/js/components/CrewCard.mjs +22 -0
  39. package/public/js/components/ExportView.mjs +3 -8
  40. package/public/js/components/PassageView.mjs +49 -19
  41. package/public/js/components/Timeline.mjs +30 -7
  42. package/public/js/components/WeatherCard.mjs +134 -0
  43. package/public/js/context.mjs +7 -0
  44. package/public/js/days.mjs +10 -0
  45. package/public/js/format.mjs +15 -0
  46. package/public/js/i18n.mjs +246 -0
  47. package/public/js/landmarks.mjs +128 -0
  48. package/public/js/main.mjs +8 -5
  49. package/public/js/weather.mjs +193 -0
  50. package/public/vendor/mediabunny-LICENSE +373 -0
  51. package/public/vendor/mediabunny.min.mjs +50 -0
@@ -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 },
@@ -46,6 +50,45 @@ const COLUMNS = [
46
50
  const REMARKS_WIDTH =
47
51
  PAGE.width - 2 * PAGE.margin - COLUMNS.reduce((sum, column) => sum + column.width, 0);
48
52
 
53
+ // The weather block (SPEC §4.5.3) is its own row spanning the full page
54
+ // width, laid out as the passage page's table: a time column of its own,
55
+ // then the columns the forecast has data for, sharing the rest of the width
56
+ // by weight -- wind needs more room than pressure.
57
+ const WEATHER_TIME_WIDTH = 46;
58
+ const WEATHER_COLUMNS = [
59
+ { key: 'sky', weight: 2 },
60
+ { key: 'wind', weight: 3 },
61
+ { key: 'waves', weight: 2 },
62
+ { key: 'swell', weight: 2 },
63
+ { key: 'pressure', weight: 1.3 },
64
+ { key: 'visibility', weight: 1.6 },
65
+ { key: 'temperature', weight: 2 },
66
+ { key: 'current', weight: 2 }
67
+ ];
68
+ const WEATHER_TITLE_HEIGHT = 16;
69
+ const WEATHER_ROW_HEIGHT = 2 * LINE_HEIGHT + 4;
70
+
71
+ // The tide and boat-status blocks (SPEC §4.5.1, §4.5.2): two half-width
72
+ // blocks side by side, between the weather block and the day's table -- what
73
+ // the skipper notes before casting off, next to the forecast bulletin.
74
+ const INFO_GAP = 16;
75
+ const INFO_TITLE_HEIGHT = 16;
76
+ const INFO_WIDTH = (PAGE.width - 2 * PAGE.margin - INFO_GAP) / 2;
77
+
78
+ function weatherLayout(columns) {
79
+ const fullWidth = PAGE.width - 2 * PAGE.margin;
80
+ const included = WEATHER_COLUMNS.filter((column) => columns[column.key]);
81
+ const totalWeight = included.reduce((sum, column) => sum + column.weight, 0);
82
+ const layout = [{ key: 'time', x: PAGE.margin, width: WEATHER_TIME_WIDTH }];
83
+ let x = PAGE.margin + WEATHER_TIME_WIDTH;
84
+ for (const column of included) {
85
+ const width = ((fullWidth - WEATHER_TIME_WIDTH) * column.weight) / totalWeight;
86
+ layout.push({ key: column.key, x, width });
87
+ x += width;
88
+ }
89
+ return layout;
90
+ }
91
+
49
92
  const HOUR_MS = 60 * 60 * 1000;
50
93
 
51
94
  // The languages of public/js/i18n.mjs, known synchronously for validation.
@@ -57,9 +100,21 @@ let sharedModules = null;
57
100
  function loadShared() {
58
101
  const load = (file) =>
59
102
  import(pathToFileURL(path.join(__dirname, '..', 'public', 'js', file)).href);
60
- sharedModules ??= Promise.all([load('i18n.mjs'), load('format.mjs'), load('log-lines.mjs')]).then(
61
- ([i18n, format, lines]) => ({ i18n, format, lines })
62
- );
103
+ sharedModules ??= Promise.all([
104
+ load('i18n.mjs'),
105
+ load('format.mjs'),
106
+ load('log-lines.mjs'),
107
+ load('weather.mjs'),
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
+ }));
63
118
  return sharedModules;
64
119
  }
65
120
 
@@ -101,15 +156,126 @@ function propulsionAt(segments, time) {
101
156
  return segment ? segment.type : null;
102
157
  }
103
158
 
159
+ // The forecast fetched at departure, as the skipper notes the bulletin before
160
+ // casting off: a block of its own, above the day's table (SPEC §4.5.3) --
161
+ // not a row mixed into the events and observations it lists.
162
+ function passageWeatherBlock(bundle, context) {
163
+ const { t, format } = context;
164
+ const { entry, weather } = bundle;
165
+ const steps = weather ? context.forecastSteps(weather.points, 3) : [];
166
+ if (steps.length === 0) {
167
+ return null;
168
+ }
169
+ const rows = steps.map((step, index) => ({
170
+ time: {
171
+ main: format.time(step.time),
172
+ sub:
173
+ index === 0 || format.dayKey(step.time) !== format.dayKey(steps[index - 1].time)
174
+ ? format.shortDate(step.time)
175
+ : null
176
+ },
177
+ cells: context.describeStepColumns(step, { t, format })
178
+ }));
179
+ return {
180
+ dayKey: format.dayKey(entry.startTime),
181
+ title: t('passage.weather', { place: entry.startPlaceName ?? t('place.unknown') }),
182
+ layout: weatherLayout(context.weatherColumns(steps)),
183
+ rows,
184
+ height: WEATHER_TITLE_HEIGHT + TABLE_HEAD_HEIGHT + rows.length * WEATHER_ROW_HEIGHT
185
+ };
186
+ }
187
+
188
+ // The tide extremes fetched near departure (SPEC §4.5.2), as a line per high
189
+ // or low -- the PDF has no room for the passage page's curve.
190
+ function tideLines(bundle, context) {
191
+ const { t, format } = context;
192
+ const extremes = bundle.tide ? context.tideExtremes(bundle.tide.points) : [];
193
+ if (extremes.length === 0) {
194
+ return null;
195
+ }
196
+ return extremes.map((extreme, index) => {
197
+ const label = extreme.type === 'high' ? t('tide.high') : t('tide.low');
198
+ const when =
199
+ index === 0 || format.dayKey(extreme.time) !== format.dayKey(extremes[index - 1].time)
200
+ ? `${format.shortDate(extreme.time)} ${format.time(extreme.time)}`
201
+ : format.time(extreme.time);
202
+ return `${label} ${when} — ${format.depth(extreme.height)}`;
203
+ });
204
+ }
205
+
206
+ // The tanks and batteries noted as the passage opened (SPEC §4.5.1) -- not
207
+ // the engine hours, already on the departure line.
208
+ function statusLines(bundle, context) {
209
+ const { t, format } = context;
210
+ const tanks = bundle.entry.startTanks ?? [];
211
+ const batteries = bundle.entry.startBatteries ?? [];
212
+ if (tanks.length + batteries.length === 0) {
213
+ return null;
214
+ }
215
+ const tankLine = (item) =>
216
+ `${context.tankName(item, t, tanks)}: ${[format.percent(item.level), format.volume(item.volume)]
217
+ .filter(Boolean)
218
+ .join(' · ')}`;
219
+ const batteryLine = (item) =>
220
+ `${context.batteryName(item, t)}: ${[
221
+ format.percent(item.stateOfCharge),
222
+ format.voltage(item.voltage),
223
+ format.current(item.current)
224
+ ]
225
+ .filter(Boolean)
226
+ .join(' · ')}`;
227
+ return [...tanks.map(tankLine), ...batteries.map(batteryLine)];
228
+ }
229
+
230
+ // The tide and boat-status blocks, side by side between the weather block
231
+ // and the day's table -- either may be missing (no forecast yet, no sensor
232
+ // aboard), in which case only the other is drawn.
233
+ function passageInfoRow(bundle, context) {
234
+ const { t, format } = context;
235
+ const tide = tideLines(bundle, context);
236
+ const status = statusLines(bundle, context);
237
+ if (!tide && !status) {
238
+ return null;
239
+ }
240
+ const blockOf = (title, lines) =>
241
+ lines && {
242
+ title,
243
+ lines,
244
+ height: INFO_TITLE_HEIGHT + lines.length * LINE_HEIGHT + CELL_PADDING
245
+ };
246
+ const left = blockOf(t('passage.tide'), tide);
247
+ const right = blockOf(t('passage.boatState'), status);
248
+ return {
249
+ dayKey: format.dayKey(bundle.entry.startTime),
250
+ left,
251
+ right,
252
+ height: Math.max(left?.height ?? 0, right?.height ?? 0)
253
+ };
254
+ }
255
+
104
256
  function passageLines(bundle, context) {
105
257
  const { t, format, buildRows, describeEvent, engineHours, engineName, manoeuvreLabels } = context;
106
258
  const { entry, events, observations, propulsion } = bundle;
107
259
  const place = (name) => name ?? t('place.unknown');
108
- // Readings taken at departure and arrival belong on those lines.
109
- const startReadings = observations.find((observation) => observation.reason === 'entry_start');
110
- const endReadings = observations.find((observation) => observation.reason === 'entry_end');
260
+ // Readings taken at departure and arrival belong on those lines. A passage
261
+ // that set off again after a stopover has a departure reading for each start.
262
+ const byTime = (a, b) => a.time.localeCompare(b.time) || a.id - b.id;
263
+ const [startReadings, ...resumeReadings] = observations
264
+ .filter((observation) => observation.reason === 'entry_start')
265
+ .sort(byTime);
266
+ // The last arrival reading, once the passage has arrived: an earlier one was
267
+ // taken at a stopover and stays a line of its own.
268
+ const endReadings = entry.endTime
269
+ ? observations
270
+ .filter((observation) => observation.reason === 'entry_end')
271
+ .sort(byTime)
272
+ .at(-1)
273
+ : undefined;
111
274
  const otherReadings = observations.filter(
112
- (observation) => observation !== startReadings && observation !== endReadings
275
+ (observation) =>
276
+ observation !== startReadings &&
277
+ observation !== endReadings &&
278
+ !resumeReadings.includes(observation)
113
279
  );
114
280
  // Engine hour counters, as a paper log notes them at departure and arrival:
115
281
  // named when there are several engines, with the hours run at arrival.
@@ -128,6 +294,38 @@ function passageLines(bundle, context) {
128
294
  .join(', ');
129
295
  return { text: t('pdf.engineHours', { hours: list }), newLine: true };
130
296
  };
297
+ // Who was aboard (SPEC §4.11), on the true departure line only -- a
298
+ // passage's crew does not change at a mid-passage stopover.
299
+ const crewText = () => {
300
+ const list = bundle.crew ?? [];
301
+ if (list.length === 0) {
302
+ return null;
303
+ }
304
+ const names = list
305
+ .map((member) => (member.role ? `${member.name} (${member.role})` : member.name))
306
+ .join(', ');
307
+ return { text: t('pdf.crew', { crew: names }), newLine: true };
308
+ };
309
+ // The counters as they stood at one reading, for a departure after a stopover.
310
+ const hoursAt = (readings) => {
311
+ const runtimes = Object.entries(readings.engineRuntimes ?? {});
312
+ if (runtimes.length === 0) {
313
+ return null;
314
+ }
315
+ const list = runtimes
316
+ .map(([id, runtime]) => {
317
+ const name = runtimes.length > 1 ? `${engineName(id, t)} ` : '';
318
+ return `${name}${format.hours(runtime)}`;
319
+ })
320
+ .join(', ');
321
+ return { text: t('pdf.engineHours', { hours: list }), newLine: true };
322
+ };
323
+ // Where a departure after a stopover left from: the stopover before it.
324
+ const stopoverBefore = (time) =>
325
+ events
326
+ .filter((event) => event.type === 'stopover' && event.time <= time)
327
+ .sort((a, b) => a.time.localeCompare(b.time))
328
+ .at(-1);
131
329
  const lines = [
132
330
  {
133
331
  time: entry.startTime,
@@ -139,11 +337,32 @@ function passageLines(bundle, context) {
139
337
  position: entry.startPosition,
140
338
  remarks: [
141
339
  { text: t('pdf.departure', { place: place(entry.startPlaceName) }), font: 'bold' },
142
- hoursText('start')
340
+ hoursText('start'),
341
+ crewText()
143
342
  ].filter(Boolean)
144
343
  }
145
344
  ];
146
345
 
346
+ for (const readings of resumeReadings) {
347
+ const stopover = stopoverBefore(readings.time);
348
+ lines.push({
349
+ time: readings.time,
350
+ order: 1,
351
+ entry,
352
+ kind: 'departure',
353
+ readings,
354
+ propulsion: propulsionAt(propulsion, readings.time),
355
+ position: readings.position ?? stopover?.position ?? null,
356
+ remarks: [
357
+ {
358
+ text: t('pdf.departure', { place: place(stopover?.payload?.placeName) }),
359
+ font: 'bold'
360
+ },
361
+ hoursAt(readings)
362
+ ].filter(Boolean)
363
+ });
364
+ }
365
+
147
366
  for (const row of buildRows(events, otherReadings)) {
148
367
  const remarks = [];
149
368
  let strokes = null;
@@ -204,7 +423,14 @@ function passageLines(bundle, context) {
204
423
  ].filter(Boolean)
205
424
  });
206
425
  }
207
- 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
+ }));
208
434
  }
209
435
 
210
436
  // Distance and engine/sail time of each calendar day, across all passages.
@@ -307,6 +533,7 @@ function readingCells(line, context) {
307
533
  const cells = {
308
534
  time: line.time ? format.time(line.time) : '',
309
535
  position: format.position(line.position ?? null),
536
+ positionNote: line.landmark ?? '',
310
537
  course: '',
311
538
  speed: format.speed(readings?.sog),
312
539
  wind: '',
@@ -328,8 +555,16 @@ function readingCells(line, context) {
328
555
  return cells;
329
556
  }
330
557
 
558
+ const POSITION_WIDTH = COLUMNS.find((column) => column.key === 'position').width;
559
+
331
560
  function measure(line, context) {
332
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
+ : [];
333
568
  const sketch = sketchBox(line.strokes);
334
569
  const maxLines = Math.floor(
335
570
  (BODY_BOTTOM -
@@ -346,24 +581,29 @@ function measure(line, context) {
346
581
  }
347
582
  const height =
348
583
  2 * CELL_PADDING +
349
- Math.max(1, shown.length) * LINE_HEIGHT +
584
+ Math.max(1, shown.length, 1 + positionLines.length) * LINE_HEIGHT +
350
585
  (sketch ? sketch.drawnHeight + (shown.length > 0 ? 2 : 0) : 0);
351
- return { ...line, cells: readingCells(line, context), remarkLines: shown, sketch, height };
586
+ return { ...line, cells, remarkLines: shown, positionLines, sketch, height };
352
587
  }
353
588
 
354
589
  // Pages as lists of measured rows; nothing is drawn yet, so the page count is
355
- // known for the footers.
590
+ // known for the footers. A day's weather and info blocks only take up room on
591
+ // its first page, above the table -- not repeated on a page it continues onto.
356
592
  function paginate(days, context) {
357
593
  const pages = [];
358
- const available = BODY_BOTTOM - HEADER_BOTTOM - TABLE_HEAD_HEIGHT;
594
+ const baseAvailable = BODY_BOTTOM - HEADER_BOTTOM - TABLE_HEAD_HEIGHT;
359
595
  for (const day of days) {
596
+ const weatherHeight = (day.weatherBlocks ?? []).reduce((sum, block) => sum + block.height, 0);
597
+ const infoHeight = (day.infoRows ?? []).reduce((sum, row) => sum + row.height, 0);
360
598
  let page = { day, continued: false, rows: [], used: 0 };
361
599
  pages.push(page);
600
+ let available = baseAvailable - weatherHeight - infoHeight;
362
601
  for (const line of day.lines) {
363
602
  const row = measure(line, context);
364
603
  if (page.used + row.height > available && page.rows.length > 0) {
365
604
  page = { day, continued: true, rows: [], used: 0 };
366
605
  pages.push(page);
606
+ available = baseAvailable;
367
607
  }
368
608
  page.rows.push(row);
369
609
  page.used += row.height;
@@ -403,6 +643,14 @@ function drawRow(page, row, top) {
403
643
  if (text) {
404
644
  page.text(x + CELL_PADDING, baseline, text, { size: FONT_SIZE });
405
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
+ }
406
654
  x += column.width;
407
655
  }
408
656
  row.remarkLines.forEach((words, index) => {
@@ -438,6 +686,68 @@ function drawRow(page, row, top) {
438
686
  });
439
687
  }
440
688
 
689
+ // The weather block: a title naming the departure place, then a table of its
690
+ // own spanning the full width -- the same shape as the passage page's, drawn
691
+ // above the day's table rather than as one of its rows.
692
+ function drawWeatherRow(page, block, top, t) {
693
+ page.text(PAGE.margin, top + 11, block.title, { font: 'bold', size: 9 });
694
+ let y = top + WEATHER_TITLE_HEIGHT;
695
+ page.rect(PAGE.margin, y, PAGE.width - 2 * PAGE.margin, TABLE_HEAD_HEIGHT, {
696
+ fill: HEAD_FILL,
697
+ stroke: RULE
698
+ });
699
+ for (const column of block.layout) {
700
+ page.text(column.x + CELL_PADDING, y + 11, t(`weather.${column.key}`), {
701
+ font: 'bold',
702
+ size: 7.5
703
+ });
704
+ }
705
+ y += TABLE_HEAD_HEIGHT;
706
+ for (const dataRow of block.rows) {
707
+ const baseline = y + CELL_PADDING + FONT_SIZE;
708
+ for (const column of block.layout) {
709
+ const value = column.key === 'time' ? dataRow.time : dataRow.cells[column.key];
710
+ if (value?.main) {
711
+ page.text(column.x + CELL_PADDING, baseline, value.main, { size: FONT_SIZE });
712
+ }
713
+ if (value?.sub) {
714
+ page.text(column.x + CELL_PADDING, baseline + LINE_HEIGHT, value.sub, {
715
+ size: FONT_SIZE,
716
+ color: GREY
717
+ });
718
+ }
719
+ }
720
+ y += WEATHER_ROW_HEIGHT;
721
+ }
722
+ page.line(PAGE.margin, top + block.height, PAGE.width - PAGE.margin, top + block.height, {
723
+ color: RULE,
724
+ width: 0.4
725
+ });
726
+ }
727
+
728
+ // One of the two info blocks: a title, then a line per tide extreme or per
729
+ // tank/battery -- plain text, unlike the weather block's table.
730
+ function drawInfoBlock(page, block, x, top) {
731
+ if (!block) {
732
+ return;
733
+ }
734
+ page.text(x, top + 11, block.title, { font: 'bold', size: 9 });
735
+ const baseline = top + INFO_TITLE_HEIGHT + CELL_PADDING + FONT_SIZE;
736
+ block.lines.forEach((text, index) => {
737
+ page.text(x, baseline + index * LINE_HEIGHT, text, { size: FONT_SIZE });
738
+ });
739
+ }
740
+
741
+ // The tide and boat-status blocks, side by side (SPEC §4.5.1, §4.5.2).
742
+ function drawInfoRow(page, row, top) {
743
+ drawInfoBlock(page, row.left, PAGE.margin, top);
744
+ drawInfoBlock(page, row.right, PAGE.margin + INFO_WIDTH + INFO_GAP, top);
745
+ page.line(PAGE.margin, top + row.height, PAGE.width - PAGE.margin, top + row.height, {
746
+ color: RULE,
747
+ width: 0.4
748
+ });
749
+ }
750
+
441
751
  function capitalise(text) {
442
752
  return text.charAt(0).toUpperCase() + text.slice(1);
443
753
  }
@@ -446,7 +756,14 @@ async function renderLogbookPdf(
446
756
  bundles,
447
757
  { language = 'en', timeZone, vesselName = null, now, version = '' }
448
758
  ) {
449
- const { i18n, format: formatModule, lines: lineModule } = await loadShared();
759
+ const {
760
+ i18n,
761
+ format: formatModule,
762
+ lines: lineModule,
763
+ weather: weatherModule,
764
+ tide: tideModule,
765
+ landmarks: landmarkModule
766
+ } = await loadShared();
450
767
  const t = i18n.createTranslator(language);
451
768
  const format = formatModule.createFormatter({
452
769
  locale: language,
@@ -462,6 +779,13 @@ async function renderLogbookPdf(
462
779
  describeEvent: lineModule.describeEvent,
463
780
  engineHours: lineModule.engineHours,
464
781
  engineName: lineModule.engineName,
782
+ forecastSteps: weatherModule.forecastSteps,
783
+ weatherColumns: weatherModule.weatherColumns,
784
+ describeStepColumns: weatherModule.describeStepColumns,
785
+ tideExtremes: tideModule.tideExtremes,
786
+ tankName: lineModule.tankName,
787
+ batteryName: lineModule.batteryName,
788
+ landmarkLine: landmarkModule.landmarkLine,
465
789
  manoeuvreLabels: {}
466
790
  };
467
791
 
@@ -497,6 +821,22 @@ async function renderLogbookPdf(
497
821
  }
498
822
  days.at(-1).lines.push(line);
499
823
  }
824
+
825
+ // Each passage's weather and info blocks belong on the day it departed,
826
+ // above that day's table rather than mixed into it.
827
+ for (const bundle of bundles) {
828
+ const block = passageWeatherBlock(bundle, context);
829
+ const day = block && days.find((candidate) => candidate.key === block.dayKey);
830
+ if (day) {
831
+ (day.weatherBlocks ??= []).push(block);
832
+ }
833
+ const info = passageInfoRow(bundle, context);
834
+ const infoDay = info && days.find((candidate) => candidate.key === info.dayKey);
835
+ if (infoDay) {
836
+ (infoDay.infoRows ??= []).push(info);
837
+ }
838
+ }
839
+
500
840
  for (const day of days) {
501
841
  const total = totals.get(day.key) ?? { distance: 0, engine: 0, sail: 0 };
502
842
  day.lines.push({
@@ -567,8 +907,19 @@ async function renderLogbookPdf(
567
907
  size: 13
568
908
  }
569
909
  );
570
- drawTableHead(page, t, HEADER_BOTTOM);
571
- let top = HEADER_BOTTOM + TABLE_HEAD_HEIGHT;
910
+ let top = HEADER_BOTTOM;
911
+ if (!layout.continued) {
912
+ for (const block of layout.day.weatherBlocks ?? []) {
913
+ drawWeatherRow(page, block, top, t);
914
+ top += block.height;
915
+ }
916
+ for (const row of layout.day.infoRows ?? []) {
917
+ drawInfoRow(page, row, top);
918
+ top += row.height;
919
+ }
920
+ }
921
+ drawTableHead(page, t, top);
922
+ top += TABLE_HEAD_HEIGHT;
572
923
  for (const row of layout.rows) {
573
924
  drawRow(page, row, top);
574
925
  top += row.height;
@@ -250,13 +250,13 @@ function createObservationRecorder({ db, readSelfPath, readFresh, settings }) {
250
250
  sense,
251
251
 
252
252
  // Runs inside detection's transaction, with what this cycle did to passages.
253
- afterDetection(now, { opened, closed }) {
253
+ afterDetection(now, { opened, reopened, resumedAt, closed }) {
254
254
  if (closed && !closed.late) {
255
255
  // Dated from the actual end of the passage, not from this tick that
256
- // only found out about it up to stopClosureMinutes later -- a merge's
257
- // stopover event (SPEC §3.1) needs to sort after this reading, not
258
- // before it, and readings taken since the boat has been still say the
259
- // same thing regardless.
256
+ // found out about it once detection saw the stop -- a stopover event
257
+ // (SPEC §3.1) needs to sort after this reading, not before it, and
258
+ // readings taken since the boat has been still say the same thing
259
+ // regardless.
260
260
  record(closed.id, 'entry_end', closed.endTime);
261
261
  }
262
262
  if (opened) {
@@ -264,6 +264,12 @@ function createObservationRecorder({ db, readSelfPath, readFresh, settings }) {
264
264
  noteBoatState(opened);
265
265
  return;
266
266
  }
267
+ // A passage reopened after a short stop sets off again: its departure
268
+ // reading is dated from when it did, like the propulsion segment.
269
+ if (reopened) {
270
+ record(reopened, 'entry_start', iso(resumedAt));
271
+ return;
272
+ }
267
273
  const entry = db
268
274
  .prepare("SELECT id, start_time FROM log_entries WHERE state = 'active'")
269
275
  .get();