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,77 @@
|
|
|
1
|
+
// Encoding the animation to an MP4, in the browser and nowhere else.
|
|
2
|
+
//
|
|
3
|
+
// This is the only module that reaches into vendor/, and it is imported lazily
|
|
4
|
+
// — a reader who never exports never downloads the encoder. Mediabunny covers
|
|
5
|
+
// both halves of the job: WebCodecs on one side, the MP4 container on the
|
|
6
|
+
// other, with `source.add()` applying the encoder's own backpressure.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BufferTarget,
|
|
10
|
+
CanvasSource,
|
|
11
|
+
canEncodeVideo,
|
|
12
|
+
Mp4OutputFormat,
|
|
13
|
+
Output
|
|
14
|
+
} from '../../vendor/mediabunny.min.mjs';
|
|
15
|
+
import { bitrateFor } from './formats.mjs';
|
|
16
|
+
import { FPS, frameTimeSeconds } from './schedule.mjs';
|
|
17
|
+
|
|
18
|
+
// A keyframe every couple of seconds: enough to scrub, cheap enough not to
|
|
19
|
+
// bloat a short clip.
|
|
20
|
+
const KEYFRAME_INTERVAL_SECONDS = 2;
|
|
21
|
+
|
|
22
|
+
// How often the page is given back to the browser, so the progress bar moves
|
|
23
|
+
// and the cancel button can be pressed.
|
|
24
|
+
const YIELD_EVERY = 5;
|
|
25
|
+
|
|
26
|
+
export async function canEncodeThisVideo(width, height) {
|
|
27
|
+
try {
|
|
28
|
+
return await canEncodeVideo('avc', { width, height });
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `drawFrame(index)` paints frame `index` onto the canvas it was given. It is
|
|
35
|
+
// called exactly `frames` times, in order, and may await its map tiles: the
|
|
36
|
+
// film is then the same however slow the connection was — a real render, not a
|
|
37
|
+
// screen recording.
|
|
38
|
+
export async function encodeAnimation(options) {
|
|
39
|
+
const { width, height, frames, drawFrame, fps = FPS, signal, onProgress } = options;
|
|
40
|
+
|
|
41
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
42
|
+
// No transparency: the map fills the frame, and an opaque context is faster.
|
|
43
|
+
const context = canvas.getContext('2d', { alpha: false });
|
|
44
|
+
|
|
45
|
+
const output = new Output({
|
|
46
|
+
format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
|
|
47
|
+
target: new BufferTarget()
|
|
48
|
+
});
|
|
49
|
+
const source = new CanvasSource(canvas, {
|
|
50
|
+
codec: 'avc',
|
|
51
|
+
bitrate: bitrateFor(width, height, fps),
|
|
52
|
+
keyFrameInterval: KEYFRAME_INTERVAL_SECONDS
|
|
53
|
+
});
|
|
54
|
+
output.addVideoTrack(source, { frameRate: fps });
|
|
55
|
+
|
|
56
|
+
await output.start();
|
|
57
|
+
try {
|
|
58
|
+
for (let index = 0; index < frames; index += 1) {
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
await drawFrame(index, context);
|
|
61
|
+
// Resolves once the encoder is ready for more: this is the backpressure,
|
|
62
|
+
// and why nothing here counts an encode queue.
|
|
63
|
+
await source.add(frameTimeSeconds(index, fps), 1 / fps);
|
|
64
|
+
if (index % YIELD_EVERY === 0) {
|
|
65
|
+
onProgress?.({ phase: 'frames', done: index + 1, total: frames });
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await output.cancel();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onProgress?.({ phase: 'frames', done: frames, total: frames });
|
|
75
|
+
await output.finalize();
|
|
76
|
+
return new Blob([output.target.buffer], { type: 'video/mp4' });
|
|
77
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Playback: play, pause, scrub, change speed. It owns where the film is up to
|
|
2
|
+
// and nothing else — no drawing, no DOM. The frame loop is injected, so plain
|
|
3
|
+
// Node can drive it tick by tick.
|
|
4
|
+
|
|
5
|
+
import { advance, DEFAULT_SPEED } from './schedule.mjs';
|
|
6
|
+
|
|
7
|
+
export function createPlayer(options) {
|
|
8
|
+
const {
|
|
9
|
+
totalUnits,
|
|
10
|
+
speed = DEFAULT_SPEED,
|
|
11
|
+
onFrame,
|
|
12
|
+
onStateChange,
|
|
13
|
+
raf = globalThis.requestAnimationFrame,
|
|
14
|
+
cancel = globalThis.cancelAnimationFrame
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
let units = 0;
|
|
18
|
+
let playing = false;
|
|
19
|
+
let ended = false;
|
|
20
|
+
let rate = speed;
|
|
21
|
+
let handle = null;
|
|
22
|
+
let last = null;
|
|
23
|
+
|
|
24
|
+
const state = () => ({ units, playing, ended, speed: rate, totalUnits });
|
|
25
|
+
const announce = () => onStateChange?.(state());
|
|
26
|
+
const paint = () => onFrame?.(units, state());
|
|
27
|
+
|
|
28
|
+
function tick(timestamp) {
|
|
29
|
+
if (!playing) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const delta = last === null ? 0 : timestamp - last;
|
|
33
|
+
last = timestamp;
|
|
34
|
+
const next = advance(units, delta, rate, totalUnits);
|
|
35
|
+
units = next.units;
|
|
36
|
+
paint();
|
|
37
|
+
if (next.ended) {
|
|
38
|
+
playing = false;
|
|
39
|
+
ended = true;
|
|
40
|
+
handle = null;
|
|
41
|
+
announce();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
handle = raf(tick);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function start() {
|
|
48
|
+
last = null;
|
|
49
|
+
handle = raf(tick);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const player = {
|
|
53
|
+
state,
|
|
54
|
+
|
|
55
|
+
play() {
|
|
56
|
+
if (playing) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Pressing play on a finished film starts it again rather than sitting
|
|
60
|
+
// on the last frame.
|
|
61
|
+
if (ended || units >= totalUnits) {
|
|
62
|
+
units = 0;
|
|
63
|
+
ended = false;
|
|
64
|
+
}
|
|
65
|
+
playing = true;
|
|
66
|
+
announce();
|
|
67
|
+
start();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
pause() {
|
|
71
|
+
if (!playing) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
playing = false;
|
|
75
|
+
if (handle !== null) {
|
|
76
|
+
cancel?.(handle);
|
|
77
|
+
handle = null;
|
|
78
|
+
}
|
|
79
|
+
announce();
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
toggle() {
|
|
83
|
+
if (playing) {
|
|
84
|
+
player.pause();
|
|
85
|
+
} else {
|
|
86
|
+
player.play();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
seek(to) {
|
|
91
|
+
units = Math.max(0, Math.min(totalUnits, to));
|
|
92
|
+
ended = false;
|
|
93
|
+
paint();
|
|
94
|
+
announce();
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Changing speed must not move the film: the storyboard is measured in
|
|
98
|
+
// units, and only how fast they are consumed changes.
|
|
99
|
+
setSpeed(next) {
|
|
100
|
+
rate = next;
|
|
101
|
+
announce();
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// A tab that was in the background hands back a timestamp from before it
|
|
105
|
+
// went away; without this the first frame after would jump.
|
|
106
|
+
resetClock() {
|
|
107
|
+
last = null;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
destroy() {
|
|
111
|
+
playing = false;
|
|
112
|
+
if (handle !== null) {
|
|
113
|
+
cancel?.(handle);
|
|
114
|
+
handle = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return player;
|
|
120
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Drawing one frame of the animation.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is in the frame's own logical coordinates — the size of the
|
|
4
|
+
// chosen export format. The preview scales the whole context down by
|
|
5
|
+
// `renderScale`, so a frame on screen and a frame in the MP4 are the same
|
|
6
|
+
// drawing at different resolutions, and the framing cannot drift apart.
|
|
7
|
+
//
|
|
8
|
+
// Colours are literals rather than CSS variables, as in components/TrackMap.mjs
|
|
9
|
+
// (a canvas cannot read them) and, for the overlay, deliberately: the video is
|
|
10
|
+
// watched outside the app and must not depend on the theme of whoever exported
|
|
11
|
+
// it.
|
|
12
|
+
|
|
13
|
+
import { cameraOffset, createProjection, visibleTiles } from './camera.mjs';
|
|
14
|
+
import { worldX, worldY } from './mercator.mjs';
|
|
15
|
+
|
|
16
|
+
export const COLOURS = {
|
|
17
|
+
sea: '#aad3df',
|
|
18
|
+
track: '#e8590c',
|
|
19
|
+
trackPast: 'rgba(232, 89, 12, 0.55)',
|
|
20
|
+
boat: '#1d4ed8',
|
|
21
|
+
boatOutline: '#ffffff',
|
|
22
|
+
departure: '#2b8a3e',
|
|
23
|
+
arrival: '#c92a2a',
|
|
24
|
+
markerOutline: '#ffffff',
|
|
25
|
+
bubble: 'rgba(255, 255, 255, 0.88)',
|
|
26
|
+
bubbleBorder: 'rgba(0, 0, 0, 0.18)',
|
|
27
|
+
text: '#16222e',
|
|
28
|
+
muted: '#5a6b7b',
|
|
29
|
+
attribution: 'rgba(255, 255, 255, 0.75)',
|
|
30
|
+
attributionText: '#16222e'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const FONT = "600 {size}px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
|
|
34
|
+
const LABEL_FONT = "500 {size}px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
|
|
35
|
+
|
|
36
|
+
// No frame ever issues more than this many line segments: a season of sailing
|
|
37
|
+
// is tens of thousands of points, and most of them land on the same pixel.
|
|
38
|
+
const MAX_SEGMENTS = 4000;
|
|
39
|
+
|
|
40
|
+
// Sizes are derived from the short side, so a 9:16 phone video and a 16:9
|
|
41
|
+
// widescreen one carry the same weight of text. Pure, so it can be tested.
|
|
42
|
+
export function overlayLayout(width, height) {
|
|
43
|
+
const unit = Math.min(width, height) / 1080;
|
|
44
|
+
const margin = Math.round(48 * unit);
|
|
45
|
+
return {
|
|
46
|
+
unit,
|
|
47
|
+
margin,
|
|
48
|
+
padding: Math.round(22 * unit),
|
|
49
|
+
radius: Math.round(18 * unit),
|
|
50
|
+
labelSize: Math.round(24 * unit),
|
|
51
|
+
valueSize: Math.round(54 * unit),
|
|
52
|
+
clockSize: Math.round(30 * unit),
|
|
53
|
+
attributionSize: Math.round(20 * unit),
|
|
54
|
+
lineGap: Math.round(12 * unit),
|
|
55
|
+
trackWidth: Math.max(2, 5 * unit),
|
|
56
|
+
boatSize: Math.round(44 * unit),
|
|
57
|
+
markerRadius: Math.round(11 * unit),
|
|
58
|
+
placeSize: Math.round(24 * unit)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function font(template, size) {
|
|
63
|
+
return template.replace('{size}', size);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A camera is a zoom and a centre; the frame's origin is that centre, so the
|
|
67
|
+
// numbers stay small whatever the zoom.
|
|
68
|
+
function frameProjection(camera, width, height) {
|
|
69
|
+
const projection = createProjection({
|
|
70
|
+
zoom: camera.zoom,
|
|
71
|
+
bounds: { centreLat: camera.centre.lat, centreLon: camera.centre.lon }
|
|
72
|
+
});
|
|
73
|
+
const { ox, oy } = cameraOffset(projection, camera.centre, width, height);
|
|
74
|
+
return {
|
|
75
|
+
projection,
|
|
76
|
+
ox,
|
|
77
|
+
oy,
|
|
78
|
+
// Legs carry their positions as world fractions, which do not depend on the
|
|
79
|
+
// zoom: turning one into a pixel is then two multiplications.
|
|
80
|
+
x: (worldFractionX) => worldFractionX * projection.scale - projection.originX + ox,
|
|
81
|
+
y: (worldFractionY) => worldFractionY * projection.scale - projection.originY + oy
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function drawTiles(ctx, view, camera, scene) {
|
|
86
|
+
const { width, height, renderScale, cache, layers } = scene;
|
|
87
|
+
for (const layer of layers) {
|
|
88
|
+
const { tiles } = visibleTiles(view.projection, camera.centre, {
|
|
89
|
+
width,
|
|
90
|
+
height,
|
|
91
|
+
renderScale,
|
|
92
|
+
layerMaxZoom: layer.maxZoom
|
|
93
|
+
});
|
|
94
|
+
for (const tile of tiles) {
|
|
95
|
+
const bitmap = cache.peek(layer.id, tile);
|
|
96
|
+
if (!bitmap) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Floor the corner and round the size up: at a fractional zoom, exact
|
|
100
|
+
// placement leaves hairline seams between tiles.
|
|
101
|
+
ctx.drawImage(
|
|
102
|
+
bitmap,
|
|
103
|
+
Math.floor(tile.px + view.ox),
|
|
104
|
+
Math.floor(tile.py + view.oy),
|
|
105
|
+
Math.ceil(tile.size) + 1,
|
|
106
|
+
Math.ceil(tile.size) + 1
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function strokeLeg(ctx, view, leg, upTo) {
|
|
113
|
+
const { worldXs, worldYs } = leg;
|
|
114
|
+
const count = upTo ?? leg.timeline.count;
|
|
115
|
+
if (count < 2) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const stride = Math.max(1, Math.ceil(count / MAX_SEGMENTS));
|
|
119
|
+
ctx.beginPath();
|
|
120
|
+
ctx.moveTo(view.x(worldXs[0]), view.y(worldYs[0]));
|
|
121
|
+
for (let i = stride; i < count; i += stride) {
|
|
122
|
+
ctx.lineTo(view.x(worldXs[i]), view.y(worldYs[i]));
|
|
123
|
+
}
|
|
124
|
+
// Always finish on the real last point, whatever the stride skipped.
|
|
125
|
+
ctx.lineTo(view.x(worldXs[count - 1]), view.y(worldYs[count - 1]));
|
|
126
|
+
ctx.stroke();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function drawTracks(ctx, view, scene, layout) {
|
|
130
|
+
const { legs, state } = scene;
|
|
131
|
+
ctx.lineWidth = layout.trackWidth;
|
|
132
|
+
ctx.lineJoin = 'round';
|
|
133
|
+
ctx.lineCap = 'round';
|
|
134
|
+
|
|
135
|
+
for (let index = 0; index <= state.legIndex; index += 1) {
|
|
136
|
+
const leg = legs[index];
|
|
137
|
+
const sailing = index === state.legIndex && state.phase === 'leg';
|
|
138
|
+
ctx.strokeStyle = index === state.legIndex ? COLOURS.track : COLOURS.trackPast;
|
|
139
|
+
// The legs still to come are not drawn: the film shows the passage being
|
|
140
|
+
// made, not a route already plotted.
|
|
141
|
+
strokeLeg(ctx, view, leg, sailing ? leg.timeline.indexAt(state.timeMs) + 2 : undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function drawMarker(ctx, view, leg, which, layout, label) {
|
|
146
|
+
const index = which === 'departure' ? 0 : leg.timeline.count - 1;
|
|
147
|
+
const x = view.x(leg.worldXs[index]);
|
|
148
|
+
const y = view.y(leg.worldYs[index]);
|
|
149
|
+
ctx.beginPath();
|
|
150
|
+
ctx.arc(x, y, layout.markerRadius, 0, Math.PI * 2);
|
|
151
|
+
ctx.fillStyle = which === 'departure' ? COLOURS.departure : COLOURS.arrival;
|
|
152
|
+
ctx.fill();
|
|
153
|
+
ctx.lineWidth = Math.max(1.5, 2.5 * layout.unit);
|
|
154
|
+
ctx.strokeStyle = COLOURS.markerOutline;
|
|
155
|
+
ctx.stroke();
|
|
156
|
+
|
|
157
|
+
if (!label) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
ctx.font = font(LABEL_FONT, layout.placeSize);
|
|
161
|
+
ctx.textAlign = 'center';
|
|
162
|
+
ctx.textBaseline = 'bottom';
|
|
163
|
+
const offset = layout.markerRadius + Math.round(8 * layout.unit);
|
|
164
|
+
// An outline behind the name, so it reads over any chart.
|
|
165
|
+
ctx.lineWidth = Math.max(2, 4 * layout.unit);
|
|
166
|
+
ctx.strokeStyle = COLOURS.markerOutline;
|
|
167
|
+
ctx.lineJoin = 'round';
|
|
168
|
+
// fillText draws the crew's own words as text, never as markup.
|
|
169
|
+
ctx.strokeText(label, x, y - offset);
|
|
170
|
+
ctx.fillStyle = COLOURS.text;
|
|
171
|
+
ctx.fillText(label, x, y - offset);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function drawPorts(ctx, view, scene, layout) {
|
|
175
|
+
const { legs, state } = scene;
|
|
176
|
+
if (legs.length === 0) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
drawMarker(ctx, view, legs[0], 'departure', layout, legs[0].entry?.startPlaceName);
|
|
180
|
+
for (let index = 0; index < state.legIndex; index += 1) {
|
|
181
|
+
drawMarker(ctx, view, legs[index], 'arrival', layout, legs[index].entry?.endPlaceName);
|
|
182
|
+
}
|
|
183
|
+
// The leg being sailed only gets its arrival marker once it is reached.
|
|
184
|
+
if (state.phase !== 'leg') {
|
|
185
|
+
const leg = legs[state.legIndex];
|
|
186
|
+
drawMarker(ctx, view, leg, 'arrival', layout, leg.entry?.endPlaceName);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function drawBoat(ctx, view, scene, layout) {
|
|
191
|
+
const { state } = scene;
|
|
192
|
+
if (!state.boatVisible) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const x = view.x(worldX(state.lon));
|
|
196
|
+
const y = view.y(worldY(state.lat));
|
|
197
|
+
const heading = state.heading ?? state.cog ?? 0;
|
|
198
|
+
const size = layout.boatSize;
|
|
199
|
+
ctx.save();
|
|
200
|
+
ctx.translate(x, y);
|
|
201
|
+
ctx.rotate(heading);
|
|
202
|
+
// The same hull as the map's marker, drawn in a 24-unit box pointing north.
|
|
203
|
+
const k = size / 24;
|
|
204
|
+
ctx.beginPath();
|
|
205
|
+
ctx.moveTo(0 * k, -10 * k);
|
|
206
|
+
ctx.lineTo(6.5 * k, 8 * k);
|
|
207
|
+
ctx.lineTo(0 * k, 4.5 * k);
|
|
208
|
+
ctx.lineTo(-6.5 * k, 8 * k);
|
|
209
|
+
ctx.closePath();
|
|
210
|
+
ctx.fillStyle = COLOURS.boat;
|
|
211
|
+
ctx.fill();
|
|
212
|
+
ctx.lineWidth = Math.max(1.5, 2.5 * layout.unit);
|
|
213
|
+
ctx.lineJoin = 'round';
|
|
214
|
+
ctx.strokeStyle = COLOURS.boatOutline;
|
|
215
|
+
ctx.stroke();
|
|
216
|
+
ctx.restore();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function roundedRect(ctx, x, y, width, height, radius) {
|
|
220
|
+
ctx.beginPath();
|
|
221
|
+
ctx.moveTo(x + radius, y);
|
|
222
|
+
ctx.arcTo(x + width, y, x + width, y + height, radius);
|
|
223
|
+
ctx.arcTo(x + width, y + height, x, y + height, radius);
|
|
224
|
+
ctx.arcTo(x, y + height, x, y, radius);
|
|
225
|
+
ctx.arcTo(x, y, x + width, y, radius);
|
|
226
|
+
ctx.closePath();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Speed, distance covered since the film began, and when — the readout the
|
|
230
|
+
// whole animation is for.
|
|
231
|
+
function drawBubble(ctx, scene, layout) {
|
|
232
|
+
const { overlay } = scene;
|
|
233
|
+
const rows = [
|
|
234
|
+
[overlay.speedLabel, overlay.speed],
|
|
235
|
+
[overlay.distanceLabel, overlay.distance]
|
|
236
|
+
];
|
|
237
|
+
ctx.textAlign = 'left';
|
|
238
|
+
ctx.textBaseline = 'top';
|
|
239
|
+
|
|
240
|
+
let widest = 0;
|
|
241
|
+
ctx.font = font(FONT, layout.valueSize);
|
|
242
|
+
for (const [, value] of rows) {
|
|
243
|
+
widest = Math.max(widest, ctx.measureText(value).width);
|
|
244
|
+
}
|
|
245
|
+
ctx.font = font(LABEL_FONT, layout.clockSize);
|
|
246
|
+
widest = Math.max(widest, ctx.measureText(overlay.clock).width);
|
|
247
|
+
|
|
248
|
+
const rowHeight = layout.labelSize + layout.lineGap + layout.valueSize;
|
|
249
|
+
const height =
|
|
250
|
+
layout.padding * 2 + rows.length * rowHeight + layout.lineGap * 2 + layout.clockSize;
|
|
251
|
+
const width = widest + layout.padding * 2;
|
|
252
|
+
const x = layout.margin;
|
|
253
|
+
const y = layout.margin;
|
|
254
|
+
|
|
255
|
+
roundedRect(ctx, x, y, width, height, layout.radius);
|
|
256
|
+
ctx.fillStyle = COLOURS.bubble;
|
|
257
|
+
ctx.fill();
|
|
258
|
+
ctx.lineWidth = Math.max(1, layout.unit);
|
|
259
|
+
ctx.strokeStyle = COLOURS.bubbleBorder;
|
|
260
|
+
ctx.stroke();
|
|
261
|
+
|
|
262
|
+
let cursor = y + layout.padding;
|
|
263
|
+
for (const [label, value] of rows) {
|
|
264
|
+
ctx.font = font(LABEL_FONT, layout.labelSize);
|
|
265
|
+
ctx.fillStyle = COLOURS.muted;
|
|
266
|
+
ctx.fillText(label, x + layout.padding, cursor);
|
|
267
|
+
cursor += layout.labelSize + layout.lineGap;
|
|
268
|
+
ctx.font = font(FONT, layout.valueSize);
|
|
269
|
+
ctx.fillStyle = COLOURS.text;
|
|
270
|
+
ctx.fillText(value, x + layout.padding, cursor);
|
|
271
|
+
cursor += layout.valueSize;
|
|
272
|
+
}
|
|
273
|
+
cursor += layout.lineGap * 2;
|
|
274
|
+
ctx.font = font(LABEL_FONT, layout.clockSize);
|
|
275
|
+
ctx.fillStyle = COLOURS.muted;
|
|
276
|
+
ctx.fillText(overlay.clock, x + layout.padding, cursor);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Once the frames leave the page, the map credit has to travel with them.
|
|
280
|
+
function drawAttribution(ctx, scene, layout) {
|
|
281
|
+
const { width, height, overlay } = scene;
|
|
282
|
+
ctx.font = font(LABEL_FONT, layout.attributionSize);
|
|
283
|
+
ctx.textAlign = 'right';
|
|
284
|
+
ctx.textBaseline = 'bottom';
|
|
285
|
+
const text = overlay.attribution;
|
|
286
|
+
const padding = Math.round(10 * layout.unit);
|
|
287
|
+
const measured = ctx.measureText(text).width;
|
|
288
|
+
const boxHeight = layout.attributionSize + padding * 2;
|
|
289
|
+
const x = width - measured - padding * 2 - Math.round(12 * layout.unit);
|
|
290
|
+
const y = height - boxHeight - Math.round(12 * layout.unit);
|
|
291
|
+
roundedRect(ctx, x, y, measured + padding * 2, boxHeight, Math.round(6 * layout.unit));
|
|
292
|
+
ctx.fillStyle = COLOURS.attribution;
|
|
293
|
+
ctx.fill();
|
|
294
|
+
ctx.fillStyle = COLOURS.attributionText;
|
|
295
|
+
ctx.fillText(
|
|
296
|
+
text,
|
|
297
|
+
width - padding - Math.round(12 * layout.unit),
|
|
298
|
+
height - padding - Math.round(12 * layout.unit)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The one impure function here. `scene` carries everything already worked out:
|
|
303
|
+
// the state, the legs, the tile cache and the overlay's already-formatted,
|
|
304
|
+
// already-translated strings — the renderer knows nothing of i18n.
|
|
305
|
+
export function drawFrame(ctx, scene) {
|
|
306
|
+
const { width, height, state } = scene;
|
|
307
|
+
const layout = overlayLayout(width, height);
|
|
308
|
+
|
|
309
|
+
ctx.save();
|
|
310
|
+
ctx.fillStyle = COLOURS.sea;
|
|
311
|
+
ctx.fillRect(0, 0, width, height);
|
|
312
|
+
|
|
313
|
+
if (state) {
|
|
314
|
+
const view = frameProjection(state.camera, width, height);
|
|
315
|
+
drawTiles(ctx, view, state.camera, scene);
|
|
316
|
+
drawTracks(ctx, view, scene, layout);
|
|
317
|
+
drawPorts(ctx, view, scene, layout);
|
|
318
|
+
drawBoat(ctx, view, scene, layout);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (scene.overlay) {
|
|
322
|
+
drawBubble(ctx, scene, layout);
|
|
323
|
+
drawAttribution(ctx, scene, layout);
|
|
324
|
+
}
|
|
325
|
+
ctx.restore();
|
|
326
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Cadence: how the film advances when it is played, and where each frame of an
|
|
2
|
+
// export falls. Playback follows the wall clock so a dropped frame does not
|
|
3
|
+
// slow the animation down; an export ignores clocks entirely, so the file comes
|
|
4
|
+
// out the same however fast the machine and the network are.
|
|
5
|
+
//
|
|
6
|
+
// No vendor imports, no DOM: plain Node can test this.
|
|
7
|
+
|
|
8
|
+
export const FPS = 30;
|
|
9
|
+
|
|
10
|
+
// One hour of sailing per second of video at x1 (SPEC §4.12); the storyboard
|
|
11
|
+
// already counts in those seconds, so a multiplier just divides them.
|
|
12
|
+
export const SPEEDS = [0.5, 1, 2, 4];
|
|
13
|
+
export const DEFAULT_SPEED = 1;
|
|
14
|
+
|
|
15
|
+
// Three minutes of video. A guard on memory more than on patience: the whole
|
|
16
|
+
// MP4 is held in memory until it is saved.
|
|
17
|
+
export const MAX_FRAMES = 5400;
|
|
18
|
+
|
|
19
|
+
// A backgrounded tab, or a burst of tile decoding, can leave several seconds
|
|
20
|
+
// between two animation frames. Without this the film would jump hours.
|
|
21
|
+
export const MAX_REAL_STEP_MS = 250;
|
|
22
|
+
|
|
23
|
+
export function videoDurationSeconds(totalUnits, speed) {
|
|
24
|
+
return totalUnits / speed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function frameCount(totalUnits, speed, fps = FPS) {
|
|
28
|
+
const frames = Math.round(videoDurationSeconds(totalUnits, speed) * fps);
|
|
29
|
+
return Math.max(2, Math.min(MAX_FRAMES, frames));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Frame 0 is the very start and the last frame the very end, so a film never
|
|
33
|
+
// stops a frame short of the arrival.
|
|
34
|
+
export function unitsAtFrame(index, totalUnits, frames) {
|
|
35
|
+
return frames <= 1 ? 0 : (totalUnits * index) / (frames - 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Counted from the index rather than accumulated: adding 1/30 up a few thousand
|
|
39
|
+
// times drifts by milliseconds, and the timestamps are what the container
|
|
40
|
+
// records.
|
|
41
|
+
export function frameTimeSeconds(index, fps = FPS) {
|
|
42
|
+
return index / fps;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function advance(units, realDeltaMs, speed, totalUnits) {
|
|
46
|
+
const step = (Math.min(realDeltaMs, MAX_REAL_STEP_MS) / 1000) * speed;
|
|
47
|
+
const next = units + step;
|
|
48
|
+
return next >= totalUnits ? { units: totalUnits, ended: true } : { units: next, ended: false };
|
|
49
|
+
}
|