signalk-siparu 0.1.46 → 0.2.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/README.md +9 -4
- package/package.json +1 -1
- package/plugin/dist/contract.d.ts +2 -2
- package/plugin/dist/fingerprint.d.ts +2 -0
- package/plugin/dist/fingerprint.js +32 -0
- package/plugin/dist/index.js +8 -8
- package/plugin/dist/live.d.ts +4 -7
- package/plugin/dist/live.js +34 -36
- package/plugin/dist/sealer.d.ts +1 -4
- package/plugin/dist/sealer.js +3 -8
- package/plugin/dist/uplink.d.ts +0 -2
- package/plugin/dist/uplink.js +1 -1
- package/public/assets/{Logbook-D8JG01cj.js → Logbook-DFHM-jqx.js} +1 -1
- package/public/assets/{Map-jl0kRWHt.js → Map-8D_Vswr5.js} +1 -1
- package/public/assets/{Voyage-B8w_G7um.js → Voyage-CKrgqKFd.js} +1 -1
- package/public/assets/{index-DRLXkDBe.js → index-DAYjCs-4.js} +3 -3
- package/public/assets/{index-BasPbF69.css → index-vrv9X4MC.css} +1 -1
- package/public/assets/{style-CJPQfDTN.js → style-BEJHY9Dg.js} +1 -1
- package/public/index.html +2 -2
package/README.md
CHANGED
|
@@ -134,10 +134,15 @@ Exactly one kind of message travels the other way:
|
|
|
134
134
|
Anything else the shore sends is ignored, because a boat takes no command and so
|
|
135
135
|
there is nothing else to hear.
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
to
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
She reports encrypted or she does not report. Until the account has named a
|
|
138
|
+
device to seal to, nothing goes ashore at all: she keeps recording her own
|
|
139
|
+
history, and her own screen says why nothing is leaving. There is no fallback
|
|
140
|
+
that sends a position in the clear, because a boat whose privacy depends on the
|
|
141
|
+
state of a list held elsewhere does not have any.
|
|
142
|
+
|
|
143
|
+
Once a device is named, the report is encrypted to it and the relay carries a
|
|
144
|
+
block it cannot read. Three things stay legible beside it, and they are worth
|
|
145
|
+
stating plainly because they are what a carrier can see:
|
|
141
146
|
|
|
142
147
|
- Her identifier and the time she sent the frame, so it can be routed at all.
|
|
143
148
|
- How loud she is: `normal`, `warning` or `alarm`, and nothing about what is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-siparu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"signalk-node-server-plugin",
|
|
@@ -217,10 +217,10 @@ export interface HealthResult {
|
|
|
217
217
|
hours_pending: number;
|
|
218
218
|
};
|
|
219
219
|
sealing: {
|
|
220
|
-
active: boolean;
|
|
221
220
|
devices: number;
|
|
222
|
-
mode: '
|
|
221
|
+
mode: 'sealed' | 'blocked' | 'none';
|
|
223
222
|
reason: string | null;
|
|
223
|
+
screens: string[];
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
226
|
export type PhaseKind = 'underway' | 'anchored' | 'moored' | 'stopped';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deviceFingerprint = deviceFingerprint;
|
|
4
|
+
exports.fingerprintOfEncoded = fingerprintOfEncoded;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const LABEL = 'siparu/device-fingerprint/v1';
|
|
7
|
+
const BYTES = 10;
|
|
8
|
+
const GROUP = 4;
|
|
9
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
10
|
+
function deviceFingerprint(pub) {
|
|
11
|
+
if (pub.length !== 32)
|
|
12
|
+
throw new Error(`a device key is 32 bytes, this one is ${pub.length}`);
|
|
13
|
+
const digest = (0, node_crypto_1.createHash)('sha256').update(LABEL).update(pub).digest();
|
|
14
|
+
let out = '';
|
|
15
|
+
let bits = 0;
|
|
16
|
+
let held = 0;
|
|
17
|
+
for (const byte of digest.subarray(0, BYTES)) {
|
|
18
|
+
held = (held << 8) | byte;
|
|
19
|
+
bits += 8;
|
|
20
|
+
while (bits >= 5) {
|
|
21
|
+
bits -= 5;
|
|
22
|
+
out += ALPHABET[(held >> bits) & 31];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out.replace(new RegExp(`(.{${GROUP}})(?=.)`, 'g'), '$1-');
|
|
26
|
+
}
|
|
27
|
+
function fingerprintOfEncoded(pub) {
|
|
28
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(pub))
|
|
29
|
+
return null;
|
|
30
|
+
const raw = Buffer.from(pub, 'base64url');
|
|
31
|
+
return raw.length === 32 ? deviceFingerprint(raw) : null;
|
|
32
|
+
}
|
package/plugin/dist/index.js
CHANGED
|
@@ -39,6 +39,7 @@ const alarms_1 = require("./alarms");
|
|
|
39
39
|
const alertrules_1 = require("./alertrules");
|
|
40
40
|
const charts_1 = require("./charts");
|
|
41
41
|
const config_1 = require("./config");
|
|
42
|
+
const fingerprint_1 = require("./fingerprint");
|
|
42
43
|
const metrics_1 = require("./metrics");
|
|
43
44
|
const query_1 = require("./query");
|
|
44
45
|
const pairing_1 = require("./pairing");
|
|
@@ -164,8 +165,8 @@ module.exports = (app) => {
|
|
|
164
165
|
app.setPluginStatus(`Recording - ${snapshotsToday} rows today${age === null ? ', no data yet' : age > 60 ? `, data age ${age}s` : ''}${silent}`);
|
|
165
166
|
}
|
|
166
167
|
function sealingState() {
|
|
167
|
-
if (!sealer
|
|
168
|
-
return { mode: '
|
|
168
|
+
if (!sealer || !ensureRemoteLink().getRemote())
|
|
169
|
+
return { mode: 'none', reason: null };
|
|
169
170
|
return sealer.state();
|
|
170
171
|
}
|
|
171
172
|
async function health() {
|
|
@@ -193,9 +194,11 @@ module.exports = (app) => {
|
|
|
193
194
|
},
|
|
194
195
|
rollup: await rollups.status(now),
|
|
195
196
|
sealing: {
|
|
196
|
-
active: sealer?.active() ?? false,
|
|
197
197
|
devices: keySync?.status().devices ?? 0,
|
|
198
|
-
...sealingState()
|
|
198
|
+
...sealingState(),
|
|
199
|
+
screens: (keySync?.devices() ?? [])
|
|
200
|
+
.map((d) => (0, fingerprint_1.fingerprintOfEncoded)(d.pub))
|
|
201
|
+
.filter((fp) => fp !== null)
|
|
199
202
|
}
|
|
200
203
|
};
|
|
201
204
|
}
|
|
@@ -379,7 +382,6 @@ module.exports = (app) => {
|
|
|
379
382
|
};
|
|
380
383
|
},
|
|
381
384
|
seal: (frame) => seal.seal({ ...frame, alarms: alarms.conditions() }, alarms.level(audible), alarms.note(audible), alarms.risenAt(audible)),
|
|
382
|
-
sealed: () => seal.active(),
|
|
383
385
|
onWatching: () => ks.prompt(),
|
|
384
386
|
openAsk: (envelope) => seal.open(envelope),
|
|
385
387
|
sealAnswer: (payload, id) => seal.answer(payload, id),
|
|
@@ -390,10 +392,8 @@ module.exports = (app) => {
|
|
|
390
392
|
const up = new uplink_1.Uplink({
|
|
391
393
|
relayUrl: opts.relayUrl,
|
|
392
394
|
getRemote: () => rl.getRemote(),
|
|
393
|
-
frame: () => live(),
|
|
394
|
-
sealing: () => seal.active(),
|
|
395
395
|
debug: (msg) => app.debug(msg),
|
|
396
|
-
liveHealthy: () => ws.
|
|
396
|
+
liveHealthy: () => ws.reporting()
|
|
397
397
|
});
|
|
398
398
|
uplink = up;
|
|
399
399
|
up.start();
|
package/plugin/dist/live.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ParsedRuleWrite } from './alertrules';
|
|
|
3
3
|
import type { AlertRulesReadResult, SetAlertRulesResponse, PathSeriesResult, SnapshotsQuery, SnapshotsResult, TrackResult, VoyageListResult, PhaseListResult } from './contract';
|
|
4
4
|
export declare const FRAME_EVERY_MS = 2000;
|
|
5
5
|
export declare const STILL_FRAME_EVERY_MS = 60000;
|
|
6
|
+
export declare const REPORTING_STALE_MS: number;
|
|
6
7
|
export declare const PING_EVERY_MS = 25000;
|
|
7
8
|
export interface LiveSocket {
|
|
8
9
|
send(data: string): void;
|
|
@@ -25,22 +26,17 @@ export interface LiveDeps {
|
|
|
25
26
|
relayUrl: string;
|
|
26
27
|
getRemote: () => RemoteLink | undefined;
|
|
27
28
|
frame: () => unknown;
|
|
28
|
-
seal
|
|
29
|
-
mode: 'clear';
|
|
30
|
-
} | {
|
|
29
|
+
seal: (frame: unknown) => {
|
|
31
30
|
mode: 'sealed';
|
|
32
31
|
frame: unknown;
|
|
33
32
|
} | {
|
|
34
33
|
mode: 'blocked';
|
|
35
34
|
};
|
|
36
|
-
sealed?: () => boolean;
|
|
37
35
|
openAsk?: (envelope: unknown) => {
|
|
38
36
|
id: string;
|
|
39
37
|
plaintext: string;
|
|
40
38
|
} | undefined;
|
|
41
|
-
sealAnswer
|
|
42
|
-
mode: 'clear';
|
|
43
|
-
} | {
|
|
39
|
+
sealAnswer: (payload: unknown, id: string) => {
|
|
44
40
|
mode: 'sealed';
|
|
45
41
|
frame: unknown;
|
|
46
42
|
} | {
|
|
@@ -80,6 +76,7 @@ export declare class LiveUplink {
|
|
|
80
76
|
start(): void;
|
|
81
77
|
stop(): void;
|
|
82
78
|
healthy(): boolean;
|
|
79
|
+
reporting(): boolean;
|
|
83
80
|
status(): LiveStatus;
|
|
84
81
|
reset(): void;
|
|
85
82
|
private dial;
|
package/plugin/dist/live.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LiveUplink = exports.PING_EVERY_MS = exports.STILL_FRAME_EVERY_MS = exports.FRAME_EVERY_MS = void 0;
|
|
3
|
+
exports.LiveUplink = exports.PING_EVERY_MS = exports.REPORTING_STALE_MS = exports.STILL_FRAME_EVERY_MS = exports.FRAME_EVERY_MS = void 0;
|
|
4
4
|
exports.rpcEnvelope = rpcEnvelope;
|
|
5
5
|
exports.isWatchingNote = isWatchingNote;
|
|
6
6
|
const alertrules_1 = require("./alertrules");
|
|
7
7
|
exports.FRAME_EVERY_MS = 2_000;
|
|
8
8
|
exports.STILL_FRAME_EVERY_MS = 60_000;
|
|
9
|
+
exports.REPORTING_STALE_MS = exports.STILL_FRAME_EVERY_MS * 3;
|
|
9
10
|
const UNDERWAY_SOG_MS = 0.3 * 0.514444;
|
|
10
11
|
exports.PING_EVERY_MS = 25_000;
|
|
11
12
|
const RECONNECT_BASE_MS = 5_000;
|
|
@@ -60,6 +61,11 @@ class LiveUplink {
|
|
|
60
61
|
healthy() {
|
|
61
62
|
return !this.stopped && this.connected;
|
|
62
63
|
}
|
|
64
|
+
reporting() {
|
|
65
|
+
if (!this.healthy() || this.lastFrameTs === null)
|
|
66
|
+
return false;
|
|
67
|
+
return Date.now() - this.lastFrameTs < exports.REPORTING_STALE_MS;
|
|
68
|
+
}
|
|
63
69
|
status() {
|
|
64
70
|
return {
|
|
65
71
|
connected: this.connected,
|
|
@@ -128,33 +134,29 @@ class LiveUplink {
|
|
|
128
134
|
this.deps.onWatching?.();
|
|
129
135
|
return;
|
|
130
136
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.dispatch(gen, opened.plaintext);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const asked = rpcEnvelope(data);
|
|
138
|
-
if (asked) {
|
|
139
|
-
const refusal = {
|
|
140
|
-
code: 'SEALED',
|
|
141
|
-
message: 'This boat is encrypted. Her records open in the Siparu app.'
|
|
142
|
-
};
|
|
143
|
-
this.refuse(gen, asked.type === 'history'
|
|
144
|
-
? { type: 'history', id: asked.id, error: refusal }
|
|
145
|
-
: asked.type === 'snapshots'
|
|
146
|
-
? { type: 'snapshots', id: asked.id, error: refusal }
|
|
147
|
-
: asked.type === 'voyages'
|
|
148
|
-
? { type: 'voyages', id: asked.id, error: refusal }
|
|
149
|
-
: asked.type === 'track'
|
|
150
|
-
? { type: 'track', id: asked.id, error: refusal }
|
|
151
|
-
: asked.type === 'phases'
|
|
152
|
-
? { type: 'phases', id: asked.id, error: refusal }
|
|
153
|
-
: { type: 'alertrules', id: asked.id, error: refusal });
|
|
154
|
-
}
|
|
137
|
+
const opened = this.openSealedAsk(data);
|
|
138
|
+
if (opened) {
|
|
139
|
+
this.dispatch(gen, opened.plaintext);
|
|
155
140
|
return;
|
|
156
141
|
}
|
|
157
|
-
|
|
142
|
+
const asked = rpcEnvelope(data);
|
|
143
|
+
if (asked) {
|
|
144
|
+
const refusal = {
|
|
145
|
+
code: 'SEALED',
|
|
146
|
+
message: 'This boat is encrypted. Her records open in the Siparu app.'
|
|
147
|
+
};
|
|
148
|
+
this.refuse(gen, asked.type === 'history'
|
|
149
|
+
? { type: 'history', id: asked.id, error: refusal }
|
|
150
|
+
: asked.type === 'snapshots'
|
|
151
|
+
? { type: 'snapshots', id: asked.id, error: refusal }
|
|
152
|
+
: asked.type === 'voyages'
|
|
153
|
+
? { type: 'voyages', id: asked.id, error: refusal }
|
|
154
|
+
: asked.type === 'track'
|
|
155
|
+
? { type: 'track', id: asked.id, error: refusal }
|
|
156
|
+
: asked.type === 'phases'
|
|
157
|
+
? { type: 'phases', id: asked.id, error: refusal }
|
|
158
|
+
: { type: 'alertrules', id: asked.id, error: refusal });
|
|
159
|
+
}
|
|
158
160
|
});
|
|
159
161
|
sock.onClose((code) => {
|
|
160
162
|
if (gen !== this.gen)
|
|
@@ -211,11 +213,11 @@ class LiveUplink {
|
|
|
211
213
|
const frame = this.deps.frame();
|
|
212
214
|
const sog = frame.sog;
|
|
213
215
|
this.lastSog = typeof sog === 'number' && Number.isFinite(sog) ? sog : null;
|
|
214
|
-
const verdict = this.deps.seal
|
|
216
|
+
const verdict = this.deps.seal(frame);
|
|
215
217
|
if (verdict.mode === 'blocked') {
|
|
216
218
|
return;
|
|
217
219
|
}
|
|
218
|
-
this.sock.send(JSON.stringify(
|
|
220
|
+
this.sock.send(JSON.stringify({ type: 'sealed', frame: verdict.frame }));
|
|
219
221
|
this.lastFrameTs = Date.now();
|
|
220
222
|
}
|
|
221
223
|
catch (e) {
|
|
@@ -414,16 +416,12 @@ class LiveUplink {
|
|
|
414
416
|
if (gen !== this.gen || !this.sock)
|
|
415
417
|
return;
|
|
416
418
|
try {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
this.deps.debug(`answer to ${msg.id} withheld: it could not be sealed`);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
this.sock.send(JSON.stringify({ type: 'answer', id: msg.id, frame: verdict.frame }));
|
|
419
|
+
const verdict = this.deps.sealAnswer(msg, msg.id);
|
|
420
|
+
if (verdict.mode !== 'sealed') {
|
|
421
|
+
this.deps.debug(`answer to ${msg.id} withheld: it could not be sealed`);
|
|
424
422
|
return;
|
|
425
423
|
}
|
|
426
|
-
this.sock.send(JSON.stringify(msg));
|
|
424
|
+
this.sock.send(JSON.stringify({ type: 'answer', id: msg.id, frame: verdict.frame }));
|
|
427
425
|
}
|
|
428
426
|
catch (e) {
|
|
429
427
|
this.deps.debug(`history reply send failed: ${String(e)}`);
|
package/plugin/dist/sealer.d.ts
CHANGED
|
@@ -3,8 +3,6 @@ import type { AlertLevel, AlertNote, DevicePublicKey } from './contract';
|
|
|
3
3
|
import type { BoatKeyStore } from './keystore';
|
|
4
4
|
import { type SealedFrame } from './sealing';
|
|
5
5
|
export type SealVerdict = {
|
|
6
|
-
mode: 'clear';
|
|
7
|
-
} | {
|
|
8
6
|
mode: 'sealed';
|
|
9
7
|
frame: SealedFrame;
|
|
10
8
|
} | {
|
|
@@ -20,7 +18,7 @@ export interface SealerDeps {
|
|
|
20
18
|
}
|
|
21
19
|
export declare const NOTE_BLOCK_BYTES = 256;
|
|
22
20
|
export interface SealState {
|
|
23
|
-
mode: '
|
|
21
|
+
mode: 'sealed' | 'blocked' | 'none';
|
|
24
22
|
reason: string | null;
|
|
25
23
|
}
|
|
26
24
|
export declare class Sealer {
|
|
@@ -31,7 +29,6 @@ export declare class Sealer {
|
|
|
31
29
|
private lastReason;
|
|
32
30
|
constructor(deps: SealerDeps);
|
|
33
31
|
state(): SealState;
|
|
34
|
-
active(): boolean;
|
|
35
32
|
seal(frame: unknown, alert?: AlertLevel, note?: AlertNote, risenAt?: number): SealVerdict;
|
|
36
33
|
private sealNote;
|
|
37
34
|
answer(payload: unknown, id: string): SealVerdict;
|
package/plugin/dist/sealer.js
CHANGED
|
@@ -22,9 +22,6 @@ class Sealer {
|
|
|
22
22
|
state() {
|
|
23
23
|
return { mode: this.lastMode, reason: this.lastReason };
|
|
24
24
|
}
|
|
25
|
-
active() {
|
|
26
|
-
return this.deps.devices().length > 0 || this.deps.latched();
|
|
27
|
-
}
|
|
28
25
|
seal(frame, alert, note, risenAt) {
|
|
29
26
|
const extensions = {};
|
|
30
27
|
if (alert)
|
|
@@ -107,11 +104,9 @@ class Sealer {
|
|
|
107
104
|
sealPayload(payload, extensions) {
|
|
108
105
|
const devices = this.deps.devices();
|
|
109
106
|
if (devices.length === 0) {
|
|
110
|
-
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
return this.blocked('the shore has named no screen this boat can seal to');
|
|
107
|
+
return this.blocked(this.deps.latched()
|
|
108
|
+
? 'the shore has named no screen this boat can seal to. Authorise one again to bring her back'
|
|
109
|
+
: 'no screen has been authorised to read her yet. Add one in the Siparu app, or on her page ashore');
|
|
115
110
|
}
|
|
116
111
|
const boat = this.deps.boatId();
|
|
117
112
|
const keys = this.deps.keys.get();
|
package/plugin/dist/uplink.d.ts
CHANGED
|
@@ -13,8 +13,6 @@ export declare function reportedStatus(socket: {
|
|
|
13
13
|
export interface UplinkDeps {
|
|
14
14
|
relayUrl: string;
|
|
15
15
|
getRemote: () => RemoteLink | undefined;
|
|
16
|
-
frame: () => unknown;
|
|
17
|
-
sealing?: () => boolean;
|
|
18
16
|
debug: (msg: string) => void;
|
|
19
17
|
intervalMs?: number;
|
|
20
18
|
liveHealthy?: () => boolean;
|
package/plugin/dist/uplink.js
CHANGED
|
@@ -102,7 +102,7 @@ class Uplink {
|
|
|
102
102
|
'content-type': 'application/json',
|
|
103
103
|
authorization: `Bearer ${remote.boatToken}`
|
|
104
104
|
},
|
|
105
|
-
body: JSON.stringify(
|
|
105
|
+
body: JSON.stringify({ ts: Date.now() }),
|
|
106
106
|
signal: controller.signal
|
|
107
107
|
});
|
|
108
108
|
if (res.ok) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{c as t,g as n,h as r,i,l as a,n as o,o as s,p as c,s as l,t as u,u as d,v as f}from"./index-
|
|
1
|
+
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{c as t,g as n,h as r,i,l as a,n as o,o as s,p as c,s as l,t as u,u as d,v as f}from"./index-DAYjCs-4.js";import{t as p}from"./visibleInterval-CFxPzgzx.js";var m=e(f(),1),h={"1m":1,"1h":60,"6h":360,"1d":1440},g={"1m":60,"1h":48,"6h":40,"1d":30},_=15e3;function v(){let[e,t]=(0,m.useState)(`1h`),[n,i]=(0,m.useState)(0),[a,o]=(0,m.useState)(!1),[s,c]=(0,m.useState)([]),[l,u]=(0,m.useState)(null),[d,f]=(0,m.useState)(!1),v=h[e],y=g[e]+n*g[e],b=(0,m.useCallback)(e=>{t(e),i(0),c([]),o(!1)},[]),x=(0,m.useCallback)(async()=>{f(!0),u(null);try{let e=y+1,t=await r.logbook.snapshots({bucket:v,limit:e,order:`desc`});o(t.length>y),c(t.slice(0,y))}catch(e){u(e.message)}finally{f(!1)}},[v,y]);return(0,m.useEffect)(()=>{x();let e=p(x,_);return()=>e()},[x]),{granularity:e,changeGran:b,snaps:s,err:l,busy:d,hasMore:a,loadMore:(0,m.useCallback)(()=>i(e=>e+1),[])}}function y(){let[e,t]=(0,m.useState)(o()),[n,i]=(0,m.useState)([]),[a,s]=(0,m.useState)(null),[l,d]=(0,m.useState)(!1),f=(0,m.useRef)(!0),h=c(6e4),g=o(new Date(h)),v=(0,m.useMemo)(()=>u(e),[e]),y=v+24*36e5,b=e===g;(0,m.useEffect)(()=>{f.current&&e!==g&&t(g)},[g,e]);let x=(0,m.useCallback)(e=>{f.current=e===o(),t(e)},[]),S=(0,m.useCallback)(async()=>{d(!0),s(null);try{let e=await r.logbook.snapshots({from:v,to:y,limit:5e3,order:`desc`,bucket:60});i(e)}catch(e){s(e.message)}finally{d(!1)}},[v,y]);return(0,m.useEffect)(()=>{if(S(),!b)return;let e=p(S,_);return()=>e()},[S,b]),{dateStr:e,setDateStr:x,dayStart:v,isToday:b,snaps:n,err:a,busy:l,prevDay:(0,m.useCallback)(()=>x(o(new Date(v-864e5))),[v,x]),nextDay:(0,m.useCallback)(()=>x(o(new Date(v+864e5))),[v,x]),goToday:(0,m.useCallback)(()=>x(o()),[x])}}var b=n(),x=[`1m`,`1h`,`6h`,`1d`],S={"1m":`Last hour`,"1h":`Last 2 days`,"6h":`Last 10 days`,"1d":`Last month`};function C(){let[e,t]=(0,m.useState)(`live`),[n,r]=(0,m.useState)(()=>localStorage.getItem(`lb:windUnit`)||`kn`),i={mode:e,setMode:t,windUnit:n,toggleWind:()=>r(e=>{let t=e===`kn`?`bft`:`kn`;return localStorage.setItem(`lb:windUnit`,t),t})};return(0,b.jsx)(`div`,{className:`lb`,children:e===`live`?(0,b.jsx)(E,{...i}):(0,b.jsx)(D,{...i})})}function w({mode:e,setMode:t}){return(0,b.jsxs)(`div`,{className:`seg`,children:[(0,b.jsx)(`button`,{className:e===`live`?`on`:``,onClick:()=>t(`live`),children:`Live`}),(0,b.jsx)(`button`,{className:e===`day`?`on`:``,onClick:()=>t(`day`),children:`Day`})]})}function T({windUnit:e,toggleWind:t}){return(0,b.jsxs)(`div`,{className:`lb-cols`,children:[(0,b.jsx)(`span`,{children:`UTC`}),(0,b.jsx)(`span`,{children:`SOG`}),(0,b.jsx)(`span`,{children:`HDG`}),(0,b.jsx)(`span`,{className:`tap`,onClick:t,title:`Tap: knots ⇄ Beaufort`,children:e===`kn`?`TWS`:`BFT`}),(0,b.jsx)(`span`,{children:`BARO`}),(0,b.jsx)(`span`,{children:`DEP`})]})}function E({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{granularity:i,changeGran:a,snaps:o,err:s,busy:c,hasMore:l,loadMore:u}=v();return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsx)(`div`,{className:`seg`,children:x.map(e=>(0,b.jsx)(`button`,{className:i===e?`on`:``,onClick:()=>a(e),children:e},e))}),(0,b.jsx)(`span`,{className:`lb-count`,children:o.length})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:S[i]}),(0,b.jsx)(`b`,{children:o.length})]}),s&&(0,b.jsx)(`div`,{className:`lb-err`,children:s}),(0,b.jsx)(O,{snaps:o,windUnit:n,footer:l?(0,b.jsx)(`button`,{className:`lb-more`,onClick:u,disabled:c,children:c?`Loading…`:`Load ${g[i]} more`}):null})]})}function D({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{dateStr:i,setDateStr:a,isToday:s,snaps:c,err:l,busy:u,prevDay:d,nextDay:f,goToday:p}=y(),m=s?`Today · ${new Date(i).toLocaleDateString(`en-GB`,{day:`2-digit`,month:`short`,timeZone:`UTC`})}`:new Date(i).toLocaleDateString(`en-GB`,{weekday:`short`,day:`2-digit`,month:`short`,timeZone:`UTC`}).replace(/,/g,``);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsxs)(`div`,{className:`lb-date`,children:[(0,b.jsx)(`button`,{onClick:d,"aria-label":`Previous day`,children:`‹`}),(0,b.jsx)(`input`,{type:`date`,className:`dt`,value:i,max:o(),onChange:e=>a(e.target.value),style:{border:`1.5px solid var(--rule)`,background:`var(--cell)`,color:`var(--text)`,fontFamily:`var(--sp-font)`,fontSize:12,padding:`5px 7px`}}),(0,b.jsx)(`button`,{onClick:f,disabled:s,"aria-label":`Next day`,children:`›`}),(0,b.jsx)(`button`,{onClick:p,disabled:s,children:`Now`})]})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:m}),(0,b.jsx)(`b`,{children:c.length})]}),l&&(0,b.jsx)(`div`,{className:`lb-err`,children:l}),!u&&c.length===0?(0,b.jsxs)(`div`,{className:`sp-empty`,children:[(0,b.jsx)(`div`,{className:`em-t`,children:`No snapshots`}),(0,b.jsx)(`div`,{className:`em-s`,children:`No telemetry was logged for this day.`})]}):(0,b.jsx)(O,{snaps:c,windUnit:n,footer:null})]})}function O({snaps:e,footer:t,windUnit:n}){return(0,b.jsxs)(`div`,{className:`lb-rows`,children:[e.map(e=>(0,b.jsx)(k,{s:e,windUnit:n},e.ts)),t]})}function k({s:e,windUnit:n}){let r=new Date(e.ts),o=e=>String(e).padStart(2,`0`),c=d(e.sog),u=a(e.heading_true??e.heading_mag),f=l(e.wind_speed_true),p=f===null?`·`:String(n===`kn`?Math.round(f):s(f)??`·`),m=t(e.air_pressure_pa);return(0,b.jsxs)(`div`,{className:`lb-row`,children:[(0,b.jsxs)(`span`,{className:`tm`,children:[o(r.getUTCHours()),`:`,o(r.getUTCMinutes())]}),(0,b.jsx)(`span`,{className:`v`,children:i(c,1)}),(0,b.jsx)(`span`,{className:`v`,children:u===null?`·`:Math.round(u)+`°`}),(0,b.jsx)(`span`,{className:`v`,children:p}),(0,b.jsx)(`span`,{className:`v dim`,children:m===null?`·`:Math.round(m)}),(0,b.jsx)(`span`,{className:`v`,children:e.depth===null?`·`:e.depth.toFixed(1)})]})}function A(){return(0,b.jsx)(C,{})}export{A as default};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{a as t,d as n,g as r,h as i,i as a,l as o,r as s,u as c,v as l}from"./index-
|
|
1
|
+
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{a as t,d as n,g as r,h as i,i as a,l as o,r as s,u as c,v as l}from"./index-DAYjCs-4.js";import{t as u}from"./visibleInterval-CFxPzgzx.js";import{a as d,i as f,n as p,o as m,r as h,s as g,t as _}from"./style-BEJHY9Dg.js";var v=e(l(),1),y={fill:`#ffb938`,stroke:`#b38326`,glow:`rgba(255,185,56,0.65)`,dot:`#050706`};function b(e,t=y){let n=t.fill,r=t.stroke;return e===null||Number.isNaN(e)?`
|
|
2
2
|
<div style="
|
|
3
3
|
width: 14px; height: 14px;
|
|
4
4
|
background: ${n};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{_ as t,f as n,g as r,h as i,i as a,m as o,r as s,v as c}from"./index-
|
|
1
|
+
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{_ as t,f as n,g as r,h as i,i as a,m as o,r as s,v as c}from"./index-DAYjCs-4.js";import{a as l,i as u,n as d,o as f,s as p,t as m}from"./style-BEJHY9Dg.js";var h=e(c(),1),g=[{mode:`total_l`,label:`Litres`},{mode:`total_usgal`,label:`US gallons`},{mode:`total_impgal`,label:`Imp gallons`},{mode:`per_nm`,label:`Litres / nm`},{mode:`nm_per_l`,label:`nm / L`},{mode:`per_hour`,label:`Litres / hour`}],_=.26417205,v=.21996923,y=1e-6;function b(e,t,n){return`${e.toFixed(n)} ${t}`}function x(e,t,n,r){if(e===null||!Number.isFinite(e))return null;switch(r){case`total_l`:return b(e,`L`,+(e<100));case`total_usgal`:return b(e*_,`US gal`,1);case`total_impgal`:return b(e*v,`Imp gal`,1);case`per_nm`:return t>y?b(e/t,`L/nm`,2):null;case`nm_per_l`:return e>y?b(t/e,`nm/L`,2):null;case`per_hour`:return n>y?b(e/n,`L/h`,1):null}}function S(e){if(e===null)return``;let t=String(e);return/[",\n\r]/.test(t)?`"${t.replace(/"/g,`""`)}"`:t}function C(e){if(!(e>0))return``;let t=Math.round(e*60),n=Math.floor(t/60),r=t%60;return n>0?`${n}h ${r}m`:`${r}m`}var w=e=>e===null?null:new Date(e).toISOString(),T=[`id`,`start_utc`,`end_utc`,`duration`,`from`,`to`,`distance_nm`,`hours_underway`,`avg_sog_kn`,`max_sog_kn`,`fuel_used_l`,`start_lat`,`start_lon`,`end_lat`,`end_lon`];function E(e){let t=[...e].sort((e,t)=>e.start_ts-t.start_ts),n=[T.join(`,`)];for(let e of t)n.push([e.id,w(e.start_ts),w(e.end_ts),C(e.hours_underway),e.start_port,e.end_port,e.distance_nm,e.hours_underway,e.avg_sog_kn,e.max_sog_kn,e.fuel_used_l,e.start_lat,e.start_lon,e.end_lat,e.end_lon].map(S).join(`,`));return n.join(`\r
|
|
2
2
|
`)+`\r
|
|
3
3
|
`}function D(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`)}function O(e){let t=new Date(e.start_ts).toISOString().slice(0,10);return!e.start_port&&!e.end_port?`Voyage ${e.id} ${t}`:`${t} ${e.start_port??`?`} to ${e.end_port??`?`}`}function k(e,t){let n=D(O(e)),r=t.map(e=>` <trkpt lat="${e.lat}" lon="${e.lon}"><time>${new Date(e.ts).toISOString()}</time></trkpt>`).join(`
|
|
4
4
|
`);return[`<?xml version="1.0" encoding="UTF-8"?>`,`<gpx version="1.1" creator="Siparu" xmlns="http://www.topografix.com/GPX/1/1">`,` <metadata>`,` <name>${n}</name>`,` <time>${new Date(e.start_ts).toISOString()}</time>`,` </metadata>`,` <trk>`,` <name>${n}</name>`,` <trkseg>`,r,` </trkseg>`,` </trk>`,`</gpx>`,``].join(`
|