signalk-chiplog 2.3.1 → 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 +22 -1
- package/README.md +32 -18
- package/lib/api.js +6 -0
- package/lib/database.js +13 -0
- package/lib/entries.js +23 -2
- package/lib/place-names.js +73 -20
- package/lib/places.js +1 -0
- package/lib/statistics.js +208 -0
- package/package.json +1 -1
- 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 +13 -23
- 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 +76 -12
- package/public/js/main.mjs +5 -0
- package/public/js/shortcuts.mjs +31 -0
- package/public/js/statistics.mjs +76 -0
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>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { html, useEffect, useRef, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { useLocale } from '../context.mjs';
|
|
3
|
+
import {
|
|
4
|
+
completeRange,
|
|
5
|
+
monthGrid,
|
|
6
|
+
monthOf,
|
|
7
|
+
monthTitle,
|
|
8
|
+
shiftMonth,
|
|
9
|
+
weekdayLabels
|
|
10
|
+
} from '../calendar.mjs';
|
|
11
|
+
import { dayKey, rangeBoundary } from '../days.mjs';
|
|
12
|
+
|
|
13
|
+
// One control for a period: a button showing it, and a calendar to pick it with
|
|
14
|
+
// two clicks -- the first and the last day. Either end may be left open by the
|
|
15
|
+
// shortcuts around it; a range is only ever changed here by completing one, or
|
|
16
|
+
// by clearing it where any date is allowed (`allowAny`).
|
|
17
|
+
export function DateRangePicker({ from, to, onChange, label, allowAny = true, disabled = false }) {
|
|
18
|
+
const { t, format, language } = useLocale();
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const [view, setView] = useState(() => monthOf(from || dayKey(new Date())));
|
|
21
|
+
// The first click of a range being picked, until the second one arrives.
|
|
22
|
+
const [anchor, setAnchor] = useState(null);
|
|
23
|
+
const [hover, setHover] = useState(null);
|
|
24
|
+
const root = useRef(null);
|
|
25
|
+
const trigger = useRef(null);
|
|
26
|
+
|
|
27
|
+
const close = () => {
|
|
28
|
+
setOpen(false);
|
|
29
|
+
setAnchor(null);
|
|
30
|
+
setHover(null);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const outside = (event) => {
|
|
38
|
+
if (!root.current?.contains(event.target)) {
|
|
39
|
+
close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const escape = (event) => {
|
|
43
|
+
if (event.key === 'Escape') {
|
|
44
|
+
close();
|
|
45
|
+
trigger.current?.focus();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
document.addEventListener('pointerdown', outside);
|
|
49
|
+
document.addEventListener('keydown', escape);
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener('pointerdown', outside);
|
|
52
|
+
document.removeEventListener('keydown', escape);
|
|
53
|
+
};
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
const toggle = () => {
|
|
57
|
+
if (open) {
|
|
58
|
+
close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setView(monthOf(from || dayKey(new Date())));
|
|
62
|
+
setOpen(true);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const clear = () => {
|
|
66
|
+
onChange({ from: '', to: '' });
|
|
67
|
+
close();
|
|
68
|
+
trigger.current?.focus();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const pick = (key) => {
|
|
72
|
+
if (anchor === null) {
|
|
73
|
+
setAnchor(key);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
onChange(completeRange(anchor, key));
|
|
77
|
+
close();
|
|
78
|
+
trigger.current?.focus();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// What the calendar highlights: the range being picked, with the day under
|
|
82
|
+
// the pointer as its provisional end, or else the one already chosen.
|
|
83
|
+
const shown = anchor === null ? { from, to } : completeRange(anchor, hover ?? anchor);
|
|
84
|
+
const dayText = (key) => format.date(rangeBoundary(key));
|
|
85
|
+
const summary =
|
|
86
|
+
!from && !to
|
|
87
|
+
? allowAny
|
|
88
|
+
? t('range.anyDate')
|
|
89
|
+
: t('range.choose')
|
|
90
|
+
: `${from ? dayText(from) : '…'} – ${to ? dayText(to) : '…'}`;
|
|
91
|
+
const today = dayKey(new Date());
|
|
92
|
+
|
|
93
|
+
const cellClass = (key) => {
|
|
94
|
+
const classes = ['cal-day'];
|
|
95
|
+
if (key === today) {
|
|
96
|
+
classes.push('cal-today');
|
|
97
|
+
}
|
|
98
|
+
if (shown.from && shown.to && key >= shown.from && key <= shown.to) {
|
|
99
|
+
classes.push('cal-in-range');
|
|
100
|
+
}
|
|
101
|
+
if (key === shown.from) {
|
|
102
|
+
classes.push('cal-start');
|
|
103
|
+
}
|
|
104
|
+
if (key === shown.to) {
|
|
105
|
+
classes.push('cal-end');
|
|
106
|
+
}
|
|
107
|
+
return classes.join(' ');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const nav = (delta, name, text) => html`
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
class="cal-nav"
|
|
114
|
+
aria-label=${name}
|
|
115
|
+
onClick=${() => setView(shiftMonth(view, delta))}
|
|
116
|
+
>
|
|
117
|
+
${text}
|
|
118
|
+
</button>
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
return html`
|
|
122
|
+
<div class="range-picker" ref=${root}>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
class="range-trigger"
|
|
126
|
+
ref=${trigger}
|
|
127
|
+
aria-haspopup="dialog"
|
|
128
|
+
aria-expanded=${open}
|
|
129
|
+
disabled=${disabled}
|
|
130
|
+
aria-label=${`${label}: ${summary}`}
|
|
131
|
+
onClick=${toggle}
|
|
132
|
+
>
|
|
133
|
+
<span aria-hidden="true">📅</span>
|
|
134
|
+
<span>${summary}</span>
|
|
135
|
+
</button>
|
|
136
|
+
${
|
|
137
|
+
open &&
|
|
138
|
+
html`
|
|
139
|
+
<div class="range-popover" role="dialog" aria-label=${label}>
|
|
140
|
+
<div class="cal-header">
|
|
141
|
+
${nav(-12, t('range.previousYear'), '«')}
|
|
142
|
+
${nav(-1, t('range.previousMonth'), '‹')}
|
|
143
|
+
<strong class="cal-title" aria-live="polite">${monthTitle(language, view)}</strong>
|
|
144
|
+
${nav(1, t('range.nextMonth'), '›')}
|
|
145
|
+
${nav(12, t('range.nextYear'), '»')}
|
|
146
|
+
</div>
|
|
147
|
+
<div class="cal-grid" onMouseLeave=${() => setHover(null)}>
|
|
148
|
+
${weekdayLabels(language).map(
|
|
149
|
+
(name) => html`<span class="cal-weekday" key=${name}>${name}</span>`
|
|
150
|
+
)}
|
|
151
|
+
${monthGrid(view.year, view.month).map((key, index) =>
|
|
152
|
+
key === null
|
|
153
|
+
? html`<span key=${`blank-${index}`}></span>`
|
|
154
|
+
: html`
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
key=${key}
|
|
158
|
+
class=${cellClass(key)}
|
|
159
|
+
aria-label=${format.day(rangeBoundary(key))}
|
|
160
|
+
aria-pressed=${key === shown.from || key === shown.to}
|
|
161
|
+
onClick=${() => pick(key)}
|
|
162
|
+
onMouseEnter=${() => setHover(key)}
|
|
163
|
+
onFocus=${() => setHover(key)}
|
|
164
|
+
>
|
|
165
|
+
${Number(key.slice(8))}
|
|
166
|
+
</button>
|
|
167
|
+
`
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
<div class="cal-footer">
|
|
171
|
+
<span class="muted cal-hint">
|
|
172
|
+
${anchor === null ? t('range.pickStart') : t('range.pickEnd')}
|
|
173
|
+
</span>
|
|
174
|
+
${
|
|
175
|
+
allowAny &&
|
|
176
|
+
(from || to) &&
|
|
177
|
+
html`<button type="button" class="link-button" onClick=${clear}>
|
|
178
|
+
${t('range.anyDate')}
|
|
179
|
+
</button>`
|
|
180
|
+
}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
`
|
|
184
|
+
}
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
@@ -2,6 +2,7 @@ import { html, useState } from '../../vendor/preact-htm.mjs';
|
|
|
2
2
|
import { apiUrl, get, request } from '../api.mjs';
|
|
3
3
|
import { useLocale, usePolling } from '../context.mjs';
|
|
4
4
|
import { rangeBoundary } from '../days.mjs';
|
|
5
|
+
import { DateRangePicker } from './DateRangePicker.mjs';
|
|
5
6
|
import { ErrorNotice } from './common.mjs';
|
|
6
7
|
|
|
7
8
|
const PLUGIN_CONFIGURATION = '/admin/#/serverConfiguration/plugins/signalk-chiplog';
|
|
@@ -108,7 +109,6 @@ export function ExportView() {
|
|
|
108
109
|
const [writing, setWriting] = useState(false);
|
|
109
110
|
const [status, setStatus] = useState(null);
|
|
110
111
|
const [version, setVersion] = useState(0);
|
|
111
|
-
const invalid = Boolean(from && to && to < from);
|
|
112
112
|
|
|
113
113
|
usePolling(
|
|
114
114
|
(isCurrent) => {
|
|
@@ -140,31 +140,21 @@ export function ExportView() {
|
|
|
140
140
|
|
|
141
141
|
<section class="card">
|
|
142
142
|
<p>${t('export.intro')}</p>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<label>
|
|
153
|
-
${t('export.to')}
|
|
154
|
-
<input type="date" value=${to} onInput=${(event) => setTo(event.currentTarget.value)} />
|
|
155
|
-
</label>
|
|
156
|
-
</div>
|
|
143
|
+
<${DateRangePicker}
|
|
144
|
+
from=${from}
|
|
145
|
+
to=${to}
|
|
146
|
+
label=${t('range.period')}
|
|
147
|
+
onChange=${(range) => {
|
|
148
|
+
setFrom(range.from);
|
|
149
|
+
setTo(range.to);
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
157
152
|
<p class="muted">${t('export.allHint')}</p>
|
|
158
|
-
${invalid && html`<p class="notice notice-error">${t('export.invalidRange')}</p>`}
|
|
159
153
|
<ul class="downloads">
|
|
160
154
|
${['pdf', 'json', 'csv', 'gpx'].map(
|
|
161
155
|
(format) =>
|
|
162
156
|
html`<li key=${format}>
|
|
163
|
-
|
|
164
|
-
invalid
|
|
165
|
-
? html`<span class="muted">${t(`export.${format}`)}</span>`
|
|
166
|
-
: html`<a href=${exportUrl(format, from, to, language)} download>${t(`export.${format}`)}</a>`
|
|
167
|
-
}
|
|
157
|
+
<a href=${exportUrl(format, from, to, language)} download>${t(`export.${format}`)}</a>
|
|
168
158
|
</li>`
|
|
169
159
|
)}
|
|
170
160
|
</ul>
|
|
@@ -11,6 +11,7 @@ import { Timeline } from './Timeline.mjs';
|
|
|
11
11
|
import { TrackMap } from './TrackMap.mjs';
|
|
12
12
|
import { TrackScrubber } from './TrackScrubber.mjs';
|
|
13
13
|
import { WeatherCard } from './WeatherCard.mjs';
|
|
14
|
+
import { stepDirection } from '../shortcuts.mjs';
|
|
14
15
|
import { trackPoints } from '../track.mjs';
|
|
15
16
|
|
|
16
17
|
const ACTIVE_REFRESH_MS = 60 * 1000;
|
|
@@ -236,6 +237,14 @@ function NameField({ label, name, pending, busy, onSave }) {
|
|
|
236
237
|
`;
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// A neighbouring passage, or its place kept as a dimmed label at the log's end.
|
|
241
|
+
// `shortcut` is the key that does the same, told in the tooltip.
|
|
242
|
+
function StepLink({ id, label, shortcut, keyName }) {
|
|
243
|
+
return id === null
|
|
244
|
+
? html`<span class="muted" aria-disabled="true">${label}</span>`
|
|
245
|
+
: html`<a href=${`#/passages/${id}`} title=${shortcut} aria-keyshortcuts=${keyName}>${label}</a>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
239
248
|
export function PassageView({ id }) {
|
|
240
249
|
const { t, format } = useLocale();
|
|
241
250
|
const [data, setData] = useState(null);
|
|
@@ -246,6 +255,28 @@ export function PassageView({ id }) {
|
|
|
246
255
|
// null follows the latest point as new ones arrive; scrubbing pins it.
|
|
247
256
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
|
248
257
|
const active = data?.entry.state === 'active';
|
|
258
|
+
// Only the neighbours of the passage on screen, not of the one just left.
|
|
259
|
+
const shown = data?.entry.id === id ? data.entry : null;
|
|
260
|
+
const previousId = shown?.previousEntryId ?? null;
|
|
261
|
+
const nextId = shown?.nextEntryId ?? null;
|
|
262
|
+
|
|
263
|
+
// Alt+arrow steps through the log. The key is taken even at either end of it,
|
|
264
|
+
// where it does nothing, rather than left to the browser's own Alt+left: back.
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const onKeyDown = (event) => {
|
|
267
|
+
const direction = stepDirection(event);
|
|
268
|
+
if (direction === null) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const target = direction === 'previous' ? previousId : nextId;
|
|
273
|
+
if (target !== null && !event.repeat) {
|
|
274
|
+
location.hash = `#/passages/${target}`;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
document.addEventListener('keydown', onKeyDown);
|
|
278
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
279
|
+
}, [previousId, nextId]);
|
|
249
280
|
|
|
250
281
|
// A different passage starts by following its latest point too, not
|
|
251
282
|
// wherever the scrubber was left on the previous one.
|
|
@@ -357,7 +388,23 @@ export function PassageView({ id }) {
|
|
|
357
388
|
const boat = points[scrubIndex] ?? null;
|
|
358
389
|
|
|
359
390
|
return html`
|
|
360
|
-
<
|
|
391
|
+
<div class="passage-nav">
|
|
392
|
+
<a class="back" href="#/">← ${t('passage.back')}</a>
|
|
393
|
+
<div class="passage-steps">
|
|
394
|
+
<${StepLink}
|
|
395
|
+
id=${entry.previousEntryId}
|
|
396
|
+
label=${`← ${t('passage.previous')}`}
|
|
397
|
+
shortcut="Alt+←"
|
|
398
|
+
keyName="Alt+ArrowLeft"
|
|
399
|
+
/>
|
|
400
|
+
<${StepLink}
|
|
401
|
+
id=${entry.nextEntryId}
|
|
402
|
+
label=${`${t('passage.next')} →`}
|
|
403
|
+
shortcut="Alt+→"
|
|
404
|
+
keyName="Alt+ArrowRight"
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
361
408
|
|
|
362
409
|
<header class="passage-header">
|
|
363
410
|
<h1>
|