signalk-chiplog 2.3.0 → 2.4.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 +44 -1
- package/README.md +32 -18
- package/index.js +24 -2
- package/lib/api.js +6 -0
- package/lib/database.js +13 -0
- package/lib/entries.js +23 -2
- package/lib/influx-history.js +59 -28
- package/lib/place-names.js +73 -20
- package/lib/places.js +1 -0
- package/lib/replay-job.js +39 -5
- package/lib/statistics.js +208 -0
- package/package.json +3 -2
- package/public/app.css +207 -4
- package/public/js/calendar.mjs +50 -0
- package/public/js/components/AnimationView.mjs +11 -14
- package/public/js/components/DateRangePicker.mjs +187 -0
- package/public/js/components/ExportView.mjs +11 -21
- package/public/js/components/PassageView.mjs +48 -1
- package/public/js/components/ReplayView.mjs +42 -33
- package/public/js/components/StatisticsView.mjs +249 -0
- package/public/js/context.mjs +3 -0
- package/public/js/format.mjs +8 -0
- package/public/js/i18n.mjs +79 -12
- package/public/js/main.mjs +5 -0
- package/public/js/shortcuts.mjs +31 -0
- package/public/js/statistics.mjs +76 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const { rangeWhere } = require('./entries');
|
|
2
|
+
const { distanceBetween } = require('./places');
|
|
3
|
+
|
|
4
|
+
const TOP_COUNT = 5;
|
|
5
|
+
|
|
6
|
+
// Each ranking of the statistics page, sorted on the passage figure of the same name.
|
|
7
|
+
const RANKINGS = ['duration', 'distance', 'averageSpeed', 'maxSpeed', 'maxWindSpeed'];
|
|
8
|
+
|
|
9
|
+
const seconds = (from, to) => Math.max(0, (Date.parse(to) - Date.parse(from)) / 1000);
|
|
10
|
+
|
|
11
|
+
const keepHigher = (current, candidate) =>
|
|
12
|
+
candidate !== null && (current === null || candidate > current) ? candidate : current;
|
|
13
|
+
|
|
14
|
+
// Highest reading per passage, from the dense track points and the sparse
|
|
15
|
+
// instrument snapshots alike -- the same sources as a single passage's own
|
|
16
|
+
// figures (GET /entries/:id), and the same preference for true wind over apparent.
|
|
17
|
+
function loadPeaks(db, where, params) {
|
|
18
|
+
const peaks = new Map();
|
|
19
|
+
const inRange = `entry_id IN (SELECT id FROM log_entries ${where})`;
|
|
20
|
+
for (const table of ['track_points', 'observations']) {
|
|
21
|
+
const rows = db
|
|
22
|
+
.prepare(
|
|
23
|
+
`SELECT entry_id, MAX(sog) AS speed, MAX(tws) AS tws, MAX(aws) AS aws
|
|
24
|
+
FROM ${table} WHERE ${inRange} GROUP BY entry_id`
|
|
25
|
+
)
|
|
26
|
+
.all(...params);
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
const peak = peaks.get(row.entry_id) ?? { speed: null, tws: null, aws: null };
|
|
29
|
+
peak.speed = keepHigher(peak.speed, row.speed);
|
|
30
|
+
peak.tws = keepHigher(peak.tws, row.tws);
|
|
31
|
+
peak.aws = keepHigher(peak.aws, row.aws);
|
|
32
|
+
peaks.set(row.entry_id, peak);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return peaks;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadStops(db, where, params) {
|
|
39
|
+
const stops = new Map();
|
|
40
|
+
const rows = db
|
|
41
|
+
.prepare(
|
|
42
|
+
`SELECT entry_id, time, comment FROM events
|
|
43
|
+
WHERE type = 'stopover' AND entry_id IN (SELECT id FROM log_entries ${where})
|
|
44
|
+
ORDER BY entry_id, time`
|
|
45
|
+
)
|
|
46
|
+
.all(...params);
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const list = stops.get(row.entry_id) ?? [];
|
|
49
|
+
list.push(row);
|
|
50
|
+
stops.set(row.entry_id, list);
|
|
51
|
+
}
|
|
52
|
+
return stops;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A passage is cut wherever it stopped over -- a lock, a lunch anchorage kept
|
|
56
|
+
// within one entry (SPEC §3.1) -- and each stretch between two stops is measured
|
|
57
|
+
// on its own from the track. A passage that never stopped is one stretch, and
|
|
58
|
+
// its own distance and times stand: no track needs reading for it.
|
|
59
|
+
function legsOf(db, entry, stops, now) {
|
|
60
|
+
if (stops.length === 0) {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
entryId: entry.id,
|
|
64
|
+
startTime: entry.start_time,
|
|
65
|
+
endTime: entry.end_time,
|
|
66
|
+
distance: entry.distance,
|
|
67
|
+
duration: seconds(entry.start_time, entry.end_time ?? now),
|
|
68
|
+
startPlaceName: entry.start_place_name,
|
|
69
|
+
endPlaceName: entry.end_place_name
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const legs = stops.map((stop, index) => ({
|
|
75
|
+
entryId: entry.id,
|
|
76
|
+
startTime: index === 0 ? entry.start_time : null,
|
|
77
|
+
endTime: stop.time,
|
|
78
|
+
distance: 0,
|
|
79
|
+
startPlaceName: index === 0 ? entry.start_place_name : stops[index - 1].comment,
|
|
80
|
+
endPlaceName: stop.comment
|
|
81
|
+
}));
|
|
82
|
+
legs.push({
|
|
83
|
+
entryId: entry.id,
|
|
84
|
+
startTime: null,
|
|
85
|
+
endTime: entry.end_time,
|
|
86
|
+
distance: 0,
|
|
87
|
+
startPlaceName: stops[stops.length - 1].comment,
|
|
88
|
+
endPlaceName: entry.end_place_name
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let leg = 0;
|
|
92
|
+
let previous = null;
|
|
93
|
+
const points = db
|
|
94
|
+
.prepare('SELECT time, lat, lon FROM track_points WHERE entry_id = ? ORDER BY time')
|
|
95
|
+
.iterate(entry.id);
|
|
96
|
+
for (const point of points) {
|
|
97
|
+
// A point at the very moment of a stop still belongs to the stretch that
|
|
98
|
+
// ended there.
|
|
99
|
+
while (leg < stops.length && point.time > stops[leg].time) {
|
|
100
|
+
leg += 1;
|
|
101
|
+
previous = null;
|
|
102
|
+
}
|
|
103
|
+
// A stretch starts when the vessel got under way again: its first point.
|
|
104
|
+
legs[leg].startTime ??= point.time;
|
|
105
|
+
if (previous) {
|
|
106
|
+
legs[leg].distance += distanceBetween(previous, point);
|
|
107
|
+
}
|
|
108
|
+
previous = point;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A stretch with no track has no distance to speak of, and no start time
|
|
112
|
+
// either; it cannot be the longest.
|
|
113
|
+
return legs
|
|
114
|
+
.filter((candidate) => candidate.startTime !== null)
|
|
115
|
+
.map((candidate) => ({
|
|
116
|
+
...candidate,
|
|
117
|
+
duration: seconds(candidate.startTime, candidate.endTime ?? now)
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Countries reached by a departure or an arrival in the range, in order of the
|
|
122
|
+
// first visit. A place whose country is not known yet -- geocoding off, or not
|
|
123
|
+
// answered so far -- is simply not counted.
|
|
124
|
+
function loadCountries(db, where, params, now) {
|
|
125
|
+
return db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT places.country_code AS code, MIN(visit.time) AS firstTime
|
|
128
|
+
FROM (
|
|
129
|
+
SELECT start_place_id AS place_id, start_time AS time FROM log_entries ${where}
|
|
130
|
+
UNION ALL
|
|
131
|
+
SELECT end_place_id, COALESCE(end_time, ?) FROM log_entries ${where}
|
|
132
|
+
) AS visit
|
|
133
|
+
JOIN places ON places.id = visit.place_id
|
|
134
|
+
WHERE places.country_code IS NOT NULL
|
|
135
|
+
GROUP BY places.country_code
|
|
136
|
+
ORDER BY firstTime, code`
|
|
137
|
+
)
|
|
138
|
+
.all(...params, now, ...params);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Figures across every passage the range matches (SPEC §4.14). A passage still
|
|
142
|
+
// in progress counts as it stands, as in GET /entries/stats.
|
|
143
|
+
function getStatistics(db, { from, to, now }) {
|
|
144
|
+
const { where, params } = rangeWhere({ from, to });
|
|
145
|
+
const entries = db
|
|
146
|
+
.prepare(`SELECT * FROM log_entries ${where} ORDER BY start_time, id`)
|
|
147
|
+
.all(...params);
|
|
148
|
+
const peaks = loadPeaks(db, where, params);
|
|
149
|
+
const stops = loadStops(db, where, params);
|
|
150
|
+
|
|
151
|
+
const passages = entries.map((entry) => {
|
|
152
|
+
const peak = peaks.get(entry.id) ?? { speed: null, tws: null, aws: null };
|
|
153
|
+
const duration = seconds(entry.start_time, entry.end_time ?? now);
|
|
154
|
+
return {
|
|
155
|
+
id: entry.id,
|
|
156
|
+
startTime: entry.start_time,
|
|
157
|
+
endTime: entry.end_time,
|
|
158
|
+
startPlaceName: entry.start_place_name,
|
|
159
|
+
endPlaceName: entry.end_place_name,
|
|
160
|
+
startPlacePending: entry.start_place_pending === 1,
|
|
161
|
+
endPlacePending: entry.end_place_pending === 1,
|
|
162
|
+
distance: entry.distance,
|
|
163
|
+
duration,
|
|
164
|
+
averageSpeed: duration > 0 ? entry.distance / duration : null,
|
|
165
|
+
maxSpeed: peak.speed,
|
|
166
|
+
maxWindSpeed: peak.tws ?? peak.aws,
|
|
167
|
+
maxWindApparent: peak.tws === null && peak.aws !== null
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const top = {};
|
|
172
|
+
for (const ranking of RANKINGS) {
|
|
173
|
+
top[ranking] = passages
|
|
174
|
+
.filter((passage) => passage[ranking] !== null && passage[ranking] > 0)
|
|
175
|
+
.sort(
|
|
176
|
+
(a, b) => b[ranking] - a[ranking] || b.startTime.localeCompare(a.startTime) || b.id - a.id
|
|
177
|
+
)
|
|
178
|
+
.slice(0, TOP_COUNT);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let longestNonStop = null;
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
for (const leg of legsOf(db, entry, stops.get(entry.id) ?? [], now)) {
|
|
184
|
+
if (leg.distance > 0 && (longestNonStop === null || leg.distance > longestNonStop.distance)) {
|
|
185
|
+
longestNonStop = leg;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
count: passages.length,
|
|
192
|
+
distance: passages.reduce((sum, passage) => sum + passage.distance, 0),
|
|
193
|
+
duration: Math.round(passages.reduce((sum, passage) => sum + passage.duration, 0)),
|
|
194
|
+
firstTime: passages[0]?.startTime ?? null,
|
|
195
|
+
lastTime: passages.reduce((latest, passage) => {
|
|
196
|
+
const end = passage.endTime ?? now;
|
|
197
|
+
return latest === null || end > latest ? end : latest;
|
|
198
|
+
}, null),
|
|
199
|
+
maxSpeed: top.maxSpeed[0]?.maxSpeed ?? null,
|
|
200
|
+
maxWindSpeed: top.maxWindSpeed[0]?.maxWindSpeed ?? null,
|
|
201
|
+
maxWindApparent: top.maxWindSpeed[0]?.maxWindApparent ?? false,
|
|
202
|
+
longestNonStop,
|
|
203
|
+
countries: loadCountries(db, where, params, now),
|
|
204
|
+
top
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { getStatistics, TOP_COUNT };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-chiplog",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"./docs/screenshots/04-entry-app.png"
|
|
37
37
|
],
|
|
38
38
|
"recommends": [
|
|
39
|
-
"signalk-to-influxdb"
|
|
39
|
+
"signalk-to-influxdb",
|
|
40
|
+
"signalk-autostate"
|
|
40
41
|
]
|
|
41
42
|
},
|
|
42
43
|
"author": "Cedric RICARD <ricard33@gmail.com>",
|
package/public/app.css
CHANGED
|
@@ -381,6 +381,24 @@ main {
|
|
|
381
381
|
margin-top: 1rem;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
.passage-nav {
|
|
385
|
+
display: flex;
|
|
386
|
+
flex-wrap: wrap;
|
|
387
|
+
align-items: baseline;
|
|
388
|
+
justify-content: space-between;
|
|
389
|
+
gap: 0.25rem 1rem;
|
|
390
|
+
margin-top: 1rem;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.passage-nav .back {
|
|
394
|
+
margin-top: 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.passage-steps {
|
|
398
|
+
display: flex;
|
|
399
|
+
gap: 1.25rem;
|
|
400
|
+
}
|
|
401
|
+
|
|
384
402
|
.passage-header h1 {
|
|
385
403
|
margin: 0.5rem 0 0.2rem;
|
|
386
404
|
font-size: 1.5rem;
|
|
@@ -809,16 +827,201 @@ main {
|
|
|
809
827
|
|
|
810
828
|
/* Export */
|
|
811
829
|
|
|
812
|
-
|
|
830
|
+
/* Statistics */
|
|
831
|
+
.presets {
|
|
813
832
|
display: flex;
|
|
814
833
|
flex-wrap: wrap;
|
|
815
|
-
gap:
|
|
834
|
+
gap: 0.5rem;
|
|
835
|
+
margin-bottom: 1rem;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/* The range picker: a button, and a calendar dropping from it. */
|
|
839
|
+
.range-picker {
|
|
840
|
+
position: relative;
|
|
841
|
+
display: inline-block;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
button.range-trigger {
|
|
845
|
+
display: inline-flex;
|
|
846
|
+
align-items: center;
|
|
847
|
+
gap: 0.5rem;
|
|
848
|
+
background: var(--surface);
|
|
849
|
+
color: var(--text);
|
|
850
|
+
border-color: var(--border);
|
|
816
851
|
}
|
|
817
852
|
|
|
818
|
-
.
|
|
853
|
+
.range-popover {
|
|
854
|
+
position: absolute;
|
|
855
|
+
z-index: 10;
|
|
856
|
+
top: calc(100% + 0.4rem);
|
|
857
|
+
left: 0;
|
|
858
|
+
width: min(20rem, calc(100vw - 2rem));
|
|
859
|
+
padding: 0.75rem;
|
|
860
|
+
border-radius: var(--radius);
|
|
861
|
+
background: var(--surface);
|
|
862
|
+
border: 1px solid var(--border);
|
|
863
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.cal-header {
|
|
819
867
|
display: flex;
|
|
820
|
-
|
|
868
|
+
align-items: center;
|
|
821
869
|
gap: 0.25rem;
|
|
870
|
+
margin-bottom: 0.5rem;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.cal-title {
|
|
874
|
+
flex: 1;
|
|
875
|
+
text-align: center;
|
|
876
|
+
text-transform: capitalize;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
button.cal-nav {
|
|
880
|
+
padding: 0.3rem 0.6rem;
|
|
881
|
+
background: none;
|
|
882
|
+
border-color: var(--border);
|
|
883
|
+
color: var(--accent);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
.cal-grid {
|
|
887
|
+
display: grid;
|
|
888
|
+
grid-template-columns: repeat(7, 1fr);
|
|
889
|
+
row-gap: 2px;
|
|
890
|
+
text-align: center;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
.cal-weekday {
|
|
894
|
+
color: var(--muted);
|
|
895
|
+
font-size: 0.75rem;
|
|
896
|
+
padding-bottom: 0.25rem;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/* Days run into each other along a row, so a range reads as one band. */
|
|
900
|
+
button.cal-day {
|
|
901
|
+
height: 2.5rem;
|
|
902
|
+
padding: 0;
|
|
903
|
+
border: none;
|
|
904
|
+
border-radius: 0;
|
|
905
|
+
background: none;
|
|
906
|
+
color: var(--text);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
button.cal-today {
|
|
910
|
+
font-weight: 700;
|
|
911
|
+
text-decoration: underline;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
button.cal-in-range {
|
|
915
|
+
background: var(--bg);
|
|
916
|
+
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
button.cal-start,
|
|
920
|
+
button.cal-end {
|
|
921
|
+
background: var(--accent);
|
|
922
|
+
color: var(--accent-contrast);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
button.cal-start {
|
|
926
|
+
border-radius: 8px 0 0 8px;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
button.cal-end {
|
|
930
|
+
border-radius: 0 8px 8px 0;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
button.cal-start.cal-end {
|
|
934
|
+
border-radius: 8px;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
.cal-footer {
|
|
938
|
+
display: flex;
|
|
939
|
+
flex-wrap: wrap;
|
|
940
|
+
align-items: baseline;
|
|
941
|
+
justify-content: space-between;
|
|
942
|
+
gap: 0.25rem 0.5rem;
|
|
943
|
+
margin-top: 0.5rem;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
.cal-hint {
|
|
947
|
+
font-size: 0.85rem;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
button.preset {
|
|
951
|
+
background: var(--surface);
|
|
952
|
+
color: var(--accent);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
button.preset-on {
|
|
956
|
+
background: var(--accent);
|
|
957
|
+
color: var(--accent-contrast);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.statistics-loading {
|
|
961
|
+
opacity: 0.6;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.longest-facts {
|
|
965
|
+
margin: 0 0 0.4rem;
|
|
966
|
+
font-size: 1.15rem;
|
|
967
|
+
font-weight: 600;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.countries {
|
|
971
|
+
display: flex;
|
|
972
|
+
flex-wrap: wrap;
|
|
973
|
+
gap: 0.6rem 1.2rem;
|
|
974
|
+
margin: 0;
|
|
975
|
+
padding: 0;
|
|
976
|
+
list-style: none;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.countries li {
|
|
980
|
+
display: flex;
|
|
981
|
+
align-items: center;
|
|
982
|
+
gap: 0.4rem;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.countries .flag {
|
|
986
|
+
font-size: 1.8rem;
|
|
987
|
+
line-height: 1;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.ranking {
|
|
991
|
+
margin: 0;
|
|
992
|
+
padding-left: 1.4rem;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.ranking li {
|
|
996
|
+
padding: 0.35rem 0;
|
|
997
|
+
border-top: 1px solid var(--border);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.ranking li:first-child {
|
|
1001
|
+
border-top: none;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.ranking a {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
align-items: baseline;
|
|
1007
|
+
justify-content: space-between;
|
|
1008
|
+
gap: 0.75rem;
|
|
1009
|
+
color: var(--text);
|
|
1010
|
+
text-decoration: none;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.ranking-what {
|
|
1014
|
+
display: flex;
|
|
1015
|
+
flex-direction: column;
|
|
1016
|
+
min-width: 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.ranking-route {
|
|
1020
|
+
overflow-wrap: anywhere;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.ranking-value {
|
|
1024
|
+
white-space: nowrap;
|
|
822
1025
|
}
|
|
823
1026
|
|
|
824
1027
|
.downloads {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pure helpers for the range picker's calendar: the grid of a month, moving
|
|
2
|
+
// between months, and how two clicks make a range. Nothing here touches the
|
|
3
|
+
// DOM, so tests run them under Node. Days are local `YYYY-MM-DD` keys, the
|
|
4
|
+
// same as a date input gives and `days.mjs` takes.
|
|
5
|
+
|
|
6
|
+
import { dayKey } from './days.mjs';
|
|
7
|
+
|
|
8
|
+
const WEEK_DAYS = 7;
|
|
9
|
+
// Always six rows, so the calendar does not change height from month to month.
|
|
10
|
+
const GRID_CELLS = 6 * WEEK_DAYS;
|
|
11
|
+
|
|
12
|
+
// The days of a month laid out Monday first, the days of the neighbouring
|
|
13
|
+
// months left as `null`.
|
|
14
|
+
export function monthGrid(year, month) {
|
|
15
|
+
const leading = (new Date(year, month, 1).getDay() + 6) % WEEK_DAYS;
|
|
16
|
+
return Array.from({ length: GRID_CELLS }, (_, index) => {
|
|
17
|
+
const date = new Date(year, month, 1 - leading + index);
|
|
18
|
+
return date.getMonth() === new Date(year, month, 1).getMonth() ? dayKey(date) : null;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The month `delta` months on, carrying over the year.
|
|
23
|
+
export function shiftMonth({ year, month }, delta) {
|
|
24
|
+
const date = new Date(year, month + delta, 1);
|
|
25
|
+
return { year: date.getFullYear(), month: date.getMonth() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function monthOf(key) {
|
|
29
|
+
const [year, month] = key.split('-').map(Number);
|
|
30
|
+
return { year, month: month - 1 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Two clicks, in whichever order: the earlier day starts the range.
|
|
34
|
+
export function completeRange(anchor, day) {
|
|
35
|
+
return anchor <= day ? { from: anchor, to: day } : { from: day, to: anchor };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function weekdayLabels(locale) {
|
|
39
|
+
const format = new Intl.DateTimeFormat(locale, { weekday: 'short' });
|
|
40
|
+
// 1 January 2024 is a Monday.
|
|
41
|
+
return Array.from({ length: WEEK_DAYS }, (_, index) =>
|
|
42
|
+
format.format(new Date(2024, 0, 1 + index))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function monthTitle(locale, { year, month }) {
|
|
47
|
+
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(
|
|
48
|
+
new Date(year, month, 1)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -11,6 +11,7 @@ import { SPEEDS } from '../animation/schedule.mjs';
|
|
|
11
11
|
import { buildLegs, buildStoryboard, stateAt } from '../animation/storyboard.mjs';
|
|
12
12
|
import { createTileCache, TILE_LAYERS } from '../animation/tiles.mjs';
|
|
13
13
|
import { AnimationExport } from './AnimationExport.mjs';
|
|
14
|
+
import { DateRangePicker } from './DateRangePicker.mjs';
|
|
14
15
|
import { ErrorNotice } from './common.mjs';
|
|
15
16
|
|
|
16
17
|
const TRACK_CONCURRENCY = 4;
|
|
@@ -78,7 +79,6 @@ export function AnimationView({ from: initialFrom, to: initialTo }) {
|
|
|
78
79
|
cache.current = createTileCache();
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const invalid = Boolean(from && to && to < from);
|
|
82
82
|
const videoFormat = formatById(formatId);
|
|
83
83
|
const layers = useMemo(
|
|
84
84
|
() => (seamarks ? TILE_LAYERS : TILE_LAYERS.filter((layer) => layer.id !== 'seamarks')),
|
|
@@ -308,20 +308,17 @@ export function AnimationView({ from: initialFrom, to: initialTo }) {
|
|
|
308
308
|
|
|
309
309
|
<section class="card">
|
|
310
310
|
<p>${t('animation.intro')}</p>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
</div>
|
|
321
|
-
${invalid && html`<p class="notice notice-error">${t('animation.invalidRange')}</p>`}
|
|
311
|
+
<${DateRangePicker}
|
|
312
|
+
from=${from}
|
|
313
|
+
to=${to}
|
|
314
|
+
label=${t('range.period')}
|
|
315
|
+
onChange=${(range) => {
|
|
316
|
+
setFrom(range.from);
|
|
317
|
+
setTo(range.to);
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
322
320
|
${
|
|
323
321
|
summary &&
|
|
324
|
-
!invalid &&
|
|
325
322
|
html`<p class="muted">
|
|
326
323
|
${t('animation.summary', {
|
|
327
324
|
passages: summary.count,
|
|
@@ -331,7 +328,7 @@ export function AnimationView({ from: initialFrom, to: initialTo }) {
|
|
|
331
328
|
</p>`
|
|
332
329
|
}
|
|
333
330
|
<div class="actions">
|
|
334
|
-
<button type="button" disabled=${
|
|
331
|
+
<button type="button" disabled=${loading !== null} onClick=${load}>
|
|
335
332
|
${t('animation.load')}
|
|
336
333
|
</button>
|
|
337
334
|
</div>
|