@signalk/tracks-plugin 3.0.0-beta.2 → 3.0.0-beta.3
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 +783 -39
- package/dist/index.js.map +1 -1
- package/dist/simplify.d.ts +46 -0
- package/dist/simplify.test.d.ts +1 -0
- 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 -2
- package/public/index.html +30 -0
- package/public/style.css +49 -0
- package/public/tracks.js +192 -0
package/public/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()
|