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/tide-forecaster.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
createDepartureForecaster,
|
|
3
|
+
readForecast,
|
|
4
|
+
getJson,
|
|
5
|
+
utcTime
|
|
6
|
+
} = require('./departure-forecast');
|
|
2
7
|
|
|
3
8
|
const TIDE_DEFAULTS = {
|
|
4
9
|
tidesEnabled: true,
|
|
@@ -13,53 +18,10 @@ const TIDE_DEFAULTS = {
|
|
|
13
18
|
// level, not a substitute for a charted "hauteur d'eau".
|
|
14
19
|
const DATUM = 'msl';
|
|
15
20
|
|
|
16
|
-
const
|
|
17
|
-
const IDLE_MS = 60 * 1000;
|
|
18
|
-
// Offline is the normal state at sea; retry patiently rather than hammering.
|
|
19
|
-
const FIRST_RETRY_MS = 5 * 60 * 1000;
|
|
20
|
-
const MAX_RETRY_MS = 60 * 60 * 1000;
|
|
21
|
-
// A fetch this long after departure would no longer be "near departure" --
|
|
22
|
-
// the forecast window has mostly gone by, so give up rather than keep trying.
|
|
23
|
-
const MAX_FETCH_AGE_MS = 3 * 60 * 60 * 1000;
|
|
24
|
-
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
25
|
-
|
|
26
|
-
function iso(ms) {
|
|
27
|
-
return new Date(ms).toISOString();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// The entry most recently departed that has no forecast yet -- fetched or
|
|
31
|
-
// found to have none, either way recorded as a row, so this never re-selects
|
|
32
|
-
// it. Bounded to recent departures: a fetch long after departure would mean
|
|
33
|
-
// requesting a window mostly in the past, which the forecast API does not
|
|
34
|
-
// have, and repeatedly retrying an entry the boat was offline for the whole
|
|
35
|
-
// relevant window of would never succeed anyway.
|
|
36
|
-
function nextPending(db, now) {
|
|
37
|
-
return db
|
|
38
|
-
.prepare(
|
|
39
|
-
`SELECT id, start_time, start_lat AS lat, start_lon AS lon FROM log_entries
|
|
40
|
-
WHERE start_lat IS NOT NULL AND start_lon IS NOT NULL
|
|
41
|
-
AND start_time >= ?
|
|
42
|
-
AND id NOT IN (SELECT entry_id FROM tide_forecasts)
|
|
43
|
-
ORDER BY id DESC LIMIT 1`
|
|
44
|
-
)
|
|
45
|
-
.get(iso(now - MAX_FETCH_AGE_MS));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function save(db, entryId, position, now, points) {
|
|
49
|
-
withTransaction(db, () => {
|
|
50
|
-
db.prepare(
|
|
51
|
-
`INSERT INTO tide_forecasts (entry_id, lat, lon, fetched_at, points)
|
|
52
|
-
VALUES (?, ?, ?, ?, ?)
|
|
53
|
-
ON CONFLICT (entry_id) DO NOTHING`
|
|
54
|
-
).run(entryId, position.lat, position.lon, iso(now), JSON.stringify(points));
|
|
55
|
-
});
|
|
56
|
-
}
|
|
21
|
+
const TABLE = 'tide_forecasts';
|
|
57
22
|
|
|
58
23
|
function createTideForecaster({ db, settings, userAgent, fetch = globalThis.fetch }) {
|
|
59
|
-
|
|
60
|
-
let failures = 0;
|
|
61
|
-
|
|
62
|
-
async function fetchHeights(position) {
|
|
24
|
+
async function fetchHeights(position, signal) {
|
|
63
25
|
const endpoint = new URL(settings.tideUrl);
|
|
64
26
|
endpoint.searchParams.set('latitude', String(position.lat));
|
|
65
27
|
endpoint.searchParams.set('longitude', String(position.lon));
|
|
@@ -68,87 +30,32 @@ function createTideForecaster({ db, settings, userAgent, fetch = globalThis.fetc
|
|
|
68
30
|
endpoint.searchParams.set('past_days', '1');
|
|
69
31
|
endpoint.searchParams.set('forecast_days', '2');
|
|
70
32
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
75
|
-
// Rate limiting or server trouble is worth retrying later; anything else
|
|
76
|
-
// (an unrecognised parameter, say) is a final answer of "nothing usable".
|
|
77
|
-
if (response.status === 429 || response.status >= 500) {
|
|
78
|
-
throw new Error(`Tide service answered HTTP ${response.status}`);
|
|
79
|
-
}
|
|
80
|
-
if (!response.ok) {
|
|
81
|
-
return [];
|
|
82
|
-
}
|
|
83
|
-
const result = await response.json();
|
|
84
|
-
const times = result.hourly?.time ?? [];
|
|
85
|
-
const heights = result.hourly?.sea_level_height_msl ?? [];
|
|
33
|
+
const result = await getJson(fetch, endpoint, { userAgent, signal, label: 'Tide service' });
|
|
34
|
+
const times = result?.hourly?.time ?? [];
|
|
35
|
+
const heights = result?.hourly?.sea_level_height_msl ?? [];
|
|
86
36
|
return times
|
|
87
|
-
.map((time, index) => ({ time:
|
|
37
|
+
.map((time, index) => ({ time: utcTime(time), height: heights[index] }))
|
|
88
38
|
.filter((point) => typeof point.height === 'number' && Number.isFinite(point.height));
|
|
89
39
|
}
|
|
90
40
|
|
|
91
|
-
return {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const pending = nextPending(db, now);
|
|
98
|
-
if (!pending) {
|
|
99
|
-
return { outcome: 'idle', retryInMs: IDLE_MS };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const departure = Date.parse(pending.start_time);
|
|
103
|
-
const position = { lat: pending.lat, lon: pending.lon };
|
|
104
|
-
|
|
105
|
-
let all;
|
|
106
|
-
try {
|
|
107
|
-
all = await fetchHeights(position);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (stopping.signal.aborted) {
|
|
110
|
-
return { outcome: 'stopped' };
|
|
111
|
-
}
|
|
112
|
-
failures += 1;
|
|
113
|
-
return {
|
|
114
|
-
outcome: 'failed',
|
|
115
|
-
error,
|
|
116
|
-
retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
if (stopping.signal.aborted) {
|
|
120
|
-
return { outcome: 'stopped' };
|
|
121
|
-
}
|
|
122
|
-
failures = 0;
|
|
123
|
-
|
|
124
|
-
const points = all.filter((point) => {
|
|
125
|
-
const time = Date.parse(point.time);
|
|
126
|
-
return time >= departure && time < departure + WINDOW_MS;
|
|
127
|
-
});
|
|
128
|
-
save(db, pending.id, position, now, points);
|
|
129
|
-
return { outcome: points.length > 0 ? 'fetched' : 'no_data', retryInMs: 0 };
|
|
130
|
-
},
|
|
131
|
-
|
|
132
|
-
stop() {
|
|
133
|
-
stopping.abort();
|
|
134
|
-
}
|
|
135
|
-
};
|
|
41
|
+
return createDepartureForecaster({
|
|
42
|
+
db,
|
|
43
|
+
table: TABLE,
|
|
44
|
+
isEnabled: () => settings.tidesEnabled,
|
|
45
|
+
fetchPoints: fetchHeights
|
|
46
|
+
});
|
|
136
47
|
}
|
|
137
48
|
|
|
138
49
|
function getTideForecast(db, entryId) {
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const points = JSON.parse(row.points);
|
|
144
|
-
if (points.length === 0) {
|
|
50
|
+
const forecast = readForecast(db, TABLE, entryId);
|
|
51
|
+
if (!forecast) {
|
|
145
52
|
return null;
|
|
146
53
|
}
|
|
147
54
|
return {
|
|
148
|
-
position:
|
|
149
|
-
fetchedAt:
|
|
55
|
+
position: forecast.position,
|
|
56
|
+
fetchedAt: forecast.fetchedAt,
|
|
150
57
|
datum: DATUM,
|
|
151
|
-
points
|
|
58
|
+
points: forecast.points
|
|
152
59
|
};
|
|
153
60
|
}
|
|
154
61
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createDepartureForecaster,
|
|
3
|
+
readForecast,
|
|
4
|
+
getJson,
|
|
5
|
+
utcTime
|
|
6
|
+
} = require('./departure-forecast');
|
|
7
|
+
|
|
8
|
+
const WEATHER_DEFAULTS = {
|
|
9
|
+
weatherEnabled: true,
|
|
10
|
+
weatherUrl: 'https://api.open-meteo.com/v1/forecast'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const TABLE = 'weather_forecasts';
|
|
14
|
+
|
|
15
|
+
// Open-Meteo variable → stored field, per service. The marine one is the
|
|
16
|
+
// same service as the tide (`tideUrl`). Directions keep the service's
|
|
17
|
+
// conventions, which are also Signal K's: wind, waves and swell are where
|
|
18
|
+
// they come *from*, the current where it flows *towards*.
|
|
19
|
+
const ATMOSPHERE = {
|
|
20
|
+
wind_speed_10m: 'windSpeed',
|
|
21
|
+
wind_direction_10m: 'windDirection',
|
|
22
|
+
wind_gusts_10m: 'windGust',
|
|
23
|
+
pressure_msl: 'pressure',
|
|
24
|
+
weather_code: 'weatherCode',
|
|
25
|
+
visibility: 'visibility',
|
|
26
|
+
precipitation: 'precipitation',
|
|
27
|
+
cloud_cover: 'cloudCover',
|
|
28
|
+
temperature_2m: 'airTemperature'
|
|
29
|
+
};
|
|
30
|
+
const SEA = {
|
|
31
|
+
wave_height: 'waveHeight',
|
|
32
|
+
wave_direction: 'waveDirection',
|
|
33
|
+
wave_period: 'wavePeriod',
|
|
34
|
+
swell_wave_height: 'swellHeight',
|
|
35
|
+
swell_wave_direction: 'swellDirection',
|
|
36
|
+
swell_wave_period: 'swellPeriod',
|
|
37
|
+
sea_surface_temperature: 'seaTemperature',
|
|
38
|
+
ocean_current_velocity: 'currentSpeed',
|
|
39
|
+
ocean_current_direction: 'currentDirection'
|
|
40
|
+
};
|
|
41
|
+
const FIELDS = [...Object.values(ATMOSPHERE), ...Object.values(SEA)];
|
|
42
|
+
|
|
43
|
+
// Values are converted to SI from the units each answer declares, rather than
|
|
44
|
+
// from what was asked for: the marine service ignores `wind_speed_unit` for
|
|
45
|
+
// the current, which comes in km/h.
|
|
46
|
+
const TO_SI = {
|
|
47
|
+
'm/s': (v) => v,
|
|
48
|
+
'km/h': (v) => v / 3.6,
|
|
49
|
+
kn: (v) => (v * 1852) / 3600,
|
|
50
|
+
mph: (v) => v * 0.44704,
|
|
51
|
+
'°': (v) => (v * Math.PI) / 180,
|
|
52
|
+
hPa: (v) => v * 100,
|
|
53
|
+
'°C': (v) => v + 273.15,
|
|
54
|
+
'°F': (v) => ((v - 32) * 5) / 9 + 273.15,
|
|
55
|
+
mm: (v) => v / 1000,
|
|
56
|
+
inch: (v) => v * 0.0254,
|
|
57
|
+
'%': (v) => v / 100,
|
|
58
|
+
m: (v) => v,
|
|
59
|
+
ft: (v) => v * 0.3048,
|
|
60
|
+
s: (v) => v,
|
|
61
|
+
'wmo code': (v) => v
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// { "2026-09-17T08:00:00.000Z": { windSpeed: 5.1, ... } } from one answer.
|
|
65
|
+
function readSeries(result, variables, into) {
|
|
66
|
+
const times = result?.hourly?.time ?? [];
|
|
67
|
+
const units = result?.hourly_units ?? {};
|
|
68
|
+
for (const [variable, field] of Object.entries(variables)) {
|
|
69
|
+
const values = result?.hourly?.[variable];
|
|
70
|
+
const convert = TO_SI[units[variable]];
|
|
71
|
+
if (!Array.isArray(values) || !convert) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
times.forEach((time, index) => {
|
|
75
|
+
const value = values[index];
|
|
76
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const key = utcTime(time);
|
|
80
|
+
if (!into.has(key)) {
|
|
81
|
+
into.set(key, {});
|
|
82
|
+
}
|
|
83
|
+
into.get(key)[field] = convert(value);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createWeatherForecaster({ db, settings, userAgent, fetch = globalThis.fetch }) {
|
|
89
|
+
function request(base, variables, position) {
|
|
90
|
+
const endpoint = new URL(base);
|
|
91
|
+
endpoint.searchParams.set('latitude', String(position.lat));
|
|
92
|
+
endpoint.searchParams.set('longitude', String(position.lon));
|
|
93
|
+
endpoint.searchParams.set('hourly', Object.keys(variables).join(','));
|
|
94
|
+
endpoint.searchParams.set('timezone', 'UTC');
|
|
95
|
+
endpoint.searchParams.set('past_days', '1');
|
|
96
|
+
endpoint.searchParams.set('forecast_days', '2');
|
|
97
|
+
endpoint.searchParams.set('wind_speed_unit', 'ms');
|
|
98
|
+
return endpoint;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function fetchWeather(position, signal) {
|
|
102
|
+
const options = { userAgent, signal };
|
|
103
|
+
const [atmosphere, sea] = await Promise.all([
|
|
104
|
+
getJson(fetch, request(settings.weatherUrl, ATMOSPHERE, position), {
|
|
105
|
+
...options,
|
|
106
|
+
label: 'Weather service'
|
|
107
|
+
}),
|
|
108
|
+
// The sea state is a complement: far from the sea it has nothing, and a
|
|
109
|
+
// failure there is not worth losing the wind forecast for.
|
|
110
|
+
getJson(fetch, request(settings.tideUrl, SEA, position), {
|
|
111
|
+
...options,
|
|
112
|
+
label: 'Marine service'
|
|
113
|
+
}).catch((error) => {
|
|
114
|
+
if (signal.aborted) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
})
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const byTime = new Map();
|
|
122
|
+
readSeries(atmosphere, ATMOSPHERE, byTime);
|
|
123
|
+
readSeries(sea, SEA, byTime);
|
|
124
|
+
return [...byTime.entries()]
|
|
125
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
126
|
+
.map(([time, values]) => {
|
|
127
|
+
const point = { time };
|
|
128
|
+
for (const field of FIELDS) {
|
|
129
|
+
point[field] = values[field] ?? null;
|
|
130
|
+
}
|
|
131
|
+
return point;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return createDepartureForecaster({
|
|
136
|
+
db,
|
|
137
|
+
table: TABLE,
|
|
138
|
+
isEnabled: () => settings.weatherEnabled,
|
|
139
|
+
fetchPoints: fetchWeather
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getWeatherForecast(db, entryId) {
|
|
144
|
+
return readForecast(db, TABLE, entryId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { createWeatherForecaster, getWeatherForecast, WEATHER_DEFAULTS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-chiplog",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"husky": "^9.1.7",
|
|
72
72
|
"leaflet": "^1.9.4",
|
|
73
73
|
"lint-staged": "^17.5.1",
|
|
74
|
+
"mediabunny": "1.58.0",
|
|
74
75
|
"preact": "^10.29.8",
|
|
75
76
|
"prettier": "^3.9.6"
|
|
76
77
|
}
|
package/public/app.css
CHANGED
|
@@ -111,6 +111,7 @@ main,
|
|
|
111
111
|
|
|
112
112
|
.topbar {
|
|
113
113
|
display: flex;
|
|
114
|
+
flex-wrap: wrap;
|
|
114
115
|
align-items: center;
|
|
115
116
|
justify-content: space-between;
|
|
116
117
|
gap: 1rem;
|
|
@@ -130,6 +131,7 @@ main,
|
|
|
130
131
|
|
|
131
132
|
.topbar nav {
|
|
132
133
|
display: flex;
|
|
134
|
+
flex-wrap: wrap;
|
|
133
135
|
gap: 0.25rem;
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -215,10 +217,11 @@ main {
|
|
|
215
217
|
gap: 0.5rem;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
/* Side by side on a wide screen, each about half width; stacked below that
|
|
220
|
+
/* Side by side on a wide screen, each about half width; stacked below that,
|
|
221
|
+
never wider than the screen. */
|
|
219
222
|
.card-row {
|
|
220
223
|
display: grid;
|
|
221
|
-
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
|
224
|
+
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
|
222
225
|
gap: 1rem;
|
|
223
226
|
margin: 1rem 0;
|
|
224
227
|
}
|
|
@@ -554,6 +557,15 @@ main {
|
|
|
554
557
|
min-width: 14rem;
|
|
555
558
|
}
|
|
556
559
|
|
|
560
|
+
/* Where the position is in relation to the nearest landmark, under it: a
|
|
561
|
+
reading in its own right, not a heading, so it keeps the coordinates’
|
|
562
|
+
place and only its colour sets it back. */
|
|
563
|
+
.timeline-landmark {
|
|
564
|
+
display: block;
|
|
565
|
+
color: var(--muted);
|
|
566
|
+
font-size: 0.85em;
|
|
567
|
+
}
|
|
568
|
+
|
|
557
569
|
.timeline-event .timeline-time {
|
|
558
570
|
font-weight: 600;
|
|
559
571
|
}
|
|
@@ -683,12 +695,90 @@ main {
|
|
|
683
695
|
margin-bottom: 0;
|
|
684
696
|
}
|
|
685
697
|
|
|
698
|
+
.crew-list {
|
|
699
|
+
margin: 0;
|
|
700
|
+
padding: 0;
|
|
701
|
+
list-style: none;
|
|
702
|
+
display: flex;
|
|
703
|
+
flex-direction: column;
|
|
704
|
+
gap: 0.4rem;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.crew-role {
|
|
708
|
+
color: var(--muted);
|
|
709
|
+
font-size: 0.9rem;
|
|
710
|
+
}
|
|
711
|
+
|
|
686
712
|
.tide-attribution {
|
|
687
713
|
font-size: 0.8rem;
|
|
688
714
|
margin-top: 0.3rem;
|
|
689
715
|
margin-bottom: 0;
|
|
690
716
|
}
|
|
691
717
|
|
|
718
|
+
/* Weather forecast */
|
|
719
|
+
|
|
720
|
+
/* The table scrolls sideways inside its card on a narrow screen; the page never does. */
|
|
721
|
+
.weather-scroll {
|
|
722
|
+
overflow-x: auto;
|
|
723
|
+
-webkit-overflow-scrolling: touch;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.weather-table {
|
|
727
|
+
width: 100%;
|
|
728
|
+
border-collapse: collapse;
|
|
729
|
+
font-variant-numeric: tabular-nums;
|
|
730
|
+
white-space: nowrap;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.weather-table th,
|
|
734
|
+
.weather-table td {
|
|
735
|
+
padding: 0.35rem 1rem 0.35rem 0;
|
|
736
|
+
text-align: left;
|
|
737
|
+
vertical-align: top;
|
|
738
|
+
border-bottom: 1px solid var(--border);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
.weather-table thead th {
|
|
742
|
+
color: var(--muted);
|
|
743
|
+
font-weight: 500;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.weather-table tbody th {
|
|
747
|
+
font-weight: 400;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.weather-main {
|
|
751
|
+
display: block;
|
|
752
|
+
font-weight: 600;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.weather-sub {
|
|
756
|
+
display: block;
|
|
757
|
+
color: var(--muted);
|
|
758
|
+
font-size: 0.85rem;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.weather-alert .weather-main {
|
|
762
|
+
color: var(--danger);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.weather-direction {
|
|
766
|
+
display: inline-flex;
|
|
767
|
+
align-items: center;
|
|
768
|
+
gap: 0.2rem;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.weather-arrow {
|
|
772
|
+
display: inline-block;
|
|
773
|
+
line-height: 1;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.weather-note {
|
|
777
|
+
font-size: 0.8rem;
|
|
778
|
+
margin-top: 0.5rem;
|
|
779
|
+
margin-bottom: 0;
|
|
780
|
+
}
|
|
781
|
+
|
|
692
782
|
/* Corrections */
|
|
693
783
|
|
|
694
784
|
.name-field {
|
|
@@ -740,6 +830,14 @@ main {
|
|
|
740
830
|
}
|
|
741
831
|
|
|
742
832
|
@media (max-width: 600px) {
|
|
833
|
+
.topbar {
|
|
834
|
+
gap: 0.25rem 1rem;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.topbar nav a {
|
|
838
|
+
padding: 0.35rem 0.5rem;
|
|
839
|
+
}
|
|
840
|
+
|
|
743
841
|
.passage-header h1 {
|
|
744
842
|
font-size: 1.25rem;
|
|
745
843
|
}
|
|
@@ -807,3 +905,115 @@ main {
|
|
|
807
905
|
color: var(--muted);
|
|
808
906
|
font-weight: 500;
|
|
809
907
|
}
|
|
908
|
+
|
|
909
|
+
/* Passage animation */
|
|
910
|
+
|
|
911
|
+
.card-header-actions {
|
|
912
|
+
display: flex;
|
|
913
|
+
flex-wrap: wrap;
|
|
914
|
+
align-items: baseline;
|
|
915
|
+
gap: 0.8rem;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.animation-stage {
|
|
919
|
+
display: flex;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.animation-canvas {
|
|
924
|
+
display: block;
|
|
925
|
+
max-width: 100%;
|
|
926
|
+
border-radius: 8px;
|
|
927
|
+
/* The sea the renderer paints, so a tile that has not arrived is not a hole. */
|
|
928
|
+
background: #aad3df;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.animation-controls {
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-wrap: wrap;
|
|
934
|
+
align-items: center;
|
|
935
|
+
gap: 0.6rem;
|
|
936
|
+
margin-top: 0.75rem;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/* Wide enough that swapping Play for Pause does not shift the slider. */
|
|
940
|
+
.animation-play {
|
|
941
|
+
min-width: 7.5rem;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
.animation-slider {
|
|
945
|
+
flex: 1 1 12rem;
|
|
946
|
+
min-width: 8rem;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* These change every frame; proportional digits jitter. */
|
|
950
|
+
.animation-clock,
|
|
951
|
+
.animation-facts dd {
|
|
952
|
+
font-variant-numeric: tabular-nums;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.animation-speeds {
|
|
956
|
+
display: flex;
|
|
957
|
+
gap: 0.3rem;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.animation-speed {
|
|
961
|
+
padding: 0.35rem 0.6rem;
|
|
962
|
+
background: none;
|
|
963
|
+
color: var(--accent);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.animation-speed[aria-pressed='true'] {
|
|
967
|
+
background: var(--accent);
|
|
968
|
+
color: var(--accent-contrast);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.animation-facts {
|
|
972
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
973
|
+
margin: 0.75rem 0 0;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.animation-seamarks {
|
|
977
|
+
display: flex;
|
|
978
|
+
align-items: center;
|
|
979
|
+
gap: 0.4rem;
|
|
980
|
+
margin-top: 0.75rem;
|
|
981
|
+
color: var(--muted);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.animation-export-controls {
|
|
985
|
+
display: flex;
|
|
986
|
+
flex-wrap: wrap;
|
|
987
|
+
align-items: baseline;
|
|
988
|
+
gap: 1rem;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.animation-export-format {
|
|
992
|
+
display: flex;
|
|
993
|
+
flex-direction: column;
|
|
994
|
+
gap: 0.25rem;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
.animation-export-format select {
|
|
998
|
+
font: inherit;
|
|
999
|
+
padding: 0.4rem 0.6rem;
|
|
1000
|
+
border-radius: 8px;
|
|
1001
|
+
border: 1px solid var(--border);
|
|
1002
|
+
background: var(--surface);
|
|
1003
|
+
color: var(--text);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.animation-progress {
|
|
1007
|
+
margin-top: 1rem;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.animation-progress progress {
|
|
1011
|
+
width: 100%;
|
|
1012
|
+
height: 0.6rem;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
@media (max-width: 600px) {
|
|
1016
|
+
.animation-facts {
|
|
1017
|
+
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
package/public/entry/entry.css
CHANGED
|
@@ -350,6 +350,18 @@ textarea:focus-visible {
|
|
|
350
350
|
margin-bottom: 0.75rem;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
.panel-header {
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
justify-content: space-between;
|
|
357
|
+
gap: 0.5rem;
|
|
358
|
+
margin-bottom: 0.75rem;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.panel-header .panel-title {
|
|
362
|
+
margin-bottom: 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
/* Manoeuvres */
|
|
354
366
|
|
|
355
367
|
.pad-grid {
|
|
@@ -362,6 +374,65 @@ textarea:focus-visible {
|
|
|
362
374
|
min-height: 5.5rem;
|
|
363
375
|
}
|
|
364
376
|
|
|
377
|
+
/* Crew */
|
|
378
|
+
|
|
379
|
+
.crew-list {
|
|
380
|
+
list-style: none;
|
|
381
|
+
margin: 0 0 0.75rem;
|
|
382
|
+
padding: 0;
|
|
383
|
+
display: flex;
|
|
384
|
+
flex-wrap: wrap;
|
|
385
|
+
gap: 0.5rem 1rem;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.crew-role {
|
|
389
|
+
/* Inherits its colour rather than fixing one, so it stays legible whether
|
|
390
|
+
it sits on the page background or inside a selected (primary) button. */
|
|
391
|
+
opacity: 0.75;
|
|
392
|
+
font-size: 0.85em;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.crew-grid {
|
|
396
|
+
display: grid;
|
|
397
|
+
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
|
398
|
+
gap: 0.75rem;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.crew-button-label {
|
|
402
|
+
display: flex;
|
|
403
|
+
flex-direction: column;
|
|
404
|
+
align-items: center;
|
|
405
|
+
line-height: 1.3;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.crew-item {
|
|
409
|
+
display: flex;
|
|
410
|
+
flex-direction: column;
|
|
411
|
+
gap: 0.4rem;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.crew-item .big-button {
|
|
415
|
+
flex: 1;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.crew-item-actions {
|
|
419
|
+
display: flex;
|
|
420
|
+
gap: 0.4rem;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.crew-item-actions .icon-button {
|
|
424
|
+
flex: 1;
|
|
425
|
+
min-height: 2.5rem;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.crew-editing-note {
|
|
429
|
+
display: flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
gap: 0.5rem;
|
|
432
|
+
color: var(--muted);
|
|
433
|
+
margin: 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
365
436
|
/* Note and handwriting */
|
|
366
437
|
|
|
367
438
|
.tabs {
|