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.
@@ -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.0",
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
- .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>