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
@@ -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
+ }
@@ -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
 
@@ -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
+ }