@signalk/tracks-plugin 3.0.0-beta.2 → 3.0.0-beta.4
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 +8 -2
- package/dist/e2e.test-utils.d.ts +10 -0
- package/dist/gpx.d.ts +59 -0
- package/dist/gpx.test.d.ts +1 -0
- package/dist/harness.test-utils.d.ts +68 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.html +30 -0
- package/dist/index.js +880 -51
- package/dist/index.js.map +1 -1
- package/dist/simplify.d.ts +46 -0
- package/dist/simplify.test.d.ts +1 -0
- package/dist/sourceWatch.d.ts +17 -28
- package/dist/style.css +49 -0
- package/dist/trackApi.d.ts +9 -11
- package/dist/trackProvider.d.ts +9 -0
- package/dist/tracks.js +192 -0
- package/dist/utils.d.ts +60 -0
- package/dist/webapp.e2e.test.d.ts +1 -0
- package/dist/webapp.test.d.ts +1 -0
- package/package.json +6 -3
- package/public/index.html +30 -0
- package/public/style.css +49 -0
- package/public/tracks.js +192 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TimedPosition } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Metres per degree of latitude. Good to ~0.5% over the WGS84 ellipsoid.
|
|
4
|
+
*
|
|
5
|
+
* Exported because the provider derives its automatic tolerance from the same
|
|
6
|
+
* figure: "one part in a thousand of the diagonal" only holds while both use
|
|
7
|
+
* one metres-per-degree value.
|
|
8
|
+
*/
|
|
9
|
+
export declare const M_PER_DEG = 111320;
|
|
10
|
+
/**
|
|
11
|
+
* Douglas-Peucker simplification of a recorded track.
|
|
12
|
+
*
|
|
13
|
+
* Distinct from `thin()`, and the difference is the whole point: thinning
|
|
14
|
+
* decimates by *time*, so it keeps a point every N seconds whether the boat was
|
|
15
|
+
* turning or holding a course, and a corner falling between two samples is
|
|
16
|
+
* simply lost. Simplification decimates by *shape* — it keeps the points that
|
|
17
|
+
* carry the geometry and drops the ones a straight line already describes.
|
|
18
|
+
*
|
|
19
|
+
* That is what makes this the piece a route conversion needs. 5,550 points at
|
|
20
|
+
* 1.2 s spacing is a recording, not a route; thinning it to 30 points gives 30
|
|
21
|
+
* evenly spaced positions that miss every turn, while simplifying it to 30
|
|
22
|
+
* gives the turns and nothing else.
|
|
23
|
+
*
|
|
24
|
+
* `epsilon` is a tolerance in **metres**: no point of the original track is
|
|
25
|
+
* further than this from the simplified line.
|
|
26
|
+
*/
|
|
27
|
+
export declare function simplify(points: TimedPosition[], epsilon: number): TimedPosition[];
|
|
28
|
+
/**
|
|
29
|
+
* The smallest epsilon that simplifies `points` to at most `target` of them.
|
|
30
|
+
*
|
|
31
|
+
* Epsilon is meaningful in the physical world but not in the thing a user
|
|
32
|
+
* cares about: "accurate to 10 m" gives 8 waypoints across an open bay and 400
|
|
33
|
+
* through an archipelago, so a client asking for a usable route cannot pick a
|
|
34
|
+
* number in advance. A point budget is predictable; the tolerance that
|
|
35
|
+
* achieved it is what gets reported back.
|
|
36
|
+
*
|
|
37
|
+
*
|
|
38
|
+
* Not reachable through the v2 API, which takes a tolerance rather than a
|
|
39
|
+
* budget. It is here for the track-management webapp's "convert to route",
|
|
40
|
+
* where the user asks for a usable number of waypoints and is told the
|
|
41
|
+
* accuracy that produced them.
|
|
42
|
+
*/
|
|
43
|
+
export declare function simplifyToBudget(points: TimedPosition[], target: number): {
|
|
44
|
+
points: TimedPosition[];
|
|
45
|
+
epsilon: number;
|
|
46
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/sourceWatch.d.ts
CHANGED
|
@@ -1,35 +1,24 @@
|
|
|
1
1
|
import { Context } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Watches which `$source` each context's positions arrive from.
|
|
4
|
-
*
|
|
5
|
-
* `navigation.position` is a rapid update — a GPS may report at 10Hz — and a
|
|
6
|
-
* typical boat has several receivers: an internal GPS, an AIS transponder, a
|
|
7
|
-
* chart plotter echoing its own fix.
|
|
8
|
-
*
|
|
9
|
-
* Signal K resolves which one wins through source priority, and the bus this
|
|
10
|
-
* plugin listens on is the filtered one: the server applies `toPreferredDelta`
|
|
11
|
-
* before emitting `delta`, which is what feeds `getBus()`. So with a priority
|
|
12
|
-
* rule in place, only the winner arrives and there is nothing to warn about.
|
|
13
|
-
*
|
|
14
|
-
* The gap is when no rule *matches* the path. Then the engine passes every
|
|
15
|
-
* source through, the fixes from receivers metres apart interleave, and the
|
|
16
|
-
* track zigzags between them rather than following the boat. The fix is to
|
|
17
|
-
* configure source priority for `navigation.position`; this watcher exists to
|
|
18
|
-
* say so, because the symptom (a track that looks noisy) does not obviously
|
|
19
|
-
* point at the cause.
|
|
20
|
-
*
|
|
21
|
-
* Detection is therefore empirical rather than reading the priority config:
|
|
22
|
-
* seeing several sources here *is* the evidence that no rule is matching,
|
|
23
|
-
* which a config that merely exists would not tell us.
|
|
24
|
-
*/
|
|
25
2
|
export declare class SourceWatch {
|
|
3
|
+
/** Last time each source produced a position, per context. */
|
|
26
4
|
private readonly seen;
|
|
5
|
+
/** Sources found competing, per context, with when each was last seen so. */
|
|
6
|
+
private readonly competing;
|
|
7
|
+
/** The current alternation per context: who is in it, and how long it is. */
|
|
8
|
+
private readonly lastSeen;
|
|
27
9
|
/** Record that `context` produced a position from `$source`. */
|
|
28
|
-
add(context: Context, source: string | undefined): void;
|
|
29
|
-
/**
|
|
10
|
+
add(context: Context, source: string | undefined, now?: number): void;
|
|
11
|
+
/** Every source seen for a context, in the order they first appeared. */
|
|
30
12
|
sourcesFor(context: Context): string[];
|
|
31
|
-
/**
|
|
32
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The sources found live at the same time, which is what a warning names.
|
|
15
|
+
*
|
|
16
|
+
* Expired entries are dropped rather than merely hidden, so a conflict that
|
|
17
|
+
* has stopped recurring leaves nothing behind.
|
|
18
|
+
*/
|
|
19
|
+
competingFor(context: Context, now?: number): string[];
|
|
20
|
+
/** Contexts that have received positions from two sources at once. */
|
|
21
|
+
conflicted(now?: number): Context[];
|
|
33
22
|
/**
|
|
34
23
|
* A status line naming the problem, or undefined when nothing is wrong.
|
|
35
24
|
*
|
|
@@ -38,7 +27,7 @@ export declare class SourceWatch {
|
|
|
38
27
|
* vessels are usually AIS, where multiple receivers are expected and less
|
|
39
28
|
* worth nagging about — they are counted, not named.
|
|
40
29
|
*/
|
|
41
|
-
warning(selfContext?: Context): string | undefined;
|
|
30
|
+
warning(selfContext?: Context, now?: number): string | undefined;
|
|
42
31
|
/** Forget everything seen; used when the plugin restarts. */
|
|
43
32
|
clear(): void;
|
|
44
33
|
}
|
package/dist/style.css
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 1rem;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
h1 {
|
|
12
|
+
font-size: 1.25rem;
|
|
13
|
+
margin: 0 0 0.25rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#status {
|
|
17
|
+
color: GrayText;
|
|
18
|
+
margin: 0 0 1rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#status[data-state='error'] {
|
|
22
|
+
color: crimson;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
table {
|
|
26
|
+
border-collapse: collapse;
|
|
27
|
+
width: 100%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
th,
|
|
31
|
+
td {
|
|
32
|
+
border-bottom: 1px solid ButtonBorder;
|
|
33
|
+
padding: 0.4rem 0.6rem;
|
|
34
|
+
text-align: left;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Point counts are compared down the column, so they line up on the right. */
|
|
38
|
+
.num {
|
|
39
|
+
text-align: right;
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tbody tr:hover {
|
|
44
|
+
background: Canvas;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.self {
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
}
|
package/dist/trackApi.d.ts
CHANGED
|
@@ -15,18 +15,16 @@ export type TrackBoundingBox = [number, number, number, number];
|
|
|
15
15
|
/**
|
|
16
16
|
* A request as the server hands it to a provider.
|
|
17
17
|
*
|
|
18
|
-
* Not every field is honoured here. `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* rather than a wrong answer.
|
|
18
|
+
* Not every field is honoured here. `properties` is accepted by the API and
|
|
19
|
+
* ignored by this provider: it serves positions, and has no co-recorded values
|
|
20
|
+
* to attach. Geometry comes back without them, which is a superset of what such
|
|
21
|
+
* a request asked for rather than a wrong answer.
|
|
23
22
|
*
|
|
24
|
-
* `resolution` and `
|
|
25
|
-
* spacing actually used —
|
|
26
|
-
* budget widens
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
* SignalK/tracks.
|
|
23
|
+
* `resolution`, `maxPoints`, `simplify` and `epsilon` are all applied, and the
|
|
24
|
+
* response reports the spacing and tolerance actually used — neither is always
|
|
25
|
+
* the one asked for, since a budget widens the spacing and `simplify` without
|
|
26
|
+
* an epsilon leaves the tolerance to the provider.
|
|
27
|
+
|
|
30
28
|
*/
|
|
31
29
|
export interface TracksRequest {
|
|
32
30
|
contexts?: string[];
|
package/dist/trackProvider.d.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface TrackProviderDeps {
|
|
|
18
18
|
selfContext: () => string;
|
|
19
19
|
/** Gap in ms that starts a new segment; 0 leaves the track as one line. */
|
|
20
20
|
segmentGap: () => number;
|
|
21
|
+
/**
|
|
22
|
+
* The vessel's name for `contextName`, or undefined when unknown.
|
|
23
|
+
*
|
|
24
|
+
* Injected like the rest: resolving it needs the server's data model, which
|
|
25
|
+
* the provider has no handle on. Called per query rather than cached,
|
|
26
|
+
* because an AIS target's static report routinely lands well after its
|
|
27
|
+
* first position.
|
|
28
|
+
*/
|
|
29
|
+
contextName: (context: string) => string | undefined;
|
|
21
30
|
/**
|
|
22
31
|
* Positions a history provider holds for a context and window, reconciled
|
|
23
32
|
* with the store's own.
|
package/dist/tracks.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track list for the management webapp.
|
|
3
|
+
*
|
|
4
|
+
* Reads the v2 Track API with `geometry=false`, which answers with each
|
|
5
|
+
* track's metadata and no coordinates — the same query the API already serves,
|
|
6
|
+
* so listing hundreds of tracks costs nothing like fetching them.
|
|
7
|
+
*
|
|
8
|
+
* Plain modules and no build step: the plugin ships `public/` as static files
|
|
9
|
+
* and the server mounts it directly, so anything needing a bundler would mean
|
|
10
|
+
* a second build output for a page this size.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How far back the list reaches.
|
|
15
|
+
*
|
|
16
|
+
* The API refuses an unbounded multi-context query — "a time window (from or
|
|
17
|
+
* duration) is required unless a single context is given" — because listing
|
|
18
|
+
* every track a server ever saw is a question with no cheap answer. Thirty
|
|
19
|
+
* days covers the passage someone is looking for while keeping the query
|
|
20
|
+
* something the store can serve.
|
|
21
|
+
*/
|
|
22
|
+
const WINDOW = 'P30D'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ceiling on the list request.
|
|
26
|
+
*
|
|
27
|
+
* Generous, because a month of tracks on a busy receiver is a real query, not
|
|
28
|
+
* a hung one — this exists to end a request that will never answer, not to
|
|
29
|
+
* hurry a slow store.
|
|
30
|
+
*/
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 30_000
|
|
32
|
+
|
|
33
|
+
/** Relative, because the server mounts this under /@signalk/tracks-plugin. */
|
|
34
|
+
const TRACKS_URL = `../../signalk/v2/api/tracks?geometry=false&duration=${WINDOW}`
|
|
35
|
+
|
|
36
|
+
const status = document.getElementById('status')
|
|
37
|
+
const table = document.getElementById('tracks')
|
|
38
|
+
const tbody = table.querySelector('tbody')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The label for a track, matching `trackLabel()` on the server.
|
|
42
|
+
*
|
|
43
|
+
* The API gives `contextName` — the vessel's own name, undecorated and absent
|
|
44
|
+
* when unknown — plus `isSelf`. Turning those into something a person
|
|
45
|
+
* recognises is the client's job, and it has to agree with what v1 returns or
|
|
46
|
+
* the same track reads differently depending on which screen it is on.
|
|
47
|
+
*/
|
|
48
|
+
function label({ context, contextName, isSelf }) {
|
|
49
|
+
if (isSelf) {
|
|
50
|
+
return 'Own Ship'
|
|
51
|
+
}
|
|
52
|
+
if (contextName) {
|
|
53
|
+
return `AIS ${contextName}`
|
|
54
|
+
}
|
|
55
|
+
const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context)
|
|
56
|
+
return mmsi ? `AIS ${mmsi[1]}` : context
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** ISO-8601 from the API, rendered in the viewer's own locale and zone. */
|
|
60
|
+
function when(iso) {
|
|
61
|
+
const at = new Date(iso)
|
|
62
|
+
return Number.isNaN(at.getTime()) ? '—' : at.toLocaleString()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function row(properties) {
|
|
66
|
+
const tr = document.createElement('tr')
|
|
67
|
+
const name = document.createElement('td')
|
|
68
|
+
name.textContent = label(properties)
|
|
69
|
+
if (properties.isSelf) {
|
|
70
|
+
name.className = 'self'
|
|
71
|
+
}
|
|
72
|
+
tr.append(
|
|
73
|
+
name,
|
|
74
|
+
cell(when(properties.from)),
|
|
75
|
+
cell(when(properties.to)),
|
|
76
|
+
cell(properties.pointCount, 'num'),
|
|
77
|
+
exportCell(properties),
|
|
78
|
+
)
|
|
79
|
+
return tr
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A link that downloads this track as GPX.
|
|
84
|
+
*
|
|
85
|
+
* A plain link rather than a fetch: the browser already knows how to save a
|
|
86
|
+
* response with a Content-Disposition on it, and the server sets one. Doing it
|
|
87
|
+
* by hand would mean holding a whole track in memory to build a blob.
|
|
88
|
+
*/
|
|
89
|
+
function exportCell({ context, isSelf }) {
|
|
90
|
+
const td = document.createElement('td')
|
|
91
|
+
const link = document.createElement('a')
|
|
92
|
+
// `self` resolves server-side; using it keeps the URL short and avoids
|
|
93
|
+
// guessing how the own vessel's context should be spelled.
|
|
94
|
+
const vessel = isSelf ? 'self/track.gpx' : `vessels/${encodeURIComponent(context)}/track.gpx`
|
|
95
|
+
link.href = `../../signalk/v1/api/${vessel}?duration=${WINDOW}`
|
|
96
|
+
link.textContent = 'GPX'
|
|
97
|
+
link.rel = 'nofollow'
|
|
98
|
+
td.append(link)
|
|
99
|
+
return td
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cell(text, className) {
|
|
103
|
+
const td = document.createElement('td')
|
|
104
|
+
// textContent, never innerHTML: a vessel name arrives from an AIS
|
|
105
|
+
// transmission and is not ours to trust as markup.
|
|
106
|
+
td.textContent = String(text)
|
|
107
|
+
if (className) {
|
|
108
|
+
td.className = className
|
|
109
|
+
}
|
|
110
|
+
return td
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function show(message, state) {
|
|
114
|
+
status.textContent = message
|
|
115
|
+
if (state) {
|
|
116
|
+
status.dataset.state = state
|
|
117
|
+
} else {
|
|
118
|
+
delete status.dataset.state
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function load() {
|
|
123
|
+
let response
|
|
124
|
+
// Bounded, because an unsettled fetch is the one failure the page cannot
|
|
125
|
+
// recover from: every other path ends in an error message, while a stalled
|
|
126
|
+
// server or proxy leaves it on "Loading…" indefinitely, which reads as a
|
|
127
|
+
// hang rather than a failure. The signal stays armed through json() below,
|
|
128
|
+
// so a response that stops mid-body is caught too.
|
|
129
|
+
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
130
|
+
try {
|
|
131
|
+
response = await fetch(TRACKS_URL, { credentials: 'include', signal: deadline })
|
|
132
|
+
} catch {
|
|
133
|
+
show(deadline.aborted ? 'The server did not answer in time.' : 'Could not reach the server.', 'error')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// 501 is the server saying no provider is registered, which is a different
|
|
137
|
+
// thing from an error and worth telling the user plainly.
|
|
138
|
+
if (response.status === 501) {
|
|
139
|
+
show('No track provider is registered on this server.', 'error')
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
show(`The server answered ${response.status}.`, 'error')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let body
|
|
148
|
+
try {
|
|
149
|
+
body = await response.json()
|
|
150
|
+
} catch {
|
|
151
|
+
// A 200 carrying something that is not JSON: a proxy's error page, or a
|
|
152
|
+
// body that stops mid-stream, which the deadline above aborts here rather
|
|
153
|
+
// than at the fetch. Without this the rejection escapes `void load()` and
|
|
154
|
+
// the page sits on "Loading…" forever, which looks like a hang.
|
|
155
|
+
show(
|
|
156
|
+
deadline.aborted ? 'The server did not answer in time.' : 'The server sent a response this page could not read.',
|
|
157
|
+
'error',
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// A 200 carrying something else entirely -- an error object, a login page --
|
|
162
|
+
// is a failure, not an empty list. Reporting "no tracks" for it would tell
|
|
163
|
+
// the user the opposite of what happened.
|
|
164
|
+
if (body?.type !== 'FeatureCollection' || !Array.isArray(body.features)) {
|
|
165
|
+
show('The server sent a response this page could not read.', 'error')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
// Individual entries are still skipped rather than thrown on: one malformed
|
|
169
|
+
// feature should not blank a list that is otherwise fine.
|
|
170
|
+
const features = body.features
|
|
171
|
+
// Newest first: the track someone came to look at is almost always the one
|
|
172
|
+
// that just finished.
|
|
173
|
+
const sorted = features
|
|
174
|
+
.map((feature) => feature?.properties)
|
|
175
|
+
.filter(
|
|
176
|
+
(properties) => properties && typeof properties.context === 'string' && Number.isFinite(properties.pointCount),
|
|
177
|
+
)
|
|
178
|
+
.sort((a, b) => String(b.to ?? '').localeCompare(String(a.to ?? '')))
|
|
179
|
+
|
|
180
|
+
// Checked after filtering, not before: a response of nothing but malformed
|
|
181
|
+
// features would otherwise draw an empty table reading "0 tracks".
|
|
182
|
+
if (sorted.length === 0) {
|
|
183
|
+
show('No tracks in the last 30 days.')
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
tbody.replaceChildren(...sorted.map(row))
|
|
188
|
+
table.hidden = false
|
|
189
|
+
show(`${sorted.length} track${sorted.length === 1 ? '' : 's'} in the last 30 days.`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
void load()
|
package/dist/utils.d.ts
CHANGED
|
@@ -38,3 +38,63 @@ export declare function createMatcher(params: TrackParams, selfPosition?: LatLng
|
|
|
38
38
|
* track rather than a failure anybody can see.
|
|
39
39
|
*/
|
|
40
40
|
export declare const historyRowPosition: (row: unknown) => LatLngTuple | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The name of the vessel (or aircraft, or aton) a context refers to.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately bare — `Ariadne`, not `AIS Ariadne` and not `Own Ship`. This is
|
|
45
|
+
* the v2 Track API's `contextName`, whose spec says "Name of the vessel,
|
|
46
|
+
* aircraft or other context, where known. Not a name for the track itself."
|
|
47
|
+
* Decoration belongs to whoever is drawing a list; see `trackLabel`.
|
|
48
|
+
*
|
|
49
|
+
* `lookup` reads the data model (`app.getPath`). Note the shape: a vessel's
|
|
50
|
+
* `name` is a bare string in the full model, not the `{value}` wrapper that
|
|
51
|
+
* `navigation.position` and friends carry, so both are accepted rather than
|
|
52
|
+
* guessed at.
|
|
53
|
+
*
|
|
54
|
+
* Resolves `name` and nothing else, matching the server's own `findContextName`
|
|
55
|
+
* (`packages/server-admin-ui/src/utils/pathKeys.ts`), which returns undefined
|
|
56
|
+
* rather than substituting an identifier. An MMSI is how a vessel is addressed,
|
|
57
|
+
* not what it is called, so it belongs in `trackLabel` where something has to
|
|
58
|
+
* render — putting it here would make "where known" untrue.
|
|
59
|
+
*/
|
|
60
|
+
export declare function contextName(context: string, lookup: (path: string) => unknown): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* A display label for a track, as a chart plotter would show it.
|
|
63
|
+
*
|
|
64
|
+
* TimeZero's GPX exports label tracks `Own Ship` and `AIS <shipname>` — never a
|
|
65
|
+
* serial number — and that is what makes a list of tracks readable. A UI
|
|
66
|
+
* listing `vessels.urn:mrn:imo:mmsi:211234567` is unusable.
|
|
67
|
+
*
|
|
68
|
+
* Distinct from `contextName`: this one *is* a name for the track, so it says
|
|
69
|
+
* whose track it is and where the identity came from. Falls back to the raw
|
|
70
|
+
* context, because a label has to render something.
|
|
71
|
+
*/
|
|
72
|
+
export declare function trackLabel(context: string, selfContext: string | undefined, lookup: (path: string) => unknown): string;
|
|
73
|
+
/**
|
|
74
|
+
* A track label as a filename a browser can save.
|
|
75
|
+
*
|
|
76
|
+
* A vessel name arrives from an AIS transmission and is not ours to trust in a
|
|
77
|
+
* path, so the characters that could break out of one — separators, quotes,
|
|
78
|
+
* controls — are replaced. Everything else is kept, including non-ASCII: a
|
|
79
|
+
* boat called `Ärger` keeps its name.
|
|
80
|
+
*/
|
|
81
|
+
export declare function gpxFilename(label: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* The ASCII-only form of a filename, for the quoted `Content-Disposition`
|
|
84
|
+
* parameter.
|
|
85
|
+
*
|
|
86
|
+
* An HTTP header carries bytes: anything outside Latin-1 makes `setHeader`
|
|
87
|
+
* throw, and anything outside ASCII is reinterpreted byte-wise by the client,
|
|
88
|
+
* so `Ärger.gpx` arrives as mojibake. The real name travels in the `filename*`
|
|
89
|
+
* parameter; this is the fallback for readers that do not implement it.
|
|
90
|
+
*/
|
|
91
|
+
export declare function asciiFilename(filename: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* A filename as an RFC 8187 extended value, for `filename*`.
|
|
94
|
+
*
|
|
95
|
+
* `encodeURIComponent` is close but not exact: it leaves `!'()*` unescaped,
|
|
96
|
+
* and RFC 8187's `attr-char` set excludes them. A vessel called
|
|
97
|
+
* `Boat (Test)` would otherwise put raw parentheses into the header, which is
|
|
98
|
+
* not a valid extended value.
|
|
99
|
+
*/
|
|
100
|
+
export declare function rfc8187(filename: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalk/tracks-plugin",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.4",
|
|
4
4
|
"description": "Plugin to keep track of tracks of vessels",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
"//files": "app-icon.svg ships alongside dist because `files` is an allowlist: the app store probes the declared signalk.appIcon path inside the published tarball, so an icon left out here 404s and the plugin falls back to a placeholder.",
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
+
"public",
|
|
18
19
|
"app-icon.svg"
|
|
19
20
|
],
|
|
20
21
|
"signalk": {
|
|
21
22
|
"displayName": "Tracks",
|
|
22
|
-
"appIcon": "app-icon.svg"
|
|
23
|
+
"appIcon": "./app-icon.svg"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"signalk-node-server-plugin",
|
|
27
|
+
"signalk-webapp",
|
|
26
28
|
"signalk-category-utility"
|
|
27
29
|
],
|
|
28
30
|
"author": "Teppo Kurki <teppo.kurki@iki.fi>",
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
"license": "Apache-2.0",
|
|
53
55
|
"dependencies": {
|
|
54
56
|
"@js-temporal/polyfill": "^0.5.1",
|
|
57
|
+
"@xmldom/xmldom": "^0.9.12",
|
|
55
58
|
"rxjs": "^7.8.2",
|
|
56
59
|
"s2js": "^1.44.0",
|
|
57
60
|
"typebox": "^1.3.11"
|
|
@@ -72,6 +75,6 @@
|
|
|
72
75
|
"typescript-eslint": "^8.66.0",
|
|
73
76
|
"vite": "^8.2.2",
|
|
74
77
|
"vite-plugin-dts": "^5.0.3",
|
|
75
|
-
"vitest": "^
|
|
78
|
+
"vitest": "^5.0.0"
|
|
76
79
|
}
|
|
77
80
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Tracks</title>
|
|
7
|
+
<link rel="stylesheet" href="./style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<h1>Tracks</h1>
|
|
12
|
+
<p id="status" role="status">Loading…</p>
|
|
13
|
+
</header>
|
|
14
|
+
<main>
|
|
15
|
+
<table id="tracks" hidden>
|
|
16
|
+
<thead>
|
|
17
|
+
<tr>
|
|
18
|
+
<th scope="col">Vessel</th>
|
|
19
|
+
<th scope="col">From</th>
|
|
20
|
+
<th scope="col">To</th>
|
|
21
|
+
<th scope="col" class="num">Points</th>
|
|
22
|
+
<th scope="col">Export</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody></tbody>
|
|
26
|
+
</table>
|
|
27
|
+
</main>
|
|
28
|
+
<script type="module" src="./tracks.js"></script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 1rem;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
h1 {
|
|
12
|
+
font-size: 1.25rem;
|
|
13
|
+
margin: 0 0 0.25rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#status {
|
|
17
|
+
color: GrayText;
|
|
18
|
+
margin: 0 0 1rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#status[data-state='error'] {
|
|
22
|
+
color: crimson;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
table {
|
|
26
|
+
border-collapse: collapse;
|
|
27
|
+
width: 100%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
th,
|
|
31
|
+
td {
|
|
32
|
+
border-bottom: 1px solid ButtonBorder;
|
|
33
|
+
padding: 0.4rem 0.6rem;
|
|
34
|
+
text-align: left;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Point counts are compared down the column, so they line up on the right. */
|
|
38
|
+
.num {
|
|
39
|
+
text-align: right;
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tbody tr:hover {
|
|
44
|
+
background: Canvas;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.self {
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
}
|