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
package/public/js/context.mjs
CHANGED
|
@@ -27,6 +27,9 @@ function parseRoute(hash) {
|
|
|
27
27
|
if (hash === '#/export') {
|
|
28
28
|
return { name: 'export' };
|
|
29
29
|
}
|
|
30
|
+
if (hash === '#/statistics') {
|
|
31
|
+
return { name: 'statistics' };
|
|
32
|
+
}
|
|
30
33
|
// The dates a passage page hands over travel in the hash; the playback state
|
|
31
34
|
// does not, since every hash change scrolls the page back to the top.
|
|
32
35
|
const animation = hash.match(/^#\/animation(?:\?(.*))?$/);
|
package/public/js/format.mjs
CHANGED
|
@@ -33,6 +33,12 @@ export function createFormatter({ locale, units, timeZone }) {
|
|
|
33
33
|
month: 'short',
|
|
34
34
|
timeZone
|
|
35
35
|
});
|
|
36
|
+
const dateFormat = new Intl.DateTimeFormat(locale, {
|
|
37
|
+
day: 'numeric',
|
|
38
|
+
month: 'short',
|
|
39
|
+
year: 'numeric',
|
|
40
|
+
timeZone
|
|
41
|
+
});
|
|
36
42
|
// Logbooks keep time on the 24-hour clock, whatever the language.
|
|
37
43
|
const timeFormat = new Intl.DateTimeFormat(locale, {
|
|
38
44
|
hour: '2-digit',
|
|
@@ -128,6 +134,8 @@ export function createFormatter({ locale, units, timeZone }) {
|
|
|
128
134
|
: '',
|
|
129
135
|
day: (date) => dayFormat.format(date),
|
|
130
136
|
shortDate: (value) => shortDateFormat.format(new Date(value)),
|
|
137
|
+
// With the year, for figures that span seasons.
|
|
138
|
+
date: (value) => dateFormat.format(new Date(value)),
|
|
131
139
|
time: (value) => timeFormat.format(new Date(value)),
|
|
132
140
|
// The calendar day of an instant in the formatter's time zone.
|
|
133
141
|
dayKey: (value) => dayKeyFormat.format(new Date(value)),
|
package/public/js/i18n.mjs
CHANGED
|
@@ -5,6 +5,7 @@ export const MESSAGES = {
|
|
|
5
5
|
en: {
|
|
6
6
|
'app.title': 'Chiplog',
|
|
7
7
|
'nav.log': 'Logbook',
|
|
8
|
+
'nav.statistics': 'Statistics',
|
|
8
9
|
'nav.export': 'Export',
|
|
9
10
|
'nav.replay': 'Retrospective',
|
|
10
11
|
'nav.animation': 'Animation',
|
|
@@ -55,10 +56,47 @@ export const MESSAGES = {
|
|
|
55
56
|
'log.statsDistance': 'Distance',
|
|
56
57
|
'log.statsDuration': 'Total time',
|
|
57
58
|
|
|
59
|
+
'statistics.title': 'Statistics',
|
|
60
|
+
'range.period': 'Period',
|
|
61
|
+
'range.anyDate': 'Any date',
|
|
62
|
+
'range.choose': 'Choose the period',
|
|
63
|
+
'range.previousYear': 'Previous year',
|
|
64
|
+
'range.previousMonth': 'Previous month',
|
|
65
|
+
'range.nextMonth': 'Next month',
|
|
66
|
+
'range.nextYear': 'Next year',
|
|
67
|
+
'range.pickStart': 'Pick the first day.',
|
|
68
|
+
'range.pickEnd': 'Now pick the last day.',
|
|
69
|
+
'statistics.range.all': 'All time',
|
|
70
|
+
'statistics.range.thisMonth': 'This month',
|
|
71
|
+
'statistics.range.lastMonth': 'Last month',
|
|
72
|
+
'statistics.range.last12Months': 'Last 12 months',
|
|
73
|
+
'statistics.empty': 'No passage in this period.',
|
|
74
|
+
'statistics.count': 'Passages',
|
|
75
|
+
'statistics.first': 'First passage',
|
|
76
|
+
'statistics.last': 'Last passage',
|
|
77
|
+
'statistics.distance': 'Total distance',
|
|
78
|
+
'statistics.duration': 'Time under way',
|
|
79
|
+
'statistics.maxSpeed': 'Top speed',
|
|
80
|
+
'statistics.maxWind': 'Strongest wind',
|
|
81
|
+
'statistics.apparent': '{speed} (apparent)',
|
|
82
|
+
'statistics.longest': 'Longest passage without a stop',
|
|
83
|
+
'statistics.longestFacts': '{distance} in {duration}',
|
|
84
|
+
'statistics.countries': 'Countries visited',
|
|
85
|
+
'statistics.noCountries':
|
|
86
|
+
'No country known yet. Countries come with place names, which needs geocoding to be enabled and the boat to be online now and then.',
|
|
87
|
+
'statistics.topDuration': 'Longest passages',
|
|
88
|
+
'statistics.topDistance': 'Greatest distances',
|
|
89
|
+
'statistics.topAverageSpeed': 'Highest average speeds',
|
|
90
|
+
'statistics.topMaxSpeed': 'Highest top speeds',
|
|
91
|
+
'statistics.topMaxWind': 'Strongest winds',
|
|
92
|
+
'statistics.noData': 'No data',
|
|
93
|
+
|
|
58
94
|
'place.unknown': 'Unnamed place',
|
|
59
95
|
'place.pending': 'Provisional name: waiting for geocoding',
|
|
60
96
|
|
|
61
97
|
'passage.back': 'Back to the logbook',
|
|
98
|
+
'passage.previous': 'Previous passage',
|
|
99
|
+
'passage.next': 'Next passage',
|
|
62
100
|
'passage.notFound': 'This passage does not exist.',
|
|
63
101
|
'passage.inProgress': 'In progress',
|
|
64
102
|
'passage.stoppedSince': 'Stopped since {time}',
|
|
@@ -169,9 +207,6 @@ export const MESSAGES = {
|
|
|
169
207
|
'animation.title': 'Passage animation',
|
|
170
208
|
'animation.intro':
|
|
171
209
|
'Replay every passage in a date range on the map, one hour of sailing per second, and save it as a video. Everything is computed in the browser; only the map tiles are downloaded.',
|
|
172
|
-
'animation.from': 'From',
|
|
173
|
-
'animation.to': 'To',
|
|
174
|
-
'animation.invalidRange': 'The end date comes before the start date.',
|
|
175
210
|
'animation.summary': '{passages} passages · {distance} · {duration}',
|
|
176
211
|
'animation.load': 'Load the passages',
|
|
177
212
|
'animation.loading': 'Loading track {done} of {total}…',
|
|
@@ -289,10 +324,7 @@ export const MESSAGES = {
|
|
|
289
324
|
|
|
290
325
|
'export.title': 'Export',
|
|
291
326
|
'export.intro': 'Download the logbook, whole or for a period.',
|
|
292
|
-
'export.from': 'From',
|
|
293
|
-
'export.to': 'To',
|
|
294
327
|
'export.allHint': 'Leave the dates empty to export everything.',
|
|
295
|
-
'export.invalidRange': 'The end date cannot be before the start date.',
|
|
296
328
|
'export.json': 'JSON — the complete record',
|
|
297
329
|
'export.csv': 'CSV — logbook lines for a spreadsheet',
|
|
298
330
|
'export.gpx': 'GPX — tracks',
|
|
@@ -351,6 +383,7 @@ export const MESSAGES = {
|
|
|
351
383
|
'replay.start': 'Reconstruct',
|
|
352
384
|
'replay.cancel': 'Cancel',
|
|
353
385
|
'replay.progress': '{percent}% — at {time}',
|
|
386
|
+
'replay.retrying': 'Retrying after a timeout (attempt {attempt}/{of}): {message}',
|
|
354
387
|
'replay.done': 'Reconstruction finished at {time}.',
|
|
355
388
|
'replay.cancelled': 'Reconstruction cancelled at {time}.',
|
|
356
389
|
'replay.failed': 'Reconstruction failed at {time}: {message}',
|
|
@@ -450,6 +483,7 @@ export const MESSAGES = {
|
|
|
450
483
|
fr: {
|
|
451
484
|
'app.title': 'Chiplog',
|
|
452
485
|
'nav.log': 'Journal',
|
|
486
|
+
'nav.statistics': 'Statistiques',
|
|
453
487
|
'nav.export': 'Export',
|
|
454
488
|
'nav.replay': 'Rétrospective',
|
|
455
489
|
'nav.animation': 'Animation',
|
|
@@ -501,10 +535,47 @@ export const MESSAGES = {
|
|
|
501
535
|
'log.statsDistance': 'Distance',
|
|
502
536
|
'log.statsDuration': 'Temps total',
|
|
503
537
|
|
|
538
|
+
'statistics.title': 'Statistiques',
|
|
539
|
+
'range.period': 'Période',
|
|
540
|
+
'range.anyDate': 'Toutes les dates',
|
|
541
|
+
'range.choose': 'Choisir la période',
|
|
542
|
+
'range.previousYear': 'Année précédente',
|
|
543
|
+
'range.previousMonth': 'Mois précédent',
|
|
544
|
+
'range.nextMonth': 'Mois suivant',
|
|
545
|
+
'range.nextYear': 'Année suivante',
|
|
546
|
+
'range.pickStart': 'Choisissez le premier jour.',
|
|
547
|
+
'range.pickEnd': 'Choisissez maintenant le dernier jour.',
|
|
548
|
+
'statistics.range.all': 'Tout',
|
|
549
|
+
'statistics.range.thisMonth': 'Ce mois-ci',
|
|
550
|
+
'statistics.range.lastMonth': 'Le mois dernier',
|
|
551
|
+
'statistics.range.last12Months': '12 derniers mois',
|
|
552
|
+
'statistics.empty': 'Aucune navigation sur cette période.',
|
|
553
|
+
'statistics.count': 'Navigations',
|
|
554
|
+
'statistics.first': 'Première navigation',
|
|
555
|
+
'statistics.last': 'Dernière navigation',
|
|
556
|
+
'statistics.distance': 'Distance totale',
|
|
557
|
+
'statistics.duration': 'Temps en navigation',
|
|
558
|
+
'statistics.maxSpeed': 'Vitesse max',
|
|
559
|
+
'statistics.maxWind': 'Vent max',
|
|
560
|
+
'statistics.apparent': '{speed} (apparent)',
|
|
561
|
+
'statistics.longest': 'Plus longue navigation sans escale',
|
|
562
|
+
'statistics.longestFacts': '{distance} en {duration}',
|
|
563
|
+
'statistics.countries': 'Pays visités',
|
|
564
|
+
'statistics.noCountries':
|
|
565
|
+
'Aucun pays connu pour l’instant. Ils viennent avec les noms de lieux, ce qui demande que le géocodage soit activé et que le bateau soit de temps en temps en ligne.',
|
|
566
|
+
'statistics.topDuration': 'Navigations les plus longues',
|
|
567
|
+
'statistics.topDistance': 'Plus grandes distances',
|
|
568
|
+
'statistics.topAverageSpeed': 'Meilleures vitesses moyennes',
|
|
569
|
+
'statistics.topMaxSpeed': 'Vitesses max les plus élevées',
|
|
570
|
+
'statistics.topMaxWind': 'Vents les plus forts',
|
|
571
|
+
'statistics.noData': 'Aucune donnée',
|
|
572
|
+
|
|
504
573
|
'place.unknown': 'Lieu sans nom',
|
|
505
574
|
'place.pending': 'Nom provisoire : en attente du géocodage',
|
|
506
575
|
|
|
507
576
|
'passage.back': 'Retour au journal',
|
|
577
|
+
'passage.previous': 'Navigation précédente',
|
|
578
|
+
'passage.next': 'Navigation suivante',
|
|
508
579
|
'passage.notFound': 'Cette navigation n’existe pas.',
|
|
509
580
|
'passage.inProgress': 'En cours',
|
|
510
581
|
'passage.stoppedSince': 'À l’arrêt depuis {time}',
|
|
@@ -617,9 +688,6 @@ export const MESSAGES = {
|
|
|
617
688
|
'animation.title': 'Animation des navigations',
|
|
618
689
|
'animation.intro':
|
|
619
690
|
'Rejouez sur la carte toutes les navigations d’une plage de dates, une heure de navigation par seconde, et enregistrez-les en vidéo. Tout est calculé dans le navigateur ; seuls les fonds de carte sont téléchargés.',
|
|
620
|
-
'animation.from': 'Du',
|
|
621
|
-
'animation.to': 'Au',
|
|
622
|
-
'animation.invalidRange': 'La date de fin précède la date de début.',
|
|
623
691
|
'animation.summary': '{passages} navigations · {distance} · {duration}',
|
|
624
692
|
'animation.load': 'Charger les navigations',
|
|
625
693
|
'animation.loading': 'Chargement de la trace {done} sur {total}…',
|
|
@@ -739,10 +807,7 @@ export const MESSAGES = {
|
|
|
739
807
|
|
|
740
808
|
'export.title': 'Export',
|
|
741
809
|
'export.intro': 'Téléchargez le journal, en entier ou sur une période.',
|
|
742
|
-
'export.from': 'Du',
|
|
743
|
-
'export.to': 'Au',
|
|
744
810
|
'export.allHint': 'Laissez les dates vides pour tout exporter.',
|
|
745
|
-
'export.invalidRange': 'La date de fin ne peut pas précéder la date de début.',
|
|
746
811
|
'export.json': 'JSON — l’enregistrement complet',
|
|
747
812
|
'export.csv': 'CSV — les lignes du journal, pour un tableur',
|
|
748
813
|
'export.gpx': 'GPX — les traces',
|
|
@@ -802,6 +867,8 @@ export const MESSAGES = {
|
|
|
802
867
|
'replay.start': 'Reconstruire',
|
|
803
868
|
'replay.cancel': 'Annuler',
|
|
804
869
|
'replay.progress': '{percent} % — à {time}',
|
|
870
|
+
'replay.retrying':
|
|
871
|
+
'Nouvelle tentative après un délai dépassé (essai {attempt}/{of}) : {message}',
|
|
805
872
|
'replay.done': 'Reconstruction terminée à {time}.',
|
|
806
873
|
'replay.cancelled': 'Reconstruction annulée à {time}.',
|
|
807
874
|
'replay.failed': 'Échec de la reconstruction à {time} : {message}',
|
package/public/js/main.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { ExportView } from './components/ExportView.mjs';
|
|
|
6
6
|
import { LogView } from './components/LogView.mjs';
|
|
7
7
|
import { PassageView } from './components/PassageView.mjs';
|
|
8
8
|
import { ReplayView } from './components/ReplayView.mjs';
|
|
9
|
+
import { StatisticsView } from './components/StatisticsView.mjs';
|
|
9
10
|
import { StatusBar } from './components/StatusBar.mjs';
|
|
10
11
|
|
|
11
12
|
function Page({ route }) {
|
|
@@ -15,6 +16,9 @@ function Page({ route }) {
|
|
|
15
16
|
if (route.name === 'export') {
|
|
16
17
|
return html`<${ExportView} />`;
|
|
17
18
|
}
|
|
19
|
+
if (route.name === 'statistics') {
|
|
20
|
+
return html`<${StatisticsView} />`;
|
|
21
|
+
}
|
|
18
22
|
if (route.name === 'animation') {
|
|
19
23
|
// Keyed on the range, so arriving from a passage page with dates in the
|
|
20
24
|
// hash starts on those dates rather than keeping the previous ones.
|
|
@@ -36,6 +40,7 @@ function Shell() {
|
|
|
36
40
|
</a>
|
|
37
41
|
<nav>
|
|
38
42
|
<a href="#/" aria-current=${current('log')}>${t('nav.log')}</a>
|
|
43
|
+
<a href="#/statistics" aria-current=${current('statistics')}>${t('nav.statistics')}</a>
|
|
39
44
|
<a href="#/animation" aria-current=${current('animation')}>${t('nav.animation')}</a>
|
|
40
45
|
<a href="#/export" aria-current=${current('export')}>${t('nav.export')}</a>
|
|
41
46
|
<a href="#/replay" aria-current=${current('replay')}>${t('nav.replay')}</a>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Keyboard shortcuts of the webapp. Pure, so tests run them under Node with
|
|
2
|
+
// plain objects standing in for events.
|
|
3
|
+
|
|
4
|
+
// A field where the arrow keys with Alt belong to the text being typed; a range
|
|
5
|
+
// slider or a checkbox has no use for them.
|
|
6
|
+
const NON_TEXT_INPUTS = new Set(['range', 'checkbox', 'radio', 'button', 'submit']);
|
|
7
|
+
|
|
8
|
+
function isTypingTarget(target) {
|
|
9
|
+
if (!target) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (target.isContentEditable || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return target.tagName === 'INPUT' && !NON_TEXT_INPUTS.has(target.type);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Alt+← and Alt+→ step to the previous and the next passage. Any other modifier
|
|
19
|
+
// makes it a different shortcut, and none is taken from a text field.
|
|
20
|
+
export function stepDirection(event) {
|
|
21
|
+
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (isTypingTarget(event.target)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (event.key === 'ArrowLeft') {
|
|
28
|
+
return 'previous';
|
|
29
|
+
}
|
|
30
|
+
return event.key === 'ArrowRight' ? 'next' : null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Pure helpers for the statistics page: the period shortcuts and how a country
|
|
2
|
+
// is shown. Nothing here touches the DOM, so tests run them under Node.
|
|
3
|
+
|
|
4
|
+
import { dayKey, rangeBoundary } from './days.mjs';
|
|
5
|
+
|
|
6
|
+
export const RANGE_PRESETS = [
|
|
7
|
+
'all',
|
|
8
|
+
'thisMonth',
|
|
9
|
+
'lastMonth',
|
|
10
|
+
'last12Months',
|
|
11
|
+
'thisYear',
|
|
12
|
+
'lastYear'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// The calendar days a shortcut stands for, as the `YYYY-MM-DD` values of a date
|
|
16
|
+
// input, both ends included. "All" is an open range: two empty inputs.
|
|
17
|
+
export function presetRange(preset, today = new Date()) {
|
|
18
|
+
const year = today.getFullYear();
|
|
19
|
+
const month = today.getMonth();
|
|
20
|
+
const day = (y, m, d) => dayKey(new Date(y, m, d));
|
|
21
|
+
|
|
22
|
+
switch (preset) {
|
|
23
|
+
case 'thisMonth':
|
|
24
|
+
return { from: day(year, month, 1), to: day(year, month + 1, 0) };
|
|
25
|
+
case 'lastMonth':
|
|
26
|
+
return { from: day(year, month - 1, 1), to: day(year, month, 0) };
|
|
27
|
+
// A full year back from today, today included.
|
|
28
|
+
case 'last12Months':
|
|
29
|
+
return { from: day(year - 1, month, today.getDate() + 1), to: dayKey(today) };
|
|
30
|
+
case 'thisYear':
|
|
31
|
+
return { from: day(year, 0, 1), to: day(year, 11, 31) };
|
|
32
|
+
case 'lastYear':
|
|
33
|
+
return { from: day(year - 1, 0, 1), to: day(year - 1, 11, 31) };
|
|
34
|
+
default:
|
|
35
|
+
return { from: '', to: '' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The shortcut whose days are exactly these, if any -- to show which one is on.
|
|
40
|
+
export function activePreset(from, to, today = new Date()) {
|
|
41
|
+
return (
|
|
42
|
+
RANGE_PRESETS.find((preset) => {
|
|
43
|
+
const range = presetRange(preset, today);
|
|
44
|
+
return range.from === from && range.to === to;
|
|
45
|
+
}) ?? null
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What the API takes for a range picked as calendar days: `to` is exclusive, so
|
|
50
|
+
// the last day goes up to the following midnight (local time, like the log).
|
|
51
|
+
export function rangeQuery(from, to) {
|
|
52
|
+
const params = new URLSearchParams();
|
|
53
|
+
if (from) {
|
|
54
|
+
params.set('from', rangeBoundary(from).toISOString());
|
|
55
|
+
}
|
|
56
|
+
if (to) {
|
|
57
|
+
params.set('to', rangeBoundary(to, 1).toISOString());
|
|
58
|
+
}
|
|
59
|
+
return params.toString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A flag is the two regional-indicator letters of the ISO code; a platform with
|
|
63
|
+
// no flag emoji (Windows) draws the letters, which the name beside it explains.
|
|
64
|
+
export function flagEmoji(code) {
|
|
65
|
+
return /^[A-Z]{2}$/.test(code)
|
|
66
|
+
? String.fromCodePoint(...[...code].map((letter) => 0x1f1e6 + letter.charCodeAt(0) - 65))
|
|
67
|
+
: '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function countryName(code, language) {
|
|
71
|
+
try {
|
|
72
|
+
return new Intl.DisplayNames([language], { type: 'region' }).of(code) ?? code;
|
|
73
|
+
} catch {
|
|
74
|
+
return code;
|
|
75
|
+
}
|
|
76
|
+
}
|