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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- 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 };
|