@signalk/tracks-plugin 3.0.0-beta.1 → 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.
@@ -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 {};
@@ -55,16 +55,20 @@ export declare class SqliteTrackStore implements TrackStore {
55
55
  track: LatLngTuple[];
56
56
  }[]>;
57
57
  /**
58
- * Tracks whose last position matches the spatial predicate.
58
+ * Tracks matching the spatial predicate, which depends on who is asking.
59
+ *
60
+ * `intersects: false` — the v1 routes — matches a track's *last* position:
61
+ * "which vessels are near me now". `intersects: true` — the v2 Track API —
62
+ * matches a track that passed through the box at any point in the window,
63
+ * including one that has since left.
59
64
  *
60
65
  * Two filters run, and they are not redundant despite both testing bounds:
61
66
  *
62
67
  * - `contextsInBounds` asks the cell index which contexts have *any* position
63
68
  * near the box. It is a cheap prefilter that keeps the query off every row
64
69
  * in the table, and it decides nothing on its own.
65
- * - `matcher` — the same predicate the in-memory store uses — then tests each
66
- * track's *last* position, which is what the endpoint actually means by
67
- * "in this box". That test is authoritative.
70
+ * - `matcher` — the same predicate the in-memory store uses — then applies
71
+ * whichever rule the caller asked for. That test is authoritative.
68
72
  *
69
73
  * Because `matcher` has the final say, dropping the exact-bounds check inside
70
74
  * `contextsInBounds` does not change any result; it only widens the candidate
@@ -88,7 +92,7 @@ export declare class SqliteTrackStore implements TrackStore {
88
92
  * straight line across the anchorage.
89
93
  */
90
94
  segments(context: Context, window?: TimeWindow): LatLngTuple[][];
91
- prune(maxAge: number): void;
95
+ prune(maxAge: number, keep?: Context): void;
92
96
  close(): void;
93
97
  /** Unsigned round-trip check, used by the tests. */
94
98
  cellIdsFor(context: Context): bigint[];
@@ -1,8 +1,4 @@
1
1
  import { Context } from './types.js';
2
- /**
3
- * Off unless a user opts in, per the request and because the states worth
4
- * pausing on depend on how a boat is used.
5
- */
6
2
  export declare const DEFAULT_PAUSE_STATES: string[];
7
3
  /**
8
4
  * The states a boat is plausibly parked in, offered in the plugin UI.
package/dist/store.d.ts CHANGED
@@ -8,12 +8,12 @@ import { TrackQuery } from './timeWindow.js';
8
8
  * calls in `index.ts` and nothing more, so that adding an implementation cannot
9
9
  * quietly widen what a store is expected to do.
10
10
  *
11
- * Note what is *not* here. `getAllTracks` and `getFilteredTracks` are derived
12
- * operations — filtering is a predicate over whole tracks, which the in-memory
13
- * implementation applies in JS and a database implementation will want to push
14
- * into the query. Both stay on the interface for that reason: a store that can
15
- * filter in SQL must be allowed to, rather than being forced to materialise
16
- * every track so a caller can filter it afterwards.
11
+ * `getAllTracks` and `getFilteredTracks` are derived operations, and they are
12
+ * on the interface deliberately: filtering is a predicate over whole tracks,
13
+ * which the in-memory implementation applies in JS and a database
14
+ * implementation will want to push into the query. A store that can filter in
15
+ * SQL must be allowed to, rather than being forced to materialise every track
16
+ * so a caller can filter it afterwards.
17
17
  */
