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/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
- .date-range {
830
+ /* Statistics */
831
+ .presets {
813
832
  display: flex;
814
833
  flex-wrap: wrap;
815
- gap: 1rem;
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
- .date-range label {
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
- flex-direction: column;
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
- <div class="date-range">
312
- <label>
313
- ${t('animation.from')}
314
- <input type="date" value=${from} onInput=${(event) => setFrom(event.currentTarget.value)} />
315
- </label>
316
- <label>
317
- ${t('animation.to')}
318
- <input type="date" value=${to} onInput=${(event) => setTo(event.currentTarget.value)} />
319
- </label>
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=${invalid || loading !== null} onClick=${load}>
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
- <div class="date-range">
144
- <label>
145
- ${t('export.from')}
146
- <input
147
- type="date"
148
- value=${from}
149
- onInput=${(event) => setFrom(event.currentTarget.value)}
150
- />
151
- </label>
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
- <a class="back" href="#/">← ${t('passage.back')}</a>
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>