signalk-tides 2.2.1 → 2.3.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/.github/workflows/release.yml +15 -0
- package/README.md +2 -1
- package/dist/calculations.js +20 -0
- package/dist/index.js +167 -35
- package/dist/middleware.js +89 -64
- package/package.json +3 -3
- package/public/assets/main-FMwgC28o.js +1 -0
- package/public/index.html +1 -1
- package/public/assets/main-BvKlaM8p.js +0 -1
|
@@ -20,6 +20,21 @@ jobs:
|
|
|
20
20
|
node-version-file: .github/mise.node24.toml # npm >= 11.5.1 for trusted publishing
|
|
21
21
|
registry-url: https://registry.npmjs.org
|
|
22
22
|
|
|
23
|
+
# Before anything installs, so a preview build is neither run nor published.
|
|
24
|
+
# Overrides don't reach people installing the plugin, so these specs are
|
|
25
|
+
# what they get.
|
|
26
|
+
- name: Refuse dependencies from outside the registry
|
|
27
|
+
run: |
|
|
28
|
+
node -e '
|
|
29
|
+
const { dependencies = {}, optionalDependencies = {} } = require("./package.json");
|
|
30
|
+
const outside = Object.entries({ ...dependencies, ...optionalDependencies })
|
|
31
|
+
.filter(([, spec]) => /[:/]/.test(spec));
|
|
32
|
+
if (outside.length) {
|
|
33
|
+
console.error("Not from the registry:", Object.fromEntries(outside));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
'
|
|
37
|
+
|
|
23
38
|
- name: Install dependencies
|
|
24
39
|
run: npm install
|
|
25
40
|
|
package/README.md
CHANGED
|
@@ -21,12 +21,13 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
|
|
|
21
21
|
* `environment.tide.timeLow`
|
|
22
22
|
* `environment.tide.heightNow`
|
|
23
23
|
* `environment.tide.stationName`
|
|
24
|
+
* `environment.tide.stationDistance` — metres from the vessel to the station, when it was chosen by position
|
|
24
25
|
* `environment.tide.state` — tide trend, `rising` or `falling`
|
|
25
26
|
* `environment.tide.timeToNextExtreme` — seconds until the next high or low water
|
|
26
27
|
|
|
27
28
|
### Tides API
|
|
28
29
|
|
|
29
|
-
The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) at `/signalk/v2/api/tides`, which serves station search, extremes, and timeline predictions. The synthetic station `vessel/default` resolves to the configured default station, or the nearest station
|
|
30
|
+
The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) at `/signalk/v2/api/tides`, which serves station search, extremes, and timeline predictions. The synthetic station `vessel/default` resolves to the configured default station, or the nearest station with tide predictions within 100 km when none is set or the configured one has no predictions. With none in range, the plugin publishes no tide and says so in its status, and the API answers 404:
|
|
30
31
|
|
|
31
32
|
```
|
|
32
33
|
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
|
package/dist/calculations.js
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far from the vessel a station may be and still stand in for its tide. Past
|
|
3
|
+
* this a station gives a plausible tide for other water, so the plugin publishes
|
|
4
|
+
* none. Half of the CHS positions with no model have a station that predicts
|
|
5
|
+
* within 28 km, and 184 of 1,057 have none within 100 km. This project's
|
|
6
|
+
* definition, not a published figure.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_STATION_KM = 100;
|
|
9
|
+
/** Whether a location lookup found nothing in range. */
|
|
10
|
+
export function isNoStationNearby(e) {
|
|
11
|
+
return e instanceof Error && e.message.startsWith("No stations found");
|
|
12
|
+
}
|
|
13
|
+
/** Whether a lookup failed on the id, rather than on loading the database. */
|
|
14
|
+
export function isStationNotFound(e) {
|
|
15
|
+
return e instanceof Error && e.message.startsWith("Station not found");
|
|
16
|
+
}
|
|
17
|
+
// CHS stations carry identity only, and predicting from one gives a flat zero tide.
|
|
18
|
+
export function hasTideModel(station) {
|
|
19
|
+
return station.harmonic_constituents.length > 0 || station.offsets !== undefined;
|
|
20
|
+
}
|
|
1
21
|
/**
|
|
2
22
|
* Trend of the tide at `time`: "rising" if the next extreme is a High,
|
|
3
23
|
* "falling" if it is a Low.
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createRoutes } from "@slackwater/api";
|
|
17
17
|
import { findStation, nearestStation, stationsNear } from "slackwater";
|
|
18
|
-
import { tideStateAt, timeToNextExtreme } from "./calculations.js";
|
|
18
|
+
import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, tideStateAt, timeToNextExtreme, } from "./calculations.js";
|
|
19
19
|
import FileCache from "./cache.js";
|
|
20
20
|
import { withVesselPosition } from "./middleware.js";
|
|
21
21
|
import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-extension.js";
|
|
@@ -23,6 +23,17 @@ import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-exten
|
|
|
23
23
|
// by slackwater, so this is cheap and needs no configuration.
|
|
24
24
|
const UPDATE_INTERVAL = 60 * 1000; // 1 minute
|
|
25
25
|
const API_PATH = "/signalk/v2/api/tides";
|
|
26
|
+
const TIDE_PATHS = [
|
|
27
|
+
"environment.tide.stationName",
|
|
28
|
+
"environment.tide.stationDistance",
|
|
29
|
+
"environment.tide.heightNow",
|
|
30
|
+
"environment.tide.state",
|
|
31
|
+
"environment.tide.timeToNextExtreme",
|
|
32
|
+
"environment.tide.heightHigh",
|
|
33
|
+
"environment.tide.timeHigh",
|
|
34
|
+
"environment.tide.heightLow",
|
|
35
|
+
"environment.tide.timeLow",
|
|
36
|
+
];
|
|
26
37
|
// Tide heights are water levels, so clients should show them in the user's
|
|
27
38
|
// depth unit. Without a category the server picks one from the unit alone, and
|
|
28
39
|
// for meters that is distance: kilometers or miles, depending on the preset.
|
|
@@ -50,7 +61,8 @@ export default function (app) {
|
|
|
50
61
|
id: "tides",
|
|
51
62
|
name: "Tides",
|
|
52
63
|
description: "Offline tidal predictions for the vessel's position, powered by Slackwater.",
|
|
53
|
-
|
|
64
|
+
// signalk-server awaits this, but a rejection would fail its whole plugin list
|
|
65
|
+
schema: async () => ({
|
|
54
66
|
title: "Tides",
|
|
55
67
|
type: "object",
|
|
56
68
|
properties: {
|
|
@@ -59,7 +71,7 @@ export default function (app) {
|
|
|
59
71
|
title: "Default tide station",
|
|
60
72
|
description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
|
|
61
73
|
default: "auto",
|
|
62
|
-
oneOf: defaultStationOptions(),
|
|
74
|
+
oneOf: await defaultStationOptions(),
|
|
63
75
|
},
|
|
64
76
|
},
|
|
65
77
|
}),
|
|
@@ -70,11 +82,20 @@ export default function (app) {
|
|
|
70
82
|
activeRouter = null;
|
|
71
83
|
},
|
|
72
84
|
};
|
|
73
|
-
function defaultStationOptions() {
|
|
85
|
+
async function defaultStationOptions() {
|
|
74
86
|
const options = [{ const: "auto", title: "Closest station" }];
|
|
75
87
|
if (lastPosition) {
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
try {
|
|
89
|
+
const nearby = await stationsNear({
|
|
90
|
+
...lastPosition,
|
|
91
|
+
maxResults: 10,
|
|
92
|
+
});
|
|
93
|
+
for (const station of nearby) {
|
|
94
|
+
options.push({ const: station.id, title: stationTitle(station) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
app.error(`Could not list nearby tide stations: ${errorMessage(e)}`);
|
|
78
99
|
}
|
|
79
100
|
}
|
|
80
101
|
// Keep the saved station selectable even when it is not in the nearby
|
|
@@ -84,10 +105,16 @@ export default function (app) {
|
|
|
84
105
|
if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
|
|
85
106
|
let title = saved;
|
|
86
107
|
try {
|
|
87
|
-
|
|
108
|
+
const station = await findStation(saved);
|
|
109
|
+
title = hasTideModel(station)
|
|
110
|
+
? stationTitle(station)
|
|
111
|
+
: `${stationTitle(station)} (no tide predictions)`;
|
|
88
112
|
}
|
|
89
|
-
catch {
|
|
113
|
+
catch (e) {
|
|
90
114
|
// Unknown id: keep the raw value as the label
|
|
115
|
+
if (!isStationNotFound(e)) {
|
|
116
|
+
app.error(`Could not load saved tide station: ${errorMessage(e)}`);
|
|
117
|
+
}
|
|
91
118
|
}
|
|
92
119
|
options.splice(1, 0, { const: saved, title });
|
|
93
120
|
}
|
|
@@ -121,7 +148,18 @@ export default function (app) {
|
|
|
121
148
|
return undefined;
|
|
122
149
|
}
|
|
123
150
|
}
|
|
124
|
-
async
|
|
151
|
+
// Not async: signalk-server only rolls back a start() that throws synchronously,
|
|
152
|
+
// and that rollback does not call stop().
|
|
153
|
+
function start(options) {
|
|
154
|
+
try {
|
|
155
|
+
startPublishing(options);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
plugin.stop();
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function startPublishing(options) {
|
|
125
163
|
app.debug("Starting tides");
|
|
126
164
|
config = (options ?? {});
|
|
127
165
|
void suggestDepthUnits();
|
|
@@ -129,6 +167,13 @@ export default function (app) {
|
|
|
129
167
|
// extremes and the current height are always read from the same station.
|
|
130
168
|
let lastForecast = null;
|
|
131
169
|
let lastPredictor = null;
|
|
170
|
+
let latestUpdate = 0;
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let tidesCleared = false;
|
|
173
|
+
let positionWrite = Promise.resolve();
|
|
174
|
+
unsubscribes.push(() => {
|
|
175
|
+
stopped = true;
|
|
176
|
+
});
|
|
132
177
|
const cache = new FileCache(app.getDataDirPath());
|
|
133
178
|
// Mount the Slackwater API, with vessel/default resolved to the configured
|
|
134
179
|
// default station (or the nearest one).
|
|
@@ -143,7 +188,7 @@ export default function (app) {
|
|
|
143
188
|
type: "tides",
|
|
144
189
|
methods: {
|
|
145
190
|
async listResources() {
|
|
146
|
-
const predictor = resolvePredictor();
|
|
191
|
+
const predictor = await resolvePredictor();
|
|
147
192
|
if (!predictor) {
|
|
148
193
|
throw new Error("No position or default station available");
|
|
149
194
|
}
|
|
@@ -193,25 +238,51 @@ export default function (app) {
|
|
|
193
238
|
}, unsubscribes, (subscriptionError) => {
|
|
194
239
|
app.error("Error:" + subscriptionError);
|
|
195
240
|
}, updatePosition);
|
|
196
|
-
function resolvePredictor() {
|
|
241
|
+
async function resolvePredictor(report = reportLookupFailure) {
|
|
242
|
+
let failed = false;
|
|
197
243
|
if (config.defaultStation && config.defaultStation !== "auto") {
|
|
198
244
|
try {
|
|
199
|
-
|
|
245
|
+
const station = await findStation(config.defaultStation);
|
|
246
|
+
if (hasTideModel(station))
|
|
247
|
+
return station;
|
|
248
|
+
throw new Error("Station has no tide predictions");
|
|
200
249
|
}
|
|
201
|
-
catch {
|
|
202
|
-
|
|
250
|
+
catch (e) {
|
|
251
|
+
failed = true;
|
|
252
|
+
report(`Could not load tide station ${config.defaultStation}: ${errorMessage(e)}`);
|
|
203
253
|
}
|
|
204
254
|
}
|
|
205
255
|
if (lastPosition) {
|
|
206
256
|
try {
|
|
207
|
-
return nearestStation(
|
|
257
|
+
return await nearestStation({
|
|
258
|
+
...lastPosition,
|
|
259
|
+
maxDistance: MAX_STATION_KM,
|
|
260
|
+
});
|
|
208
261
|
}
|
|
209
|
-
catch {
|
|
210
|
-
|
|
262
|
+
catch (e) {
|
|
263
|
+
if (isNoStationNearby(e)) {
|
|
264
|
+
// A status would replace the error about the saved station
|
|
265
|
+
if (!stopped && !failed) {
|
|
266
|
+
app.setPluginStatus(`No tide station within ${MAX_STATION_KM} km of the vessel`);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
report(`Could not find a tide station near vessel position: ${errorMessage(e)}`);
|
|
211
271
|
}
|
|
212
272
|
}
|
|
213
273
|
return null;
|
|
214
274
|
}
|
|
275
|
+
function reportLookupFailure(message) {
|
|
276
|
+
if (stopped)
|
|
277
|
+
return;
|
|
278
|
+
app.error(message);
|
|
279
|
+
app.setPluginError(message);
|
|
280
|
+
}
|
|
281
|
+
function forecastStatus({ station, distance }) {
|
|
282
|
+
return distance === undefined
|
|
283
|
+
? "Updated tide forecast"
|
|
284
|
+
: `Updated tide forecast from ${station.name}, ${Math.round(distance)} km away`;
|
|
285
|
+
}
|
|
215
286
|
function forecastFor(predictor) {
|
|
216
287
|
const now = new Date();
|
|
217
288
|
return predictor.getExtremesPrediction({
|
|
@@ -219,32 +290,63 @@ export default function (app) {
|
|
|
219
290
|
end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
220
291
|
});
|
|
221
292
|
}
|
|
293
|
+
// Runs from a timer and a delta callback, which both drop its promise, so
|
|
294
|
+
// it must never reject.
|
|
222
295
|
async function updatePosition() {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
296
|
+
try {
|
|
297
|
+
const newPosition = app.getSelfPath("navigation.position.value");
|
|
298
|
+
// New position received, save it to the cache
|
|
299
|
+
if (newPosition) {
|
|
300
|
+
lastPosition = newPosition;
|
|
301
|
+
// Writes run in turn and each saves the newest position, so an older
|
|
302
|
+
// one can't land last
|
|
303
|
+
positionWrite = positionWrite
|
|
304
|
+
.catch(() => { })
|
|
305
|
+
.then(() => cache.set("position", lastPosition));
|
|
306
|
+
await positionWrite;
|
|
307
|
+
}
|
|
308
|
+
// No last known position, try to load from cache.
|
|
309
|
+
if (!lastPosition) {
|
|
310
|
+
const cached = (await cache.get("position"));
|
|
311
|
+
// A delta may have set a newer position during the read
|
|
312
|
+
lastPosition ??= cached ?? null;
|
|
313
|
+
}
|
|
228
314
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
lastPosition = (await cache.get("position"));
|
|
315
|
+
catch (e) {
|
|
316
|
+
app.error(`Could not update cached position: ${errorMessage(e)}`);
|
|
232
317
|
}
|
|
233
318
|
// A configured default station predicts without a position
|
|
234
|
-
updateForecast();
|
|
319
|
+
await updateForecast();
|
|
235
320
|
}
|
|
236
|
-
function updateForecast() {
|
|
237
|
-
const
|
|
238
|
-
if (!predictor) {
|
|
239
|
-
app.debug("No position or default station available, cannot compute tide data");
|
|
240
|
-
lastForecast = null;
|
|
241
|
-
lastPredictor = null;
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
321
|
+
async function updateForecast() {
|
|
322
|
+
const update = ++latestUpdate;
|
|
244
323
|
try {
|
|
324
|
+
let lookupFailed = false;
|
|
325
|
+
const predictor = await resolvePredictor((message) => {
|
|
326
|
+
lookupFailed = true;
|
|
327
|
+
reportLookupFailure(message);
|
|
328
|
+
});
|
|
329
|
+
// The first lookup waits for the database, so a newer update or stop()
|
|
330
|
+
// can overtake this one.
|
|
331
|
+
if (stopped || update !== latestUpdate)
|
|
332
|
+
return;
|
|
333
|
+
if (!predictor) {
|
|
334
|
+
app.debug("No position or default station available, cannot compute tide data");
|
|
335
|
+
// Once per loss, and once after a restart, which can't tell what the
|
|
336
|
+
// run before it published
|
|
337
|
+
if (!tidesCleared)
|
|
338
|
+
clearTides();
|
|
339
|
+
tidesCleared = true;
|
|
340
|
+
lastForecast = null;
|
|
341
|
+
lastPredictor = null;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
245
344
|
lastForecast = forecastFor(predictor);
|
|
246
345
|
lastPredictor = predictor;
|
|
247
|
-
|
|
346
|
+
tidesCleared = false;
|
|
347
|
+
// A fallback keeps the lookup's error showing
|
|
348
|
+
if (!lookupFailed)
|
|
349
|
+
app.setPluginStatus(forecastStatus(lastForecast));
|
|
248
350
|
updateTides();
|
|
249
351
|
}
|
|
250
352
|
catch (e) {
|
|
@@ -254,6 +356,19 @@ export default function (app) {
|
|
|
254
356
|
app.error(e);
|
|
255
357
|
}
|
|
256
358
|
}
|
|
359
|
+
// Signal K keeps a path's last value, so a tide that no longer applies has
|
|
360
|
+
// to be cleared or it stays on displays, frozen.
|
|
361
|
+
function clearTides() {
|
|
362
|
+
app.handleMessage(plugin.id, {
|
|
363
|
+
context: ("vessels." + app.selfId),
|
|
364
|
+
updates: [
|
|
365
|
+
{
|
|
366
|
+
timestamp: new Date().toISOString(),
|
|
367
|
+
values: TIDE_PATHS.map((path) => ({ path, value: null })),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
257
372
|
function updateTides(now = new Date()) {
|
|
258
373
|
if (!lastForecast || !lastPredictor)
|
|
259
374
|
return;
|
|
@@ -274,6 +389,13 @@ export default function (app) {
|
|
|
274
389
|
path: "environment.tide.stationName",
|
|
275
390
|
value: lastForecast.station.name,
|
|
276
391
|
},
|
|
392
|
+
// Null for a configured station, which isn't chosen by distance
|
|
393
|
+
{
|
|
394
|
+
path: "environment.tide.stationDistance",
|
|
395
|
+
value: lastForecast.distance === undefined
|
|
396
|
+
? null
|
|
397
|
+
: lastForecast.distance * 1000,
|
|
398
|
+
},
|
|
277
399
|
{
|
|
278
400
|
path: "environment.tide.heightNow",
|
|
279
401
|
value: heightNow,
|
|
@@ -314,6 +436,13 @@ export default function (app) {
|
|
|
314
436
|
description: "Tide trend at the current position: rising or falling",
|
|
315
437
|
},
|
|
316
438
|
},
|
|
439
|
+
{
|
|
440
|
+
path: "environment.tide.stationDistance",
|
|
441
|
+
value: {
|
|
442
|
+
units: "m",
|
|
443
|
+
description: "Distance from the vessel to the station it was given",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
317
446
|
{
|
|
318
447
|
path: "environment.tide.timeToNextExtreme",
|
|
319
448
|
value: {
|
|
@@ -339,6 +468,9 @@ export default function (app) {
|
|
|
339
468
|
}
|
|
340
469
|
return plugin;
|
|
341
470
|
}
|
|
471
|
+
function errorMessage(e) {
|
|
472
|
+
return e instanceof Error ? e.message : String(e);
|
|
473
|
+
}
|
|
342
474
|
function stationTitle(station) {
|
|
343
475
|
const where = [station.region, station.country].filter(Boolean).join(", ");
|
|
344
476
|
const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";
|
package/dist/middleware.js
CHANGED
|
@@ -1,78 +1,103 @@
|
|
|
1
1
|
import { findStation, nearestStation } from "slackwater";
|
|
2
|
+
import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, } from "./calculations.js";
|
|
3
|
+
const nearestToVessel = ({ latitude, longitude }) => nearestStation({
|
|
4
|
+
latitude,
|
|
5
|
+
longitude,
|
|
6
|
+
maxDistance: MAX_STATION_KM,
|
|
7
|
+
});
|
|
2
8
|
/**
|
|
3
9
|
* Middleware that resolves the vessel/default station alias and bare
|
|
4
10
|
* location-based queries (/extremes, /timeline) against the configured default
|
|
5
11
|
* station, falling back to the station nearest the vessel's position when no
|
|
6
|
-
* default is set.
|
|
12
|
+
* default is set or it can't be used.
|
|
7
13
|
*/
|
|
8
14
|
export function withVesselPosition(router, getPosition, getDefaultStationId) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (!
|
|
41
|
-
|
|
15
|
+
// signalk-server runs Express 4, which ignores a rejected handler and leaves
|
|
16
|
+
// the request hanging, so every failure has to reach next().
|
|
17
|
+
return async (req, res, next) => {
|
|
18
|
+
try {
|
|
19
|
+
// HTTP 303 See Other: the response can be found under a different URI
|
|
20
|
+
const redirect = (toPath) => res.redirect(303, `${req.baseUrl}${toPath}`);
|
|
21
|
+
// The configured default station, or null when unset, "auto", stale, or unpredictable.
|
|
22
|
+
const defaultStation = async () => {
|
|
23
|
+
const id = getDefaultStationId?.();
|
|
24
|
+
if (!id || id === "auto")
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const station = await findStation(id);
|
|
28
|
+
return hasTideModel(station) ? station : null;
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
// A stale or invalid saved id falls back to position; a database that
|
|
32
|
+
// failed to load has to reach next().
|
|
33
|
+
if (isStationNotFound(e))
|
|
34
|
+
return null;
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
// Redirect the vessel/default alias to the configured (or nearest) station.
|
|
39
|
+
if (/^\/stations\/vessel\/default(?:\/|$)/.test(req.path)) {
|
|
40
|
+
const toStation = (station) => redirect(req.url.replace(/^\/stations\/vessel\/default\b/, `/stations/${station.id}`));
|
|
41
|
+
// A configured default station resolves without a position
|
|
42
|
+
const station = await defaultStation();
|
|
43
|
+
if (station)
|
|
44
|
+
return toStation(station);
|
|
45
|
+
const pos = getPosition();
|
|
46
|
+
if (!pos) {
|
|
47
|
+
res.status(503).json({ message: "Vessel position not available" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return toStation(await nearestToVessel(pos));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (isNoStationNearby(e)) {
|
|
55
|
+
res.status(404).json({ message: "No nearby stations found" });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.status(500).json({ message: "Failed to find nearest station" });
|
|
59
|
+
return;
|
|
42
60
|
}
|
|
43
|
-
return toStation(nearest);
|
|
44
61
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
// Bare location-based queries: redirect to the configured station when set,
|
|
63
|
+
// otherwise inject the vessel position so slackwater uses the nearest station.
|
|
64
|
+
const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
|
|
65
|
+
if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
|
|
66
|
+
const station = await defaultStation();
|
|
67
|
+
if (station) {
|
|
68
|
+
return redirect(req.url.replace(/^\/(extremes|timeline)\b/, `/stations/${station.id}/$1`));
|
|
69
|
+
}
|
|
70
|
+
const pos = getPosition();
|
|
71
|
+
if (pos) {
|
|
72
|
+
// The Slackwater route takes any distance, so check the range here
|
|
73
|
+
try {
|
|
74
|
+
await nearestToVessel(pos);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
if (!isNoStationNearby(e))
|
|
78
|
+
throw e;
|
|
79
|
+
res.status(404).json({ message: "No nearby stations found" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Express 5 makes `req.query` a read-only getter, so assigning to its
|
|
83
|
+
// properties is silently lost. Shadow it with an own data property,
|
|
84
|
+
// which behaves identically on Express 4 and 5.
|
|
85
|
+
Object.defineProperty(req, "query", {
|
|
86
|
+
value: {
|
|
87
|
+
...req.query,
|
|
88
|
+
latitude: String(pos.latitude),
|
|
89
|
+
longitude: String(pos.longitude),
|
|
90
|
+
},
|
|
91
|
+
writable: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
enumerable: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
49
96
|
}
|
|
97
|
+
router(req, res, next);
|
|
50
98
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
|
|
54
|
-
if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
|
|
55
|
-
const station = defaultStation();
|
|
56
|
-
if (station) {
|
|
57
|
-
return redirect(req.url.replace(/^\/(extremes|timeline)\b/, `/stations/${station.id}/$1`));
|
|
58
|
-
}
|
|
59
|
-
const pos = getPosition();
|
|
60
|
-
if (pos) {
|
|
61
|
-
// Express 5 makes `req.query` a read-only getter, so assigning to its
|
|
62
|
-
// properties is silently lost. Shadow it with an own data property,
|
|
63
|
-
// which behaves identically on Express 4 and 5.
|
|
64
|
-
Object.defineProperty(req, "query", {
|
|
65
|
-
value: {
|
|
66
|
-
...req.query,
|
|
67
|
-
latitude: String(pos.latitude),
|
|
68
|
-
longitude: String(pos.longitude),
|
|
69
|
-
},
|
|
70
|
-
writable: true,
|
|
71
|
-
configurable: true,
|
|
72
|
-
enumerable: true,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
next(error);
|
|
75
101
|
}
|
|
76
|
-
router(req, res, next);
|
|
77
102
|
};
|
|
78
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-tides",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Tidal predictions for the vessel's position, powered by Slackwater.",
|
|
5
5
|
"homepage": "https://openwaters.io/tides/slackwater/",
|
|
6
6
|
"type": "module",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"license": "Apache-2.0",
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@signalk/server-api": "^2.35.0",
|
|
39
|
-
"@slackwater/api": "^1.0.0-beta.
|
|
39
|
+
"@slackwater/api": "^1.0.0-beta.4",
|
|
40
40
|
"@slackwater/react": "^1.0.0-beta.2",
|
|
41
41
|
"express": "^5.3.0",
|
|
42
42
|
"react": "^19.1.0",
|
|
43
43
|
"react-dom": "^19.3.1",
|
|
44
44
|
"signalk-plotterext-bus": "^0.24.0",
|
|
45
|
-
"slackwater": "^1.0.0-beta.
|
|
45
|
+
"slackwater": "^1.0.0-beta.4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@eslint/js": "^9.21.0",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e,c as t,d as n,f as r,i,l as a,n as o,r as s,s as c,t as l,u}from"./useUnitPreferences-Bnl2Hfv0.js";var d=u(),f=r(n(),1),p=()=>new URLSearchParams(window.location.search).get(`station`)||`vessel/default`;function m(){let[e,t]=(0,f.useState)(p);return(0,f.useEffect)(()=>{let e=()=>t(p());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),[e,(0,f.useCallback)(e=>{let n=e.trim()||`vessel/default`,r=new URL(window.location.href);n===`vessel/default`?r.searchParams.delete(`station`):r.searchParams.set(`station`,n),window.history.pushState(window.history.state,``,r),t(n)},[])]}var h=a(),g={version:8,sources:{osm:{type:`raster`,tiles:[`https://tile.openstreetmap.org/{z}/{x}/{y}.png`],tileSize:256,attribution:`© OpenStreetMap contributors`}},layers:[{id:`osm`,type:`raster`,source:`osm`}]};function _({stationId:n,onSelect:r}){let a=(0,f.useRef)(null),[s,c]=(0,f.useState)(!1),[l,u]=(0,f.useState)(!1),[d,p]=(0,f.useState)(`list`),{data:m,error:_}=t(n),v=()=>{u(window.matchMedia(`(min-width: 1024px)`).matches),p(`list`),c(!0),a.current?.showModal()},y=e=>{r(e.id),a.current?.close()};return(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(`button`,{type:`button`,onClick:v,"aria-label":`Browse stations`,className:`absolute hidden min-[20rem]:block right-2 top-2 sm:right-4 sm:top-4 md:right-6 md:top-6 lg:right-8 lg:top-8 xl:right-20 xl:top-20 z-10 rounded-md border border-(--slackwater-border) bg-(--slackwater-bg-subtle) p-2 text-(--slackwater-text) hover:bg-(--slackwater-bg)`,children:(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`21`,y2:`18`})]})}),(0,h.jsx)(`dialog`,{ref:a,onClose:()=>c(!1),onClick:e=>{e.target===e.currentTarget&&e.currentTarget.close()},className:`m-auto w-[min(92vw,900px)] max-h-[85vh] rounded-lg border border-(--slackwater-border) bg-(--slackwater-bg) p-0 text-(--slackwater-text) backdrop:bg-black/50`,children:s&&(0,h.jsxs)(`div`,{className:`flex max-h-[85vh] flex-col`,children:[(0,h.jsxs)(`div`,{className:`flex items-center justify-between border-b border-(--slackwater-border) p-3`,children:[(0,h.jsx)(`button`,{type:`button`,onClick:()=>a.current?.close(),"aria-label":`Close`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text)`,children:`✕`}),(0,h.jsx)(`h2`,{className:`text-base font-semibold`,children:`Stations`}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>p(d===`map`?`list`:`map`),"aria-label":d===`map`?`Show list`:`Show map`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text) lg:invisible`,children:d===`map`?(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`8`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`8`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`8`,y1:`18`,x2:`21`,y2:`18`}),(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`3.01`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`3.01`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`3.01`,y2:`18`})]}):(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`polygon`,{points:`3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21 3 6`}),(0,h.jsx)(`line`,{x1:`9`,y1:`3`,x2:`9`,y2:`18`}),(0,h.jsx)(`line`,{x1:`15`,y1:`6`,x2:`15`,y2:`21`})]})})]}),(0,h.jsxs)(`div`,{className:`flex min-h-0 flex-col gap-4 p-3 lg:flex-row`,children:[(0,h.jsxs)(`div`,{className:`w-full flex-col gap-3 overflow-y-auto lg:flex lg:w-80 ${d===`map`?`hidden`:`flex`}`,children:[(0,h.jsx)(i,{onSelect:y}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>y({id:`vessel/default`}),className:`rounded-md border border-(--slackwater-border) px-3 py-2 text-left text-sm hover:bg-(--slackwater-bg-subtle)`,children:`📍 Vessel position`}),(0,h.jsx)(o,{stationId:n,onStationSelect:y})]}),(l||d===`map`)&&(m||_)&&(0,h.jsx)(`div`,{className:`h-[60vh] overflow-hidden rounded-md border border-(--slackwater-border) lg:h-auto lg:min-h-100 lg:flex-1`,children:(0,h.jsx)(e,{mapStyle:g,initialViewState:m?{longitude:m.longitude,latitude:m.latitude,zoom:10}:{longitude:0,latitude:20,zoom:1},focusStation:m?.id,onStationSelect:y})})]})]})})]})}var{VITE_SIGNALK_URL:v=window.location.toString()}={BASE_URL:`./`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1},y=new URL(`/signalk/v2/api`,v).toString();function b(){let{units:e,ready:t}=l(),[n,r]=m();return t?(0,h.jsx)(s,{baseUrl:y,units:e,children:(0,h.jsxs)(`div`,{className:`relative`,children:[(0,h.jsx)(_,{stationId:n,onSelect:r}),(0,h.jsx)(c,{id:n,className:`overflow-y-auto p-2 sm:p-4 md:p-6 lg:p-8 xl:p-20`})]})}):null}(0,d.createRoot)(document.getElementById(`app`)).render((0,h.jsx)(b,{}));
|
package/public/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>Tides</title>
|
|
9
|
-
<script type="module" crossorigin src="./assets/main-
|
|
9
|
+
<script type="module" crossorigin src="./assets/main-FMwgC28o.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="./assets/useUnitPreferences-Bnl2Hfv0.js">
|
|
11
11
|
<link rel="stylesheet" crossorigin href="./assets/useUnitPreferences-D6HIp3mv.css">
|
|
12
12
|
</head>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{a as e,c as t,d as n,f as r,i,l as a,n as o,r as s,s as c,t as l,u}from"./useUnitPreferences-Bnl2Hfv0.js";var d=u(),f=r(n(),1),p=()=>new URLSearchParams(window.location.search).get(`station`)||`vessel/default`;function m(){let[e,t]=(0,f.useState)(p);return(0,f.useEffect)(()=>{let e=()=>t(p());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),[e,(0,f.useCallback)(e=>{let n=e.trim()||`vessel/default`,r=new URL(window.location.href);n===`vessel/default`?r.searchParams.delete(`station`):r.searchParams.set(`station`,n),window.history.pushState(window.history.state,``,r),t(n)},[])]}var h=a(),g={version:8,sources:{osm:{type:`raster`,tiles:[`https://tile.openstreetmap.org/{z}/{x}/{y}.png`],tileSize:256,attribution:`© OpenStreetMap contributors`}},layers:[{id:`osm`,type:`raster`,source:`osm`}]};function _({stationId:n,onSelect:r}){let a=(0,f.useRef)(null),[s,c]=(0,f.useState)(!1),[l,u]=(0,f.useState)(!1),[d,p]=(0,f.useState)(`list`),{data:m}=t(n),_=()=>{u(window.matchMedia(`(min-width: 1024px)`).matches),p(`list`),c(!0),a.current?.showModal()},v=e=>{r(e.id),a.current?.close()};return(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(`button`,{type:`button`,onClick:_,"aria-label":`Browse stations`,className:`absolute hidden min-[20rem]:block right-2 top-2 sm:right-4 sm:top-4 md:right-6 md:top-6 lg:right-8 lg:top-8 xl:right-20 xl:top-20 z-10 rounded-md border border-(--slackwater-border) bg-(--slackwater-bg-subtle) p-2 text-(--slackwater-text) hover:bg-(--slackwater-bg)`,children:(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`21`,y2:`18`})]})}),(0,h.jsx)(`dialog`,{ref:a,onClose:()=>c(!1),onClick:e=>{e.target===e.currentTarget&&e.currentTarget.close()},className:`m-auto w-[min(92vw,900px)] max-h-[85vh] rounded-lg border border-(--slackwater-border) bg-(--slackwater-bg) p-0 text-(--slackwater-text) backdrop:bg-black/50`,children:s&&(0,h.jsxs)(`div`,{className:`flex max-h-[85vh] flex-col`,children:[(0,h.jsxs)(`div`,{className:`flex items-center justify-between border-b border-(--slackwater-border) p-3`,children:[(0,h.jsx)(`button`,{type:`button`,onClick:()=>a.current?.close(),"aria-label":`Close`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text)`,children:`✕`}),(0,h.jsx)(`h2`,{className:`text-base font-semibold`,children:`Stations`}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>p(d===`map`?`list`:`map`),"aria-label":d===`map`?`Show list`:`Show map`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text) lg:invisible`,children:d===`map`?(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`8`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`8`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`8`,y1:`18`,x2:`21`,y2:`18`}),(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`3.01`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`3.01`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`3.01`,y2:`18`})]}):(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`polygon`,{points:`3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21 3 6`}),(0,h.jsx)(`line`,{x1:`9`,y1:`3`,x2:`9`,y2:`18`}),(0,h.jsx)(`line`,{x1:`15`,y1:`6`,x2:`15`,y2:`21`})]})})]}),(0,h.jsxs)(`div`,{className:`flex min-h-0 flex-col gap-4 p-3 lg:flex-row`,children:[(0,h.jsxs)(`div`,{className:`w-full flex-col gap-3 overflow-y-auto lg:flex lg:w-80 ${d===`map`?`hidden`:`flex`}`,children:[(0,h.jsx)(i,{onSelect:v}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>v({id:`vessel/default`}),className:`rounded-md border border-(--slackwater-border) px-3 py-2 text-left text-sm hover:bg-(--slackwater-bg-subtle)`,children:`📍 Vessel position`}),(0,h.jsx)(o,{stationId:n,onStationSelect:v})]}),(l||d===`map`)&&m&&(0,h.jsx)(`div`,{className:`h-[60vh] overflow-hidden rounded-md border border-(--slackwater-border) lg:h-auto lg:min-h-100 lg:flex-1`,children:(0,h.jsx)(e,{mapStyle:g,initialViewState:{longitude:m.longitude,latitude:m.latitude,zoom:10},focusStation:m.id,onStationSelect:v})})]})]})})]})}var{VITE_SIGNALK_URL:v=window.location.toString()}={BASE_URL:`./`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1},y=new URL(`/signalk/v2/api`,v).toString();function b(){let{units:e,ready:t}=l(),[n,r]=m();return t?(0,h.jsx)(s,{baseUrl:y,units:e,children:(0,h.jsxs)(`div`,{className:`relative`,children:[(0,h.jsx)(_,{stationId:n,onSelect:r}),(0,h.jsx)(c,{id:n,className:`overflow-y-auto p-2 sm:p-4 md:p-6 lg:p-8 xl:p-20`})]})}):null}(0,d.createRoot)(document.getElementById(`app`)).render((0,h.jsx)(b,{}));
|