signalk-chiplog 1.0.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. package/public/vendor/preact-htm.mjs +1 -0
package/lib/places.js ADDED
@@ -0,0 +1,128 @@
1
+ const { notFound } = require('./errors');
2
+
3
+ const EARTH_RADIUS_M = 6371008.8;
4
+
5
+ function toRadians(degrees) {
6
+ return (degrees * Math.PI) / 180;
7
+ }
8
+
9
+ function distanceBetween(a, b) {
10
+ const dLat = toRadians(b.lat - a.lat);
11
+ const dLon = toRadians(b.lon - a.lon);
12
+ const h =
13
+ Math.sin(dLat / 2) ** 2 +
14
+ Math.cos(toRadians(a.lat)) * Math.cos(toRadians(b.lat)) * Math.sin(dLon / 2) ** 2;
15
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
16
+ }
17
+
18
+ function findNearestPlace(db, position, radius) {
19
+ const latDelta = (radius / EARTH_RADIUS_M) * (180 / Math.PI);
20
+ const cosLat = Math.cos(toRadians(position.lat));
21
+ const lonDelta = cosLat > 1e-6 ? latDelta / cosLat : 360;
22
+ // Near the antimeridian a longitude window would wrap; filtering on latitude
23
+ // alone keeps the candidate set small and the distance test stays exact.
24
+ const wraps = position.lon - lonDelta < -180 || position.lon + lonDelta > 180;
25
+
26
+ const candidates = wraps
27
+ ? db
28
+ .prepare('SELECT * FROM places WHERE lat BETWEEN ? AND ?')
29
+ .all(position.lat - latDelta, position.lat + latDelta)
30
+ : db
31
+ .prepare('SELECT * FROM places WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?')
32
+ .all(
33
+ position.lat - latDelta,
34
+ position.lat + latDelta,
35
+ position.lon - lonDelta,
36
+ position.lon + lonDelta
37
+ );
38
+
39
+ let nearest = null;
40
+ let nearestDistance = Infinity;
41
+ for (const place of candidates) {
42
+ const distance = distanceBetween(position, place);
43
+ if (distance <= radius && distance < nearestDistance) {
44
+ nearest = place;
45
+ nearestDistance = distance;
46
+ }
47
+ }
48
+ return nearest;
49
+ }
50
+
51
+ function toPlace(row) {
52
+ return {
53
+ id: row.id,
54
+ name: row.name,
55
+ position: { lat: row.lat, lon: row.lon },
56
+ source: row.source,
57
+ createdAt: row.created_at,
58
+ updatedAt: row.updated_at
59
+ };
60
+ }
61
+
62
+ function requirePlaceRow(db, id) {
63
+ const row = db.prepare('SELECT * FROM places WHERE id = ?').get(id);
64
+ if (!row) {
65
+ throw notFound('place', id);
66
+ }
67
+ return row;
68
+ }
69
+
70
+ const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
71
+
72
+ // Sorted in JavaScript because SQLite's NOCASE only folds ASCII and would put
73
+ // "Île de Ré" after "Rochefort". A vessel's gazetteer stays small enough.
74
+ function listPlaces(db, { limit, offset }) {
75
+ const rows = db.prepare('SELECT * FROM places').all();
76
+ rows.sort((a, b) => nameCollator.compare(a.name, b.name) || a.id - b.id);
77
+ return {
78
+ total: rows.length,
79
+ limit,
80
+ offset,
81
+ items: rows.slice(offset, offset + limit).map(toPlace)
82
+ };
83
+ }
84
+
85
+ // A user-supplied name becomes authoritative for its surroundings: it updates
86
+ // the nearest known place within the radius, or creates one.
87
+ function rememberPlaceName(db, position, name, radius, now) {
88
+ const nearest = findNearestPlace(db, position, radius);
89
+ if (nearest) {
90
+ db.prepare("UPDATE places SET name = ?, source = 'manual', updated_at = ? WHERE id = ?").run(
91
+ name,
92
+ now,
93
+ nearest.id
94
+ );
95
+ return nearest.id;
96
+ }
97
+ const { lastInsertRowid } = db
98
+ .prepare(
99
+ `INSERT INTO places (name, lat, lon, source, created_at, updated_at)
100
+ VALUES (?, ?, ?, 'manual', ?, ?)`
101
+ )
102
+ .run(name, position.lat, position.lon, now, now);
103
+ return Number(lastInsertRowid);
104
+ }
105
+
106
+ function renamePlace(db, id, name, now) {
107
+ requirePlaceRow(db, id);
108
+ db.prepare("UPDATE places SET name = ?, source = 'manual', updated_at = ? WHERE id = ?").run(
109
+ name,
110
+ now,
111
+ id
112
+ );
113
+ return toPlace(requirePlaceRow(db, id));
114
+ }
115
+
116
+ function deletePlace(db, id) {
117
+ requirePlaceRow(db, id);
118
+ db.prepare('DELETE FROM places WHERE id = ?').run(id);
119
+ }
120
+
121
+ module.exports = {
122
+ distanceBetween,
123
+ findNearestPlace,
124
+ listPlaces,
125
+ rememberPlaceName,
126
+ renamePlace,
127
+ deletePlace
128
+ };
@@ -0,0 +1,156 @@
1
+ const { recomputeDurations } = require('./entries');
2
+
3
+ // Engine data is published continuously: NMEA 2000 revolutions several times
4
+ // a second, signalk-alternator-engine-on's state on every alternator reading.
5
+ const ENGINE_DATA_MAX_AGE_MS = 2 * 60 * 1000;
6
+
7
+ const PROPULSION_DEFAULTS = { defaultPropulsion: 'sail' };
8
+
9
+ function iso(ms) {
10
+ return new Date(ms).toISOString();
11
+ }
12
+
13
+ function createPropulsionTracker({ db, readSelfPath, readFresh, settings }) {
14
+ let previousType = null;
15
+ let rpmAverage = null;
16
+
17
+ // Engine data wins over navigation.state, which signalk-autostate derives
18
+ // from the same sources or from its own default when it has none.
19
+ function sense(now, navigationState) {
20
+ let hasEngineData = false;
21
+ let running = false;
22
+ const rpms = [];
23
+
24
+ for (const id of Object.keys(readSelfPath('propulsion') ?? {})) {
25
+ const revolutions = readFresh(`propulsion.${id}.revolutions`, now, ENGINE_DATA_MAX_AGE_MS);
26
+ if (typeof revolutions === 'number' && Number.isFinite(revolutions)) {
27
+ hasEngineData = true;
28
+ if (revolutions > 0) {
29
+ running = true;
30
+ rpms.push(revolutions * 60);
31
+ }
32
+ continue;
33
+ }
34
+ const state = readFresh(`propulsion.${id}.state`, now, ENGINE_DATA_MAX_AGE_MS);
35
+ if (state === 'started' || state === 'stopped') {
36
+ hasEngineData = true;
37
+ running = running || state === 'started';
38
+ }
39
+ }
40
+
41
+ let type = settings.defaultPropulsion;
42
+ if (hasEngineData) {
43
+ type = running ? 'engine' : 'sail';
44
+ } else if (navigationState === 'motoring') {
45
+ type = 'engine';
46
+ } else if (navigationState === 'sailing') {
47
+ type = 'sail';
48
+ }
49
+ const rpm = rpms.length > 0 ? rpms.reduce((sum, value) => sum + value, 0) / rpms.length : null;
50
+ return { type, rpm };
51
+ }
52
+
53
+ function closeSegment(segment, endTime) {
54
+ const end = endTime < segment.start_time ? segment.start_time : endTime;
55
+ db.prepare('UPDATE propulsion_segments SET end_time = ? WHERE id = ?').run(end, segment.id);
56
+ }
57
+
58
+ function openSegment(entry, type, requestedStart, now) {
59
+ const { lastEnd } = db
60
+ .prepare('SELECT MAX(end_time) AS lastEnd FROM propulsion_segments WHERE entry_id = ?')
61
+ .get(entry.id);
62
+ let start = lastEnd === null ? entry.start_time : requestedStart;
63
+ if (lastEnd !== null && start < lastEnd) {
64
+ start = lastEnd;
65
+ }
66
+ if (start > iso(now)) {
67
+ start = iso(now);
68
+ }
69
+ const { lastInsertRowid } = db
70
+ .prepare(
71
+ "INSERT INTO propulsion_segments (entry_id, type, start_time, source) VALUES (?, ?, ?, 'auto')"
72
+ )
73
+ .run(entry.id, type, start);
74
+ return db
75
+ .prepare('SELECT * FROM propulsion_segments WHERE id = ?')
76
+ .get(Number(lastInsertRowid));
77
+ }
78
+
79
+ function trackRpm(segment, rpm) {
80
+ if (rpmAverage?.segmentId !== segment.id) {
81
+ rpmAverage = {
82
+ segmentId: segment.id,
83
+ sum: segment.average_rpm ?? 0,
84
+ count: segment.average_rpm === null ? 0 : 1
85
+ };
86
+ }
87
+ rpmAverage.sum += rpm;
88
+ rpmAverage.count += 1;
89
+ db.prepare('UPDATE propulsion_segments SET average_rpm = ? WHERE id = ?').run(
90
+ rpmAverage.sum / rpmAverage.count,
91
+ segment.id
92
+ );
93
+ }
94
+
95
+ // Runs inside detection's transaction, after it has updated the passage, so
96
+ // segment boundaries reuse the departure, stop and resume times it worked out.
97
+ function reconcile(now, sensed, { resumedAt = null } = {}) {
98
+ const entry = db.prepare("SELECT * FROM log_entries WHERE state = 'active'").get();
99
+ const moving = entry !== undefined && entry.stopped_since === null;
100
+ const touched = new Set(moving ? [entry.id] : []);
101
+ let current = null;
102
+
103
+ const open = db
104
+ .prepare(
105
+ `SELECT s.*, e.state AS entry_state, e.end_time AS entry_end, e.stopped_since AS entry_stopped
106
+ FROM propulsion_segments s JOIN log_entries e ON e.id = s.entry_id
107
+ WHERE s.end_time IS NULL
108
+ ORDER BY s.start_time`
109
+ )
110
+ .all();
111
+
112
+ for (const segment of open) {
113
+ let end = null;
114
+ if (segment.entry_state === 'closed') {
115
+ end = segment.entry_end;
116
+ } else if (segment.entry_stopped !== null) {
117
+ end = segment.entry_stopped;
118
+ } else if (current) {
119
+ end = iso(now);
120
+ } else if (segment.type !== sensed.type) {
121
+ // Split only when the engine actually changes state, so a manual
122
+ // correction of the ongoing segment holds on a boat whose sensors keep
123
+ // reporting the old value. After a restart, only automatic segments
124
+ // are brought back in line.
125
+ const changed = previousType !== null && sensed.type !== previousType;
126
+ const restarted = previousType === null && segment.source === 'auto';
127
+ if (changed || restarted) {
128
+ end = iso(now);
129
+ }
130
+ }
131
+
132
+ if (end === null) {
133
+ current = segment;
134
+ } else {
135
+ closeSegment(segment, end);
136
+ touched.add(segment.entry_id);
137
+ }
138
+ }
139
+
140
+ if (moving && !current) {
141
+ current = openSegment(entry, sensed.type, iso(resumedAt ?? now), now);
142
+ }
143
+ if (moving && current.type === 'engine' && sensed.rpm !== null) {
144
+ trackRpm(current, sensed.rpm);
145
+ }
146
+
147
+ for (const entryId of touched) {
148
+ recomputeDurations(db, entryId, iso(now));
149
+ }
150
+ previousType = sensed.type;
151
+ }
152
+
153
+ return { sense, reconcile };
154
+ }
155
+
156
+ module.exports = { createPropulsionTracker, PROPULSION_DEFAULTS };
@@ -0,0 +1,75 @@
1
+ const { withTransaction } = require('./database');
2
+ const { notFound } = require('./errors');
3
+ const { requireEntryRow, recomputeDurations } = require('./entries');
4
+ const { paginate } = require('./rows');
5
+
6
+ function toSegment(row) {
7
+ return {
8
+ id: row.id,
9
+ entryId: row.entry_id,
10
+ type: row.type,
11
+ startTime: row.start_time,
12
+ endTime: row.end_time,
13
+ source: row.source,
14
+ averageRpm: row.average_rpm
15
+ };
16
+ }
17
+
18
+ function requireSegmentRow(db, id) {
19
+ const row = db.prepare('SELECT * FROM propulsion_segments WHERE id = ?').get(id);
20
+ if (!row) {
21
+ throw notFound('propulsion_segment', id);
22
+ }
23
+ return row;
24
+ }
25
+
26
+ function listSegments(db, entryId, { limit, offset }) {
27
+ requireEntryRow(db, entryId);
28
+ return paginate(db, {
29
+ table: 'propulsion_segments',
30
+ where: 'WHERE entry_id = ?',
31
+ params: [entryId],
32
+ orderBy: 'start_time, id',
33
+ limit,
34
+ offset,
35
+ map: toSegment
36
+ });
37
+ }
38
+
39
+ function allSegments(db, entryId) {
40
+ return db
41
+ .prepare('SELECT * FROM propulsion_segments WHERE entry_id = ? ORDER BY start_time, id')
42
+ .all(entryId)
43
+ .map(toSegment);
44
+ }
45
+
46
+ function correctSegment(db, id, { type }, { now }) {
47
+ return withTransaction(db, () => {
48
+ const row = requireSegmentRow(db, id);
49
+ if (row.type === type) {
50
+ return toSegment(row);
51
+ }
52
+
53
+ db.prepare("UPDATE propulsion_segments SET type = ?, source = 'manual' WHERE id = ?").run(
54
+ type,
55
+ id
56
+ );
57
+ recomputeDurations(db, row.entry_id, now);
58
+
59
+ // Placed at the segment's start so the timeline shows the correction where
60
+ // it applies; created_at still records when it was made.
61
+ db.prepare(
62
+ `INSERT INTO events (entry_id, time, type, subtype, payload, source, created_at)
63
+ VALUES (?, ?, 'manual_correction', 'propulsion', ?, 'manual', ?)`
64
+ ).run(
65
+ row.entry_id,
66
+ row.start_time,
67
+ JSON.stringify({ segmentId: id, before: { type: row.type }, after: { type } }),
68
+ now
69
+ );
70
+
71
+ return toSegment(requireSegmentRow(db, id));
72
+ });
73
+ }
74
+
75
+ module.exports = { listSegments, allSegments, correctSegment };
package/lib/rows.js ADDED
@@ -0,0 +1,15 @@
1
+ function toPosition(lat, lon) {
2
+ return lat === null || lon === null ? null : { lat, lon };
3
+ }
4
+
5
+ // Table and ORDER BY fragments are always literals from the calling module,
6
+ // never request input; only `params` carry user-supplied values.
7
+ function paginate(db, { table, where = '', params = [], orderBy, limit, offset, map }) {
8
+ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM ${table} ${where}`).get(...params);
9
+ const rows = db
10
+ .prepare(`SELECT * FROM ${table} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
11
+ .all(...params, limit, offset);
12
+ return { total, limit, offset, items: rows.map(map) };
13
+ }
14
+
15
+ module.exports = { toPosition, paginate };
@@ -0,0 +1,149 @@
1
+ const { withTransaction } = require('./database');
2
+ const { MAX_REFINEMENT_AGE_MS } = require('./detection');
3
+ const { distanceBetween } = require('./places');
4
+
5
+ const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
6
+
7
+ const SAMPLE_INTERVAL_MS = 1000;
8
+ const TRACK_DEFAULTS = { trackIntervalSeconds: 15 };
9
+
10
+ // Interval points are skipped until the vessel has moved this far, so a boat
11
+ // waiting at a lock does not pile up identical points.
12
+ const MIN_MOVE_METRES = 10;
13
+ const HEADING_CHANGE_RAD = (15 * Math.PI) / 180;
14
+ // Course over ground is noise at low speed; below this, a swinging boat would
15
+ // log a turn every second.
16
+ const MIN_HEADING_SPEED = 2 * METRES_PER_SECOND_PER_KNOT;
17
+ const SPEED_CHANGE = 1 * METRES_PER_SECOND_PER_KNOT;
18
+ const MIN_CHANGE_SPACING_MS = 2000;
19
+
20
+ function iso(ms) {
21
+ return new Date(ms).toISOString();
22
+ }
23
+
24
+ function finiteOrNull(value) {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
26
+ }
27
+
28
+ function headingDifference(a, b) {
29
+ const diff = Math.abs(a - b) % (2 * Math.PI);
30
+ return diff > Math.PI ? 2 * Math.PI - diff : diff;
31
+ }
32
+
33
+ function createTrackRecorder({ db, readSelfPath, settings, clock = Date.now }) {
34
+ const intervalMs = settings.trackIntervalSeconds * 1000;
35
+
36
+ let lastFixTimestamp = null;
37
+ let lastSampled = null;
38
+ // Detection dates a departure back to when the vessel left its berth, up to
39
+ // MAX_REFINEMENT_AGE_MS before it opens the entry, and resumes a stopped one
40
+ // only after averaging. Points wait here until they have a moving passage to
41
+ // belong to, so the track starts where the passage does.
42
+ let pending = [];
43
+ let lastWritten = null;
44
+
45
+ function readFix(now) {
46
+ const node = readSelfPath('navigation.position');
47
+ if (!node?.timestamp || node.timestamp === lastFixTimestamp) {
48
+ return null;
49
+ }
50
+ lastFixTimestamp = node.timestamp;
51
+
52
+ const { value } = node;
53
+ if (!value || !Number.isFinite(value.latitude) || !Number.isFinite(value.longitude)) {
54
+ return null;
55
+ }
56
+ return {
57
+ time: now,
58
+ lat: value.latitude,
59
+ lon: value.longitude,
60
+ sog: finiteOrNull(readSelfPath('navigation.speedOverGround')?.value),
61
+ cog: finiteOrNull(readSelfPath('navigation.courseOverGroundTrue')?.value)
62
+ };
63
+ }
64
+
65
+ function isWorthKeeping(fix) {
66
+ if (!lastSampled) {
67
+ return true;
68
+ }
69
+ const elapsed = fix.time - lastSampled.time;
70
+ if (elapsed >= intervalMs && distanceBetween(lastSampled, fix) >= MIN_MOVE_METRES) {
71
+ return true;
72
+ }
73
+ if (elapsed < MIN_CHANGE_SPACING_MS) {
74
+ return false;
75
+ }
76
+ const turned =
77
+ fix.sog !== null &&
78
+ fix.sog >= MIN_HEADING_SPEED &&
79
+ fix.cog !== null &&
80
+ lastSampled.cog !== null &&
81
+ headingDifference(fix.cog, lastSampled.cog) >= HEADING_CHANGE_RAD;
82
+ const changedSpeed =
83
+ fix.sog !== null &&
84
+ lastSampled.sog !== null &&
85
+ Math.abs(fix.sog - lastSampled.sog) >= SPEED_CHANGE;
86
+ return turned || changedSpeed;
87
+ }
88
+
89
+ function lastPointOf(entryId) {
90
+ const row = db
91
+ .prepare(
92
+ 'SELECT time, lat, lon FROM track_points WHERE entry_id = ? ORDER BY time DESC, id DESC LIMIT 1'
93
+ )
94
+ .get(entryId);
95
+ return row ? { entryId, time: Date.parse(row.time), lat: row.lat, lon: row.lon } : null;
96
+ }
97
+
98
+ function flush(entry, now) {
99
+ withTransaction(db, () => {
100
+ let previous = lastWritten?.entryId === entry.id ? lastWritten : lastPointOf(entry.id);
101
+ const startedAt = Date.parse(entry.start_time);
102
+ const insert = db.prepare(
103
+ 'INSERT INTO track_points (entry_id, time, lat, lon, sog, cog) VALUES (?, ?, ?, ?, ?, ?)'
104
+ );
105
+
106
+ let added = 0;
107
+ for (const point of pending) {
108
+ if (point.time < startedAt || (previous && point.time <= previous.time)) {
109
+ continue;
110
+ }
111
+ insert.run(entry.id, iso(point.time), point.lat, point.lon, point.sog, point.cog);
112
+ if (previous) {
113
+ added += distanceBetween(previous, point);
114
+ }
115
+ previous = { entryId: entry.id, time: point.time, lat: point.lat, lon: point.lon };
116
+ }
117
+
118
+ if (added > 0) {
119
+ db.prepare(
120
+ 'UPDATE log_entries SET distance = distance + ?, updated_at = ? WHERE id = ?'
121
+ ).run(added, iso(now), entry.id);
122
+ }
123
+ lastWritten = previous;
124
+ });
125
+ pending = [];
126
+ }
127
+
128
+ return {
129
+ sample() {
130
+ const now = clock();
131
+ const fix = readFix(now);
132
+ if (!fix || !isWorthKeeping(fix)) {
133
+ return;
134
+ }
135
+ lastSampled = fix;
136
+ pending.push(fix);
137
+ pending = pending.filter((point) => point.time >= now - MAX_REFINEMENT_AGE_MS);
138
+
139
+ const entry = db
140
+ .prepare("SELECT id, start_time, stopped_since FROM log_entries WHERE state = 'active'")
141
+ .get();
142
+ if (entry && entry.stopped_since === null) {
143
+ flush(entry, now);
144
+ }
145
+ }
146
+ };
147
+ }
148
+
149
+ module.exports = { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS };
package/lib/track.js ADDED
@@ -0,0 +1,58 @@
1
+ const { requireEntryRow } = require('./entries');
2
+ const { toPosition, paginate } = require('./rows');
3
+
4
+ function toTrackPoint(row) {
5
+ return { time: row.time, lat: row.lat, lon: row.lon, sog: row.sog, cog: row.cog };
6
+ }
7
+
8
+ function toObservation(row) {
9
+ return {
10
+ id: row.id,
11
+ time: row.time,
12
+ reason: row.reason,
13
+ position: toPosition(row.lat, row.lon),
14
+ sog: row.sog,
15
+ cog: row.cog,
16
+ heading: row.heading,
17
+ stw: row.stw,
18
+ twd: row.twd,
19
+ tws: row.tws,
20
+ awa: row.awa,
21
+ aws: row.aws,
22
+ depth: row.depth,
23
+ pressure: row.pressure,
24
+ airTemp: row.air_temp,
25
+ waterTemp: row.water_temp,
26
+ tripLog: row.trip_log,
27
+ engineRuntime: row.engine_runtime
28
+ };
29
+ }
30
+
31
+ function allTrackPoints(db, entryId) {
32
+ return db
33
+ .prepare('SELECT * FROM track_points WHERE entry_id = ? ORDER BY time, id')
34
+ .all(entryId)
35
+ .map(toTrackPoint);
36
+ }
37
+
38
+ function allObservations(db, entryId) {
39
+ return db
40
+ .prepare('SELECT * FROM observations WHERE entry_id = ? ORDER BY time, id')
41
+ .all(entryId)
42
+ .map(toObservation);
43
+ }
44
+
45
+ function listObservations(db, entryId, { limit, offset }) {
46
+ requireEntryRow(db, entryId);
47
+ return paginate(db, {
48
+ table: 'observations',
49
+ where: 'WHERE entry_id = ?',
50
+ params: [entryId],
51
+ orderBy: 'time, id',
52
+ limit,
53
+ offset,
54
+ map: toObservation
55
+ });
56
+ }
57
+
58
+ module.exports = { allTrackPoints, allObservations, listObservations };