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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { html, useState } from '../../../vendor/preact-htm.mjs';
|
|
2
|
+
import { useLocale } from '../../../js/context.mjs';
|
|
3
|
+
import { PencilIcon, TrashIcon } from './Icons.mjs';
|
|
4
|
+
|
|
5
|
+
// .big-button is a row flex container, so a bare <br/> between its children
|
|
6
|
+
// would not stack them -- wrap name and role in their own column instead.
|
|
7
|
+
function CrewButtonLabel({ name, role }) {
|
|
8
|
+
return html`<span class="crew-button-label"
|
|
9
|
+
>${name}${role ? html`<span class="crew-role">${role}</span>` : ''}</span
|
|
10
|
+
>`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// roster: the global crew list ({id, name, role}); current: this passage's
|
|
14
|
+
// crew as GET /entries/:id returns it ({id, crewMemberId, name, role}).
|
|
15
|
+
// onAddMember(name, role) creates a roster member (POST /crew) and picks it;
|
|
16
|
+
// onEditMember(id, {name, role}) and onDeleteMember(id) correct or remove one
|
|
17
|
+
// (PATCH/DELETE /crew/:id) -- both act on the roster itself, not just this
|
|
18
|
+
// passage's assignment, which is only sent to the server on Enregistrer.
|
|
19
|
+
export function CrewDialog({
|
|
20
|
+
roster,
|
|
21
|
+
current,
|
|
22
|
+
onCancel,
|
|
23
|
+
onSave,
|
|
24
|
+
onAddMember,
|
|
25
|
+
onEditMember,
|
|
26
|
+
onDeleteMember
|
|
27
|
+
}) {
|
|
28
|
+
const { t } = useLocale();
|
|
29
|
+
const [picked, setPicked] = useState(
|
|
30
|
+
() => new Set((current ?? []).filter((m) => m.crewMemberId !== null).map((m) => m.crewMemberId))
|
|
31
|
+
);
|
|
32
|
+
const [name, setName] = useState('');
|
|
33
|
+
const [role, setRole] = useState('');
|
|
34
|
+
const [editingId, setEditingId] = useState(null);
|
|
35
|
+
|
|
36
|
+
const toggle = (id) => {
|
|
37
|
+
setPicked((prev) => {
|
|
38
|
+
const next = new Set(prev);
|
|
39
|
+
if (next.has(id)) {
|
|
40
|
+
next.delete(id);
|
|
41
|
+
} else {
|
|
42
|
+
next.add(id);
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const startEditing = (member) => {
|
|
49
|
+
setEditingId(member.id);
|
|
50
|
+
setName(member.name);
|
|
51
|
+
setRole(member.role ?? '');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const cancelEditing = () => {
|
|
55
|
+
setEditingId(null);
|
|
56
|
+
setName('');
|
|
57
|
+
setRole('');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const addNew = async (event) => {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
const trimmedName = name.trim();
|
|
63
|
+
if (!trimmedName) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const member = await onAddMember(trimmedName, role.trim() || null);
|
|
67
|
+
if (member) {
|
|
68
|
+
setPicked((prev) => new Set(prev).add(member.id));
|
|
69
|
+
setName('');
|
|
70
|
+
setRole('');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const saveEdit = (event) => {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
if (!name.trim()) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
onEditMember(editingId, { name: name.trim(), role: role.trim() || null });
|
|
80
|
+
setEditingId(null);
|
|
81
|
+
setName('');
|
|
82
|
+
setRole('');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const removeMember = async (member) => {
|
|
86
|
+
if (!confirm(t('entry.deleteCrewMemberConfirm', { name: member.name }))) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const removed = await onDeleteMember(member.id);
|
|
90
|
+
if (!removed) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setPicked((prev) => {
|
|
94
|
+
const next = new Set(prev);
|
|
95
|
+
next.delete(member.id);
|
|
96
|
+
return next;
|
|
97
|
+
});
|
|
98
|
+
if (editingId === member.id) {
|
|
99
|
+
cancelEditing();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// The name/role fields sit inside the dialog's own <form>, so Enter would
|
|
104
|
+
// otherwise submit that -- saving and closing the whole dialog -- instead
|
|
105
|
+
// of adding or saving this one member.
|
|
106
|
+
const onFieldKeyDown = (event) => {
|
|
107
|
+
if (event.key !== 'Enter') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
(editingId === null ? addNew : saveEdit)(event);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const submit = (event) => {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
onSave([...picked].map((crewMemberId) => ({ crewMemberId })));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const editingMember = roster.find((member) => member.id === editingId) ?? null;
|
|
119
|
+
|
|
120
|
+
return html`
|
|
121
|
+
<div class="sheet-backdrop" onClick=${(event) => event.target === event.currentTarget && onCancel()}>
|
|
122
|
+
<form class="sheet" role="dialog" aria-modal="true" aria-labelledby="crew-title" onSubmit=${submit}>
|
|
123
|
+
<h2 id="crew-title">${t('entry.editCrew')}</h2>
|
|
124
|
+
<div class="crew-grid">
|
|
125
|
+
${roster.map(
|
|
126
|
+
(member) =>
|
|
127
|
+
html`<div class="crew-item" key=${member.id}>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class=${picked.has(member.id) ? 'big-button primary' : 'big-button'}
|
|
131
|
+
onClick=${() => toggle(member.id)}
|
|
132
|
+
>
|
|
133
|
+
<${CrewButtonLabel} name=${member.name} role=${member.role} />
|
|
134
|
+
</button>
|
|
135
|
+
<div class="crew-item-actions">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
class="tool-button icon-button"
|
|
139
|
+
aria-label=${t('entry.editCrewMember', { name: member.name })}
|
|
140
|
+
title=${t('common.edit')}
|
|
141
|
+
onClick=${() => startEditing(member)}
|
|
142
|
+
>
|
|
143
|
+
<${PencilIcon} />
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
class="tool-button icon-button danger"
|
|
148
|
+
aria-label=${t('entry.deleteCrewMember', { name: member.name })}
|
|
149
|
+
title=${t('entry.delete')}
|
|
150
|
+
onClick=${() => removeMember(member)}
|
|
151
|
+
>
|
|
152
|
+
<${TrashIcon} />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>`
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
${
|
|
159
|
+
editingMember &&
|
|
160
|
+
html`<p class="crew-editing-note">
|
|
161
|
+
${t('entry.editingCrewMember', { name: editingMember.name })}
|
|
162
|
+
<button type="button" class="link-button" onClick=${cancelEditing}>
|
|
163
|
+
${t('common.cancel')}
|
|
164
|
+
</button>
|
|
165
|
+
</p>`
|
|
166
|
+
}
|
|
167
|
+
<div class="sheet-row">
|
|
168
|
+
<input
|
|
169
|
+
value=${name}
|
|
170
|
+
maxlength="100"
|
|
171
|
+
placeholder=${t('entry.crewName')}
|
|
172
|
+
aria-label=${t('entry.crewName')}
|
|
173
|
+
onInput=${(event) => setName(event.currentTarget.value)}
|
|
174
|
+
onKeyDown=${onFieldKeyDown}
|
|
175
|
+
/>
|
|
176
|
+
<input
|
|
177
|
+
value=${role}
|
|
178
|
+
maxlength="100"
|
|
179
|
+
placeholder=${t('entry.crewRole')}
|
|
180
|
+
aria-label=${t('entry.crewRole')}
|
|
181
|
+
onInput=${(event) => setRole(event.currentTarget.value)}
|
|
182
|
+
onKeyDown=${onFieldKeyDown}
|
|
183
|
+
/>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
class="big-button"
|
|
187
|
+
disabled=${!name.trim()}
|
|
188
|
+
onClick=${editingId === null ? addNew : saveEdit}
|
|
189
|
+
>
|
|
190
|
+
${editingId === null ? t('entry.addCrewMember') : t('common.save')}
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="sheet-row">
|
|
194
|
+
<button type="button" class="big-button" onClick=${onCancel}>${t('common.cancel')}</button>
|
|
195
|
+
<button type="submit" class="big-button primary">${t('common.save')}</button>
|
|
196
|
+
</div>
|
|
197
|
+
</form>
|
|
198
|
+
</div>
|
|
199
|
+
`;
|
|
200
|
+
}
|
package/public/entry/js/main.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { createServerClock } from './clock.mjs';
|
|
|
7
7
|
import { createJournal } from './journal.mjs';
|
|
8
8
|
import { AccessGate } from './components/AccessGate.mjs';
|
|
9
9
|
import { CommentDialog, Toast } from './components/Dialogs.mjs';
|
|
10
|
+
import { CrewDialog } from './components/CrewDialog.mjs';
|
|
11
|
+
import { PencilIcon } from './components/Icons.mjs';
|
|
10
12
|
import { ManoeuvrePad } from './components/ManoeuvrePad.mjs';
|
|
11
13
|
import { NotePanel } from './components/NotePanel.mjs';
|
|
12
14
|
import { RecentList } from './components/RecentList.mjs';
|
|
@@ -18,6 +20,7 @@ const FLUSH_INTERVAL_MS = 10 * 1000;
|
|
|
18
20
|
const TOAST_MS = 10 * 1000;
|
|
19
21
|
const NIGHT_KEY = 'chiplog.night';
|
|
20
22
|
const TYPES_KEY = 'chiplog.manoeuvreTypes';
|
|
23
|
+
const CREW_KEY = 'chiplog.crew';
|
|
21
24
|
|
|
22
25
|
// The shortcuts shipped with the plugin, until the server's list has been seen
|
|
23
26
|
// once: an app started with no connection still has its buttons.
|
|
@@ -43,6 +46,15 @@ function knownTypes() {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function knownCrew() {
|
|
50
|
+
try {
|
|
51
|
+
const stored = JSON.parse(readSetting(CREW_KEY) ?? 'null');
|
|
52
|
+
return Array.isArray(stored) ? stored : [];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
function browserStorage() {
|
|
47
59
|
try {
|
|
48
60
|
return window.localStorage;
|
|
@@ -80,11 +92,13 @@ function App({ journal }) {
|
|
|
80
92
|
const [entry, setEntry] = useState(null);
|
|
81
93
|
const [lastEntryId, setLastEntryId] = useState(null);
|
|
82
94
|
const [types, setTypes] = useState(knownTypes);
|
|
95
|
+
const [roster, setRoster] = useState(knownCrew);
|
|
83
96
|
const [online, setOnline] = useState(true);
|
|
84
97
|
const [forbidden, setForbidden] = useState(false);
|
|
85
98
|
const [queue, setQueue] = useState(journal.outbox.snapshot());
|
|
86
99
|
const [toast, setToast] = useState(null);
|
|
87
100
|
const [commenting, setCommenting] = useState(null);
|
|
101
|
+
const [managingCrew, setManagingCrew] = useState(false);
|
|
88
102
|
const [tab, setTab] = useState('note');
|
|
89
103
|
const [night, setNight] = useState(readSetting(NIGHT_KEY) === 'on');
|
|
90
104
|
const [version, setVersion] = useState(0);
|
|
@@ -153,6 +167,21 @@ function App({ journal }) {
|
|
|
153
167
|
[forbidden, online]
|
|
154
168
|
);
|
|
155
169
|
|
|
170
|
+
usePolling(
|
|
171
|
+
(isCurrent) => {
|
|
172
|
+
fetchAll('/crew')
|
|
173
|
+
.then((items) => {
|
|
174
|
+
writeSetting(CREW_KEY, JSON.stringify(items));
|
|
175
|
+
if (isCurrent()) {
|
|
176
|
+
setRoster(items);
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
.catch(noteFailure);
|
|
180
|
+
},
|
|
181
|
+
0,
|
|
182
|
+
[forbidden, online]
|
|
183
|
+
);
|
|
184
|
+
|
|
156
185
|
// Replays the queue while it holds anything, and as soon as the network is back.
|
|
157
186
|
useEffect(() => {
|
|
158
187
|
const flush = async () => {
|
|
@@ -256,6 +285,52 @@ function App({ journal }) {
|
|
|
256
285
|
journal.outbox.remove(ref);
|
|
257
286
|
};
|
|
258
287
|
|
|
288
|
+
const saveCrew = async (members) => {
|
|
289
|
+
try {
|
|
290
|
+
const updated = await request('PUT', `/entries/${entry.id}/crew`, { members });
|
|
291
|
+
setEntry((current) => ({ ...current, crew: updated }));
|
|
292
|
+
setManagingCrew(false);
|
|
293
|
+
refresh();
|
|
294
|
+
} catch (error) {
|
|
295
|
+
noteFailure(error);
|
|
296
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const addCrewMember = async (name, role) => {
|
|
301
|
+
try {
|
|
302
|
+
const member = await request('POST', '/crew', { name, role });
|
|
303
|
+
setRoster((prev) => [...prev, member]);
|
|
304
|
+
return member;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
noteFailure(error);
|
|
307
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const editCrewMember = async (id, patch) => {
|
|
313
|
+
try {
|
|
314
|
+
const updated = await request('PATCH', `/crew/${id}`, patch);
|
|
315
|
+
setRoster((prev) => prev.map((member) => (member.id === id ? updated : member)));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
noteFailure(error);
|
|
318
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const deleteCrewMember = async (id) => {
|
|
323
|
+
try {
|
|
324
|
+
await request('DELETE', `/crew/${id}`);
|
|
325
|
+
setRoster((prev) => prev.filter((member) => member.id !== id));
|
|
326
|
+
return true;
|
|
327
|
+
} catch (error) {
|
|
328
|
+
noteFailure(error);
|
|
329
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
259
334
|
const commentTitle = commenting?.what ?? t('entry.comment');
|
|
260
335
|
|
|
261
336
|
return html`
|
|
@@ -278,6 +353,33 @@ function App({ journal }) {
|
|
|
278
353
|
}}
|
|
279
354
|
/>`
|
|
280
355
|
}
|
|
356
|
+
<section class="panel crew-panel" aria-labelledby="crew-panel-title">
|
|
357
|
+
<div class="panel-header">
|
|
358
|
+
<h2 id="crew-panel-title" class="panel-title">${t('entry.crew')}</h2>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
class="tool-button icon-button"
|
|
362
|
+
aria-label=${t('entry.editCrew')}
|
|
363
|
+
title=${t('entry.editCrew')}
|
|
364
|
+
disabled=${!entry}
|
|
365
|
+
onClick=${() => setManagingCrew(true)}
|
|
366
|
+
>
|
|
367
|
+
<${PencilIcon} />
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
${
|
|
371
|
+
entry?.crew?.length > 0
|
|
372
|
+
? html`<ul class="crew-list">
|
|
373
|
+
${entry.crew.map(
|
|
374
|
+
(member) =>
|
|
375
|
+
html`<li key=${member.id}>
|
|
376
|
+
${member.name}${member.role ? html` <span class="crew-role">${member.role}</span>` : ''}
|
|
377
|
+
</li>`
|
|
378
|
+
)}
|
|
379
|
+
</ul>`
|
|
380
|
+
: html`<p class="hint">${t('entry.noCrew')}</p>`
|
|
381
|
+
}
|
|
382
|
+
</section>
|
|
281
383
|
<div class="entry-columns">
|
|
282
384
|
<${ManoeuvrePad}
|
|
283
385
|
types=${types}
|
|
@@ -331,6 +433,18 @@ function App({ journal }) {
|
|
|
331
433
|
onSave=${saveComment}
|
|
332
434
|
/>`
|
|
333
435
|
}
|
|
436
|
+
${
|
|
437
|
+
managingCrew &&
|
|
438
|
+
html`<${CrewDialog}
|
|
439
|
+
roster=${roster}
|
|
440
|
+
current=${entry?.crew ?? []}
|
|
441
|
+
onCancel=${() => setManagingCrew(false)}
|
|
442
|
+
onSave=${saveCrew}
|
|
443
|
+
onAddMember=${addCrewMember}
|
|
444
|
+
onEditMember=${editCrewMember}
|
|
445
|
+
onDeleteMember=${deleteCrewMember}
|
|
446
|
+
/>`
|
|
447
|
+
}
|
|
334
448
|
`;
|
|
335
449
|
}
|
|
336
450
|
|
package/public/entry/sw.js
CHANGED
|
@@ -19,6 +19,7 @@ const SHELL = [
|
|
|
19
19
|
'js/outbox.mjs',
|
|
20
20
|
'js/strokes.mjs',
|
|
21
21
|
'js/components/AccessGate.mjs',
|
|
22
|
+
'js/components/CrewDialog.mjs',
|
|
22
23
|
'js/components/Dialogs.mjs',
|
|
23
24
|
'js/components/Icons.mjs',
|
|
24
25
|
'js/components/ManoeuvrePad.mjs',
|
|
@@ -34,6 +35,7 @@ const SHELL = [
|
|
|
34
35
|
'../js/format.mjs',
|
|
35
36
|
'../js/i18n.mjs',
|
|
36
37
|
'../js/ids.mjs',
|
|
38
|
+
'../js/landmarks.mjs',
|
|
37
39
|
'../js/log-lines.mjs',
|
|
38
40
|
'../js/status.mjs',
|
|
39
41
|
'../js/components/common.mjs',
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Framing the animation: how far to zoom out for a passage, and which tiles a
|
|
2
|
+
// frame needs. Pure functions over numbers — no canvas, no vendor imports.
|
|
3
|
+
|
|
4
|
+
import { TILE_SIZE, worldScale, worldX, worldY } from './mercator.mjs';
|
|
5
|
+
|
|
6
|
+
export const MIN_ZOOM = 3;
|
|
7
|
+
export const MAX_ZOOM = 19;
|
|
8
|
+
|
|
9
|
+
// Zoom is chosen on a quarter-level grid: finer than that is invisible, and a
|
|
10
|
+
// coarse grid keeps the choice stable when a track grows by a point.
|
|
11
|
+
export const ZOOM_STEP = 0.25;
|
|
12
|
+
|
|
13
|
+
// How much of the frame the track is allowed to fill, leaving a margin so the
|
|
14
|
+
// boat never rides the edge.
|
|
15
|
+
export const FIT_FRACTION = 0.82;
|
|
16
|
+
|
|
17
|
+
// Half the side of the reference box, in degrees of latitude: the box spans
|
|
18
|
+
// about 32 km, which is the working scale the camera animates at. It stops a
|
|
19
|
+
// hop across a harbour from diving to street level (SPEC §4.12).
|
|
20
|
+
export const REFERENCE_HALF_SPAN_DEGREES = 0.143;
|
|
21
|
+
|
|
22
|
+
// And how far outside that working scale a long passage may pull the camera.
|
|
23
|
+
// Without a limit the camera backs off until the whole track fits — but it is
|
|
24
|
+
// centred on the boat, not on the track, so most of the frame is then empty
|
|
25
|
+
// water the boat crawls across. One level buys some context and no more.
|
|
26
|
+
export const MAX_WIDEN_LEVELS = 1;
|
|
27
|
+
|
|
28
|
+
// Bounds of a track, with its centre. Longitudes are expected already
|
|
29
|
+
// unwrapped (see `unwrapLongitude`), so they may sit outside [-180, 180] and
|
|
30
|
+
// the span stays the real one rather than a trip around the world.
|
|
31
|
+
export function trackBounds(points) {
|
|
32
|
+
let minLat = Infinity;
|
|
33
|
+
let maxLat = -Infinity;
|
|
34
|
+
let minLon = Infinity;
|
|
35
|
+
let maxLon = -Infinity;
|
|
36
|
+
for (const { lat, lon } of points) {
|
|
37
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
minLat = Math.min(minLat, lat);
|
|
41
|
+
maxLat = Math.max(maxLat, lat);
|
|
42
|
+
minLon = Math.min(minLon, lon);
|
|
43
|
+
maxLon = Math.max(maxLon, lon);
|
|
44
|
+
}
|
|
45
|
+
if (minLat === Infinity) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
minLat,
|
|
50
|
+
maxLat,
|
|
51
|
+
minLon,
|
|
52
|
+
maxLon,
|
|
53
|
+
centreLat: (minLat + maxLat) / 2,
|
|
54
|
+
centreLon: (minLon + maxLon) / 2
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A box that is square *on screen*: a degree of longitude is shorter than a
|
|
59
|
+
// degree of latitude away from the equator, so the longitude span is widened by
|
|
60
|
+
// 1 / cos(lat). The fit below then depends only on the shorter side of the
|
|
61
|
+
// frame, never on its aspect ratio.
|
|
62
|
+
export function referenceBounds({ lat, lon }, halfSpan = REFERENCE_HALF_SPAN_DEGREES) {
|
|
63
|
+
const lonHalf = halfSpan / Math.max(0.01, Math.cos((lat * Math.PI) / 180));
|
|
64
|
+
return {
|
|
65
|
+
minLat: lat - halfSpan,
|
|
66
|
+
maxLat: lat + halfSpan,
|
|
67
|
+
minLon: lon - lonHalf,
|
|
68
|
+
maxLon: lon + lonHalf,
|
|
69
|
+
centreLat: lat,
|
|
70
|
+
centreLon: lon
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The closest zoom at which the box still fits the frame with its margin.
|
|
75
|
+
export function fitZoom(bounds, options) {
|
|
76
|
+
const {
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
fraction = FIT_FRACTION,
|
|
80
|
+
minZoom = MIN_ZOOM,
|
|
81
|
+
maxZoom = MAX_ZOOM,
|
|
82
|
+
step = ZOOM_STEP
|
|
83
|
+
} = options;
|
|
84
|
+
const dx = worldX(bounds.maxLon) - worldX(bounds.minLon);
|
|
85
|
+
// y grows southwards, so the northern edge gives the smaller value.
|
|
86
|
+
const dy = worldY(bounds.minLat) - worldY(bounds.maxLat);
|
|
87
|
+
for (let zoom = maxZoom; zoom > minZoom; zoom -= step) {
|
|
88
|
+
const scale = worldScale(zoom);
|
|
89
|
+
if (dx * scale <= width * fraction && dy * scale <= height * fraction) {
|
|
90
|
+
return zoom;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return minZoom;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The zoom a passage is animated at. The working scale is the reference box:
|
|
97
|
+
// a passage smaller than it is shown at that scale rather than magnified — a
|
|
98
|
+
// boat that never left its mooring has a zero-sized box and would otherwise
|
|
99
|
+
// fit at any zoom at all — and a passage larger than it may pull the camera
|
|
100
|
+
// back, but only so far.
|
|
101
|
+
export function passageZoom(bounds, options) {
|
|
102
|
+
const centre = { lat: bounds.centreLat, lon: bounds.centreLon };
|
|
103
|
+
const working = fitZoom(referenceBounds(centre), options);
|
|
104
|
+
const whole = fitZoom(bounds, options);
|
|
105
|
+
return Math.max(Math.min(whole, working), working - MAX_WIDEN_LEVELS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// World pixels are measured from the track's centre rather than from the
|
|
109
|
+
// antimeridian: at zoom 19 absolute world coordinates reach 1.3e8, far enough
|
|
110
|
+
// for canvas path precision to start showing.
|
|
111
|
+
export function createProjection({ zoom, bounds }) {
|
|
112
|
+
const scale = worldScale(zoom);
|
|
113
|
+
const originX = worldX(bounds.centreLon) * scale;
|
|
114
|
+
const originY = worldY(bounds.centreLat) * scale;
|
|
115
|
+
return {
|
|
116
|
+
zoom,
|
|
117
|
+
scale,
|
|
118
|
+
originX,
|
|
119
|
+
originY,
|
|
120
|
+
projectX: (lon) => worldX(lon) * scale - originX,
|
|
121
|
+
projectY: (lat) => worldY(lat) * scale - originY
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// What to translate the canvas by to put `centre` in the middle of the frame.
|
|
126
|
+
// A translation leaves line widths alone, so nothing needs compensating.
|
|
127
|
+
export function cameraOffset(projection, centre, width, height) {
|
|
128
|
+
return {
|
|
129
|
+
ox: width / 2 - projection.projectX(centre.lon),
|
|
130
|
+
oy: height / 2 - projection.projectY(centre.lat)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The tiles covering the frame, in the projection's own pixel space.
|
|
135
|
+
//
|
|
136
|
+
// `renderScale` is device pixels per frame pixel: the preview draws the very
|
|
137
|
+
// same frame smaller, so it should fetch coarser tiles rather than the
|
|
138
|
+
// export's full-resolution ones. It changes which zoom the tiles come from and
|
|
139
|
+
// nothing else — the framing is identical, which is what makes the preview a
|
|
140
|
+
// faithful preview.
|
|
141
|
+
export function visibleTiles(projection, centre, options) {
|
|
142
|
+
const { width, height, renderScale = 1, layerMaxZoom = MAX_ZOOM } = options;
|
|
143
|
+
const effectiveZoom = projection.zoom + Math.log2(renderScale);
|
|
144
|
+
const tileZoom = Math.max(0, Math.min(Math.ceil(effectiveZoom), layerMaxZoom));
|
|
145
|
+
const count = 2 ** tileZoom;
|
|
146
|
+
// scale / count is TILE_SIZE * 2 ** (zoom - tileZoom): a tile's size in the
|
|
147
|
+
// frame's own pixels, which is above TILE_SIZE when the layer ran out of
|
|
148
|
+
// zoom levels and the tiles have to be blown up.
|
|
149
|
+
const tilePixels = projection.scale / count;
|
|
150
|
+
const centreTileX = worldX(centre.lon) * count;
|
|
151
|
+
const centreTileY = worldY(centre.lat) * count;
|
|
152
|
+
const halfX = width / 2 / tilePixels;
|
|
153
|
+
const halfY = height / 2 / tilePixels;
|
|
154
|
+
const tiles = [];
|
|
155
|
+
const firstY = Math.floor(centreTileY - halfY);
|
|
156
|
+
const lastY = Math.floor(centreTileY + halfY);
|
|
157
|
+
const firstX = Math.floor(centreTileX - halfX);
|
|
158
|
+
const lastX = Math.floor(centreTileX + halfX);
|
|
159
|
+
for (let y = firstY; y <= lastY; y += 1) {
|
|
160
|
+
// Above 85° N or below 85° S there is no map, only the background.
|
|
161
|
+
if (y < 0 || y >= count) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (let x = firstX; x <= lastX; x += 1) {
|
|
165
|
+
tiles.push({
|
|
166
|
+
z: tileZoom,
|
|
167
|
+
x,
|
|
168
|
+
y,
|
|
169
|
+
// The seam is only a numbering matter; the drawing stays continuous.
|
|
170
|
+
wrappedX: ((x % count) + count) % count,
|
|
171
|
+
px: (x / count) * projection.scale - projection.originX,
|
|
172
|
+
py: (y / count) * projection.scale - projection.originY,
|
|
173
|
+
size: tilePixels
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { tileZoom, tilePixels, tiles };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { TILE_SIZE };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The shapes a video can be exported in, and what to encode it at.
|
|
2
|
+
//
|
|
3
|
+
// No vendor imports, no DOM: plain Node can test this. The encoder itself
|
|
4
|
+
// lives in mp4.mjs, which is the only module that reaches into vendor/ — and
|
|
5
|
+
// which is imported lazily, so a reader who never exports never downloads it.
|
|
6
|
+
|
|
7
|
+
import { FPS } from './schedule.mjs';
|
|
8
|
+
|
|
9
|
+
// Every dimension is even, as H.264 chroma subsampling requires, and 1080 px
|
|
10
|
+
// on the short side: enough for a phone screen without making the encode
|
|
11
|
+
// needlessly long.
|
|
12
|
+
export const VIDEO_FORMATS = [
|
|
13
|
+
{ id: 'mobile', labelKey: 'animation.format9x16', width: 1080, height: 1920 },
|
|
14
|
+
{ id: 'portrait', labelKey: 'animation.format3x4', width: 1080, height: 1440 },
|
|
15
|
+
{ id: 'square', labelKey: 'animation.format1x1', width: 1080, height: 1080 },
|
|
16
|
+
{ id: 'landscape', labelKey: 'animation.format4x3', width: 1440, height: 1080 },
|
|
17
|
+
{ id: 'widescreen', labelKey: 'animation.format16x9', width: 1920, height: 1080 }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_FORMAT_ID = 'widescreen';
|
|
21
|
+
|
|
22
|
+
export function formatById(id) {
|
|
23
|
+
return VIDEO_FORMATS.find((format) => format.id === id) ?? VIDEO_FORMATS[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The whole frame translates every frame — a map sliding under a fixed boat
|
|
27
|
+
// compresses far worse than a static scene — so this is deliberately generous.
|
|
28
|
+
export const BITS_PER_PIXEL_PER_FRAME = 0.13;
|
|
29
|
+
|
|
30
|
+
export function bitrateFor(width, height, fps = FPS) {
|
|
31
|
+
const bitrate = width * height * fps * BITS_PER_PIXEL_PER_FRAME;
|
|
32
|
+
return Math.round(Math.max(2e6, Math.min(12e6, bitrate)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Whether this browser can encode video at all. The finer question — whether
|
|
36
|
+
// it can encode *this* size in H.264 — is left to the encoder, which asks
|
|
37
|
+
// Mediabunny; keeping it to these three globals is what lets this module stay
|
|
38
|
+
// free of vendor imports and testable under Node.
|
|
39
|
+
export function isMp4ExportSupported(scope = globalThis) {
|
|
40
|
+
return (
|
|
41
|
+
typeof scope.VideoEncoder === 'function' &&
|
|
42
|
+
typeof scope.VideoFrame === 'function' &&
|
|
43
|
+
typeof scope.OffscreenCanvas === 'function'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Web Mercator, the projection the OpenStreetMap and OpenSeaMap tiles are cut
|
|
2
|
+
// in. The animation draws its own map rather than animating Leaflet (SPEC
|
|
3
|
+
// §4.12), so it needs the projection itself. No vendor imports, no DOM: plain
|
|
4
|
+
// Node can test this.
|
|
5
|
+
|
|
6
|
+
export const TILE_SIZE = 256;
|
|
7
|
+
|
|
8
|
+
// Web Mercator cannot reach the poles; every slippy map cuts here, which makes
|
|
9
|
+
// the world a square.
|
|
10
|
+
export const MAX_LATITUDE = 85.05112878;
|
|
11
|
+
|
|
12
|
+
// Both axes return a fraction of the world, 0 to 1, so a zoom level is applied
|
|
13
|
+
// afterwards by `worldScale`. x grows eastwards, y grows *southwards* — the
|
|
14
|
+
// screen direction, not the compass one.
|
|
15
|
+
export function worldX(lon) {
|
|
16
|
+
return (lon + 180) / 360;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function worldY(lat) {
|
|
20
|
+
const clamped = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat));
|
|
21
|
+
const phi = (clamped * Math.PI) / 180;
|
|
22
|
+
const y = (1 - Math.log(Math.tan(phi) + 1 / Math.cos(phi)) / Math.PI) / 2;
|
|
23
|
+
// MAX_LATITUDE is the rounded cut-off, so it lands a few 1e-12 outside the
|
|
24
|
+
// square. Left alone, that floors to tile -1 and a row of tiles goes missing.
|
|
25
|
+
return Math.max(0, Math.min(1, y));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function lonAt(x) {
|
|
29
|
+
return x * 360 - 180;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function latAt(y) {
|
|
33
|
+
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) * 180) / Math.PI;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Pixels across the whole world at this zoom. Fractional zooms are allowed and
|
|
37
|
+
// meaningful: the camera picks one per passage, and tiles are then drawn from
|
|
38
|
+
// the next integer level up and scaled down.
|
|
39
|
+
export function worldScale(zoom) {
|
|
40
|
+
return TILE_SIZE * 2 ** zoom;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The projection is a cylinder cut at ±180°, so a track crossing the
|
|
44
|
+
// antimeridian would jump the width of the world between two points. Longitudes
|
|
45
|
+
// are unwrapped once, when a track is read, by keeping each point on the side
|
|
46
|
+
// its predecessor was: they may then leave [-180, 180], which every function
|
|
47
|
+
// here tolerates, and tile numbers are wrapped back when a URL is built.
|
|
48
|
+
export function unwrapLongitude(previousLon, lon) {
|
|
49
|
+
return lon + 360 * Math.round((previousLon - lon) / 360);
|
|
50
|
+
}
|