18
18
  export interface TrackStore {
19
19
  /** Record a position for a context. `timestamp` defaults to now. */
@@ -21,7 +21,8 @@ export interface TrackStore {
21
21
  /**
22
22
  * Seed a context's track, replacing whatever it held.
23
23
  *
24
- * Used by the History API bootstrap at startup. `timestamps` is positional
24
+ * A test helper: it is how a known track is installed without going through
25
+ * the position bus, which throttles on write. `timestamps` is positional
25
26
  * against `track`; points without one are dated to the start of time so a
26
27
  * time-window query treats them as older than anything live.
27
28
  */
@@ -50,8 +51,26 @@ export interface TrackStore {
50
51
  * "vessels currently near here" rather than "vessels that ever passed here".
51
52
  */
52
53
  getFilteredTracks(params: TrackParams, selfPosition?: LatLngTuple, debug?: Debug, query?: TrackQuery): Promise<TrackCollection>;
53
- /** Drop contexts whose newest position is older than `maxAge` ms. */
54
- prune(maxAge: number): void;
54
+ /**
55
+ * Drop contexts whose newest position is older than `maxAge` ms.
56
+ *
57
+ * `keep` is never pruned however long it has been idle. The own vessel's
58
+ * track is the one a user came for — it has to survive a winter on a
59
+ * mooring, a passage with the server off, and any gap in reception. Other
60
+ * vessels age out: a harbour puts hundreds of AIS targets past a receiver in
61
+ * a day, and keeping every one of them forever is not what anybody asked for.
62
+ *
63
+ * An implementation must also apply whatever row-level retention it was
64
+ * configured with, scoped to `keep`. The two are separate: one drops a whole
65
+ * vessel that has gone quiet, the other trims old points from the vessel a
66
+ * user is actually recording, and neither may switch the other off.
67
+ *
68
+ * `Infinity` ages nothing out, which is how a caller asks for the retention
69
+ * pass alone. A negative `maxAge` puts the cutoff in the future and so drops
70
+ * every context — the tests use `-1` for exactly that; the plugin's schema
71
+ * keeps it out of a real configuration.
72
+ */
73
+ prune(maxAge: number, keep?: Context): void;
55
74
  /**
56
75
  * Release any resources held by the store.
57
76
  *
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
+ }
@@ -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. `simplify`, `epsilon` and `properties` are
19
- * accepted by the API and ignored by this provider: it serves positions, and
20
- * has no co-recorded values to attach or geometry simplifier to run. Geometry
21
- * comes back unsimplified, which is a superset of what such a request asked for
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 `maxPoints` are both applied, and the response reports the
25
- * spacing actually used — which is not always the one asked for, since a
26
- * budget widens it.
27
- *
28
- * Simplification and co-recorded properties are tracked separately; see
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[];
@@ -1,5 +1,6 @@
1
1
  import { TrackStore } from './store.js';
2
2
  import { TrackApi } from './trackApi.js';
3
+ import { TimedPosition, TimeWindow } from './types.js';
3
4
  /**
4
5
  * Serves the v2 Track API from this plugin's store.
5
6
  *
@@ -17,5 +18,28 @@ export interface TrackProviderDeps {
17
18
  selfContext: () => string;
18
19
  /** Gap in ms that starts a new segment; 0 leaves the track as one line. */
19
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;
30
+ /**
31
+ * Positions a history provider holds for a context and window, reconciled
32
+ * with the store's own.
33
+ *
34
+ * Injected rather than reached for directly: the reconciliation belongs to
35
+ * the plugin, and the v1 routes have done it since #73 — a query answered
36
+ * through the v2 provider has to give the same answer as the same query
37
+ * answered through v1, or the plugin contradicts itself depending on which
38
+ * route a client happens to use.
39
+ *
40
+ * Resolves to the store's own points unchanged when no provider is
41
+ * installed, which is the common case.
42
+ */
43
+ reconcileWithHistory: (context: string, stored: TimedPosition[], window: TimeWindow | undefined, resolutionMs: number | undefined) => Promise<TimedPosition[]>;
20
44
  }
21
45
  export declare function createTrackProvider(deps: TrackProviderDeps): TrackApi;
package/dist/tracks.d.ts CHANGED
@@ -15,9 +15,12 @@ export interface TracksConfig {
15
15
  fetchInitialTrack?: boolean;
16
16
  }
17
17
  /**
18
- * The in-memory store: a bounded sliding window of positions per context,
19
- * held in RxJS accumulators and lost on restart. `bootstrapSelfTrack()` in
20
- * index.ts is what re-hydrates it from a History provider at startup.
18
+ * The in-memory store: a bounded sliding window of positions per context, held
19
+ * in RxJS accumulators and lost on restart.
20
+ *
21
+ * No longer used by the plugin, which always records to SQLite. It remains as
22
+ * the client-side `TrackAccumulator` this package exports, where a browser
23
+ * holding one vessel's recent track in memory is exactly the right shape.
21
24
  */
22
25
  export declare class Tracks implements TrackStore {
23
26
  tracks: TracksMap;
@@ -44,7 +47,7 @@ export declare class Tracks implements TrackStore {
44
47
  * which tracks match — the filter runs once, here.
45
48
  */
46
49
  getFilteredTimedTracks(params: TrackParams, selfPosition?: LatLngTuple, debug?: Debug, query?: TrackQuery): Promise<TimedTrackCollection>;
47
- prune(maxAge: number): void;
50
+ prune(maxAge: number, keep?: Context): void;
48
51
  }
49
52
  interface AccumulatorParams {
50
53
  resolution: number;
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
@@ -29,3 +29,72 @@ export declare function validateParameters(params: QueryParameters, defaultMaxRa
29
29
  export declare function createDistanceTo([lat1d, lon1d]: LatLngTuple, debug?: Debug): (d: LatLngTuple | null) => number;
30
30
  export declare function resolveContext(vesselId: string, selfContext?: string): string;
31
31
  export declare function createMatcher(params: TrackParams, selfPosition?: LatLngTuple, debug?: Debug): (track: LatLngTuple[]) => boolean;
32
+ /**
33
+ * A history row is `[timestamp, position]`. Both encodings are legal: the
34
+ * History API types a value as `unknown` and its own comment names "objects
35
+ * (like navigation.position)" alongside primitives, so signalk-questdb returns
36
+ * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
37
+ * Accept both — neither is an error, so rejecting one silently yields an empty
38
+ * track rather than a failure anybody can see.
39
+ */
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.1",
3
+ "version": "3.0.0-beta.3",
4
4
  "description": "Plugin to keep track of tracks of vessels",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,7 @@
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": {
@@ -23,6 +24,8 @@
23
24
  },
24
25
  "keywords": [
25
26
  "signalk-node-server-plugin",
27
+ "signalk-webapp",
28
+ "signalk-embeddable-webapp",
26
29
  "signalk-category-utility"
27
30
  ],
28
31
  "author": "Teppo Kurki <teppo.kurki@iki.fi>",
@@ -52,6 +55,7 @@
52
55
  "license": "Apache-2.0",
53
56
  "dependencies": {
54
57
  "@js-temporal/polyfill": "^0.5.1",
58
+ "@xmldom/xmldom": "^0.9.12",
55
59
  "rxjs": "^7.8.2",
56
60
  "s2js": "^1.44.0",
57
61
  "typebox": "^1.3.11"
@@ -72,6 +76,6 @@
72
76
  "typescript-eslint": "^8.66.0",
73
77
  "vite": "^8.2.2",
74
78
  "vite-plugin-dts": "^5.0.3",
75
- "vitest": "^4.1.10"
79
+ "vitest": "^5.0.0"
76
80
  }
77
81
  }
@@ -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>