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.
- package/CHANGELOG.md +186 -37
- package/README.md +340 -134
- package/index.js +80 -40
- package/lib/api.js +109 -1
- package/lib/background-schedule.js +67 -0
- package/lib/crew.js +185 -0
- package/lib/database.js +103 -1
- package/lib/departure-forecast.js +150 -0
- package/lib/detection.js +45 -14
- package/lib/entries.js +96 -48
- package/lib/event-watcher.js +1 -3
- package/lib/events.js +64 -16
- package/lib/export.js +20 -1
- package/lib/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +368 -17
- package/lib/observation-recorder.js +11 -5
- package/lib/tide-forecaster.js +23 -116
- package/lib/weather-forecaster.js +147 -0
- package/package.json +2 -1
- package/public/app.css +212 -2
- package/public/entry/entry.css +71 -0
- package/public/entry/js/components/CrewDialog.mjs +200 -0
- package/public/entry/js/main.mjs +114 -0
- package/public/entry/sw.js +2 -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/CrewCard.mjs +22 -0
- package/public/js/components/ExportView.mjs +3 -8
- package/public/js/components/PassageView.mjs +49 -19
- package/public/js/components/Timeline.mjs +30 -7
- package/public/js/components/WeatherCard.mjs +134 -0
- package/public/js/context.mjs +7 -0
- package/public/js/days.mjs +10 -0
- package/public/js/format.mjs +15 -0
- package/public/js/i18n.mjs +246 -0
- package/public/js/landmarks.mjs +128 -0
- package/public/js/main.mjs +8 -5
- package/public/js/weather.mjs +193 -0
- package/public/vendor/mediabunny-LICENSE +373 -0
- package/public/vendor/mediabunny.min.mjs +50 -0
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 },
|
|
@@ -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([
|
|
61
|
-
(
|
|
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
|
-
|
|
110
|
-
const
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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();
|