signalk-tides 2.0.0-beta.0 → 2.0.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/ci.yml +3 -3
- package/.playwright-mcp/console-2026-06-12T14-41-49-989Z.log +60 -0
- package/.playwright-mcp/console-2026-06-12T14-51-31-609Z.log +5 -0
- package/README.md +4 -2
- package/dist/calculations.js +33 -20
- package/dist/index.js +128 -24
- package/dist/middleware.js +38 -20
- package/package.json +6 -6
- package/public/assets/index-D7RIUA1w.js +12 -0
- package/public/assets/index-W6DTmxq6.css +1 -0
- package/public/assets/maplibre-gl-c4hHMhPw.js +748 -0
- package/public/index.html +2 -2
- package/public/assets/index-8PLt-an_.css +0 -1
- package/public/assets/index-CvXA6qTu.js +0 -12
- package/public/assets/maplibre-gl-lX2dHdJ0.js +0 -800
package/.github/workflows/ci.yml
CHANGED
|
@@ -9,7 +9,7 @@ jobs:
|
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
steps:
|
|
11
11
|
- name: Checkout repository
|
|
12
|
-
uses: actions/checkout@
|
|
12
|
+
uses: actions/checkout@v7
|
|
13
13
|
|
|
14
14
|
- name: Setup Node.js
|
|
15
15
|
uses: actions/setup-node@v6
|
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
CI: true
|
|
28
28
|
steps:
|
|
29
29
|
- name: Checkout repository
|
|
30
|
-
uses: actions/checkout@
|
|
30
|
+
uses: actions/checkout@v7
|
|
31
31
|
|
|
32
32
|
- name: Setup Node.js
|
|
33
33
|
uses: actions/setup-node@v6
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
id-token: write
|
|
52
52
|
pull-requests: write
|
|
53
53
|
steps:
|
|
54
|
-
- uses: actions/checkout@
|
|
54
|
+
- uses: actions/checkout@v7
|
|
55
55
|
|
|
56
56
|
- uses: actions/setup-node@v6
|
|
57
57
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[ 188ms] [LOG] [ConnectionStateMachine] Service initialized @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
2
|
+
[ 191ms] [LOG] [ConnectionStateMachine] Starting HTTP discovery: Connecting to http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
3
|
+
[ 192ms] [LOG] [Connection Service] Connecting to: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
4
|
+
[ 213ms] [LOG] [ConnectionStateMachine] HTTP discovery successful @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
5
|
+
[ 214ms] [LOG] [AppSettings Service] Service startup... @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:3
|
|
6
|
+
[ 214ms] [LOG] [AppSettings Service] LocalStorage enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
7
|
+
[ 214ms] [LOG] [DatasetStreamService] Auto Starting 0 Datasets @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
|
|
8
|
+
[ 214ms] [LOG] [AppInit Network Service] Initialization completed @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
9
|
+
[ 214ms] [LOG] [ConnectionStateMachine] WebSocket mode enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
10
|
+
[ 214ms] [LOG] [AppInit Network Service] Starting WebSocket connection after initialization @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
11
|
+
[ 214ms] [LOG] [ConnectionStateMachine] Starting WebSocket connection @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
12
|
+
[ 215ms] [LOG] [Delta Service] ConnectionStateMachine request: WebSocket opening... @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
|
13
|
+
[ 220ms] [LOG] [Remote Dashboards] Cleaning paths on server @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
14
|
+
[ 221ms] [LOG] [UI Event Service] PWA mode: false @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:49
|
|
15
|
+
[ 226ms] [LOG] *********** KIP Version Information *********** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
16
|
+
[ 226ms] [LOG] ** App Version: 4.8.0 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
17
|
+
[ 226ms] [LOG] ** Browser Version: Chrome 149 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
18
|
+
[ 226ms] [LOG] ** OS Version: macOS @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
19
|
+
[ 226ms] [LOG] *********************************************** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
20
|
+
[ 229ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
21
|
+
[ 248ms] [LOG] [Delta Service] WebSocket connected without Authorization Token @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
|
22
|
+
[ 248ms] [LOG] [ConnectionStateMachine] WebSocket connected @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
23
|
+
[ 249ms] [LOG] [Connection Service] Server Name: signalk-server, Version: 2.23.0-beta.1, Roles: ["master","main"] @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
24
|
+
[ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
|
|
25
|
+
[ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
|
|
26
|
+
[ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
|
|
27
|
+
[ 380ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3
|
|
28
|
+
[ 476ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:3000/@signalk/freeboard-sk/:0
|
|
29
|
+
[ 934ms] [INFO] Internet connection detected. @ http://localhost:3000/@signalk/freeboard-sk/main-YHMZXR5G.js:249
|
|
30
|
+
[ 940ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
|
|
31
|
+
[ 1000ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip:0
|
|
32
|
+
[ 1000ms] [LOG] [KipSeriesApiClientService] POST http://localhost:3000/plugins/kip/series/reconcile (0 series) @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
|
|
33
|
+
[ 1007ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip/series/reconcile:0
|
|
34
|
+
[ 1007ms] [ERROR] [KipSeriesApiClientService] Series reconcile request failed: Ar @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
|
|
35
|
+
[ 1073ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor:0
|
|
36
|
+
[ 87530ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
|
|
37
|
+
[ 87531ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/anchoralarm:0
|
|
38
|
+
[ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/tracks:0
|
|
39
|
+
[ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/signalk-polar-performance-plugin:0
|
|
40
|
+
[ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/resources-provider:0
|
|
41
|
+
[ 87536ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/signalk-racer:0
|
|
42
|
+
[ 87572ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/course-provider:0
|
|
43
|
+
[ 87573ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/autopilot:0
|
|
44
|
+
[ 87573ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/pypilot-autopilot-provider:0
|
|
45
|
+
[ 112153ms] [ERROR] ERROR _: NG0951
|
|
46
|
+
at Object.computation (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:94168)
|
|
47
|
+
at Object.producerRecomputeValue (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:33213)
|
|
48
|
+
at Qn (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:30918)
|
|
49
|
+
at n.r [as iframe] (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:32825)
|
|
50
|
+
at n.ngAfterViewInit (http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:44:361583)
|
|
51
|
+
at ng (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27312)
|
|
52
|
+
at rI (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27474)
|
|
53
|
+
at Gg (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27201)
|
|
54
|
+
at wa (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:26929)
|
|
55
|
+
at bS (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:67813) @ http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3
|
|
56
|
+
[ 263184ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
57
|
+
[ 263185ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
58
|
+
[ 399636ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
59
|
+
[ 399636ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
60
|
+
[ 4767580ms] [LOG] [Delta Service] App suspended @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/plugins:0
|
|
2
|
+
[ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/appstore/available:0
|
|
3
|
+
[ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/security/access/requests:0
|
|
4
|
+
[ 2427ms] [LOG] connected @ http://localhost:3000/admin/assets/bootstrap-BbNbkfZh.js:69
|
|
5
|
+
[ 3712ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk-usage/assets/icons/signalk-usage-icon_72x72.png:0
|
package/README.md
CHANGED
|
@@ -21,13 +21,15 @@ 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.state` — tide trend, `rising` or `falling`
|
|
25
|
+
* `environment.tide.timeToNextExtreme` — seconds until the next high or low water
|
|
24
26
|
|
|
25
27
|
### Tides API
|
|
26
28
|
|
|
27
|
-
The plugin mounts the [Neaps API](https://github.com/neaps/neaps) at `/signalk/v2/api/tides`, which serves station search, extremes, and timeline predictions. The synthetic station `vessel/
|
|
29
|
+
The plugin mounts the [Neaps API](https://github.com/neaps/neaps) 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 to the vessel when none is set:
|
|
28
30
|
|
|
29
31
|
```
|
|
30
|
-
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/
|
|
32
|
+
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
### Tides resource
|
package/dist/calculations.js
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Trend of the tide at `time`: "rising" if the next extreme is a High,
|
|
3
|
+
* "falling" if it is a Low.
|
|
4
|
+
*
|
|
5
|
+
* Boundary: at the instant of an extreme the extreme itself satisfies
|
|
6
|
+
* `extreme.time >= time`, so it is still "next", and the state reads as that
|
|
7
|
+
* extreme's own type — e.g. "falling" with timeToNextExtreme 0 at the instant
|
|
8
|
+
* of low water — flipping on the following tick. Simplest consistent rule at
|
|
9
|
+
* one-minute resolution.
|
|
10
|
+
*
|
|
11
|
+
* Returns null when the forecast has no upcoming extreme (stale data).
|
|
12
|
+
*/
|
|
13
|
+
export function tideStateAt(extremes, time) {
|
|
14
|
+
const next = nextExtremeAt(extremes, time);
|
|
8
15
|
if (!next)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
(next.time.getTime() - prev.time.getTime());
|
|
12
|
-
const value = interpolate(prev.level, next.level, easeSine(progress));
|
|
13
|
-
return parseFloat(value.toFixed(3));
|
|
16
|
+
return null;
|
|
17
|
+
return next.high ? "rising" : "falling";
|
|
14
18
|
}
|
|
15
|
-
/**
|
|
16
|
-
function
|
|
17
|
-
return
|
|
19
|
+
/** First extreme at or after `time`, or undefined. Tolerates unsorted input. */
|
|
20
|
+
function nextExtremeAt(extremes, time) {
|
|
21
|
+
return extremes
|
|
22
|
+
.slice()
|
|
23
|
+
.sort((a, b) => a.time.getTime() - b.time.getTime())
|
|
24
|
+
.find((extreme) => extreme.time >= time);
|
|
18
25
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Whole seconds (rounded up) until the next tide extreme (high or low water)
|
|
28
|
+
* at `time`, or null when the forecast has no upcoming extreme. Ceiling keeps
|
|
29
|
+
* the countdown > 0 until the extreme is actually reached; see tideStateAt
|
|
30
|
+
* for the exactly-at-extreme boundary behaviour (this returns 0 for that tick).
|
|
31
|
+
*/
|
|
32
|
+
export function timeToNextExtreme(extremes, time) {
|
|
33
|
+
const next = nextExtremeAt(extremes, time);
|
|
34
|
+
if (!next)
|
|
35
|
+
return null;
|
|
36
|
+
return Math.ceil((next.time.getTime() - time.getTime()) / 1000);
|
|
24
37
|
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { createRoutes } from "@neaps/api";
|
|
17
|
-
import {
|
|
17
|
+
import { findStation, nearestStation, stationsNear } from "neaps";
|
|
18
|
+
import { tideStateAt, timeToNextExtreme } from "./calculations.js";
|
|
18
19
|
import FileCache from "./cache.js";
|
|
19
20
|
import { withVesselPosition } from "./middleware.js";
|
|
20
21
|
// Recompute and republish on a fixed interval. Predictions are computed locally
|
|
@@ -24,6 +25,8 @@ const API_PATH = "/signalk/v2/api/tides";
|
|
|
24
25
|
export default function (app) {
|
|
25
26
|
let unsubscribes = [];
|
|
26
27
|
let activeRouter = null;
|
|
28
|
+
let lastPosition = null;
|
|
29
|
+
let config = {};
|
|
27
30
|
// Mount forwarding middleware once (Express doesn't support unmounting)
|
|
28
31
|
// @ts-expect-error: app is an Express app at runtime
|
|
29
32
|
app.use(API_PATH, (req, res, next) => {
|
|
@@ -41,7 +44,15 @@ export default function (app) {
|
|
|
41
44
|
schema: () => ({
|
|
42
45
|
title: "Tides",
|
|
43
46
|
type: "object",
|
|
44
|
-
properties: {
|
|
47
|
+
properties: {
|
|
48
|
+
defaultStation: {
|
|
49
|
+
type: "string",
|
|
50
|
+
title: "Default tide station",
|
|
51
|
+
description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
|
|
52
|
+
default: "auto",
|
|
53
|
+
oneOf: defaultStationOptions(),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
45
56
|
}),
|
|
46
57
|
start,
|
|
47
58
|
stop() {
|
|
@@ -50,21 +61,59 @@ export default function (app) {
|
|
|
50
61
|
activeRouter = null;
|
|
51
62
|
},
|
|
52
63
|
};
|
|
53
|
-
|
|
64
|
+
function defaultStationOptions() {
|
|
65
|
+
const options = [{ const: "auto", title: "Closest station" }];
|
|
66
|
+
if (lastPosition) {
|
|
67
|
+
for (const station of stationsNear({ ...lastPosition, maxResults: 10 })) {
|
|
68
|
+
options.push({ const: station.id, title: stationTitle(station) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Keep the saved station selectable even when it is not in the nearby
|
|
72
|
+
// list, so opening the settings far from it (or before a position fix)
|
|
73
|
+
// cannot clobber it on save.
|
|
74
|
+
const saved = savedDefaultStation();
|
|
75
|
+
if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
|
|
76
|
+
let title = saved;
|
|
77
|
+
try {
|
|
78
|
+
title = stationTitle(findStation(saved));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Unknown id: keep the raw value as the label
|
|
82
|
+
}
|
|
83
|
+
options.splice(1, 0, { const: saved, title });
|
|
84
|
+
}
|
|
85
|
+
return options;
|
|
86
|
+
}
|
|
87
|
+
function savedDefaultStation() {
|
|
88
|
+
try {
|
|
89
|
+
const saved = app.readPluginOptions();
|
|
90
|
+
return saved?.configuration?.defaultStation;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function start(options) {
|
|
54
97
|
app.debug("Starting tides");
|
|
98
|
+
config = (options ?? {});
|
|
99
|
+
// Keep the forecast and the predictor that produced it together, so the
|
|
100
|
+
// extremes and the current height are always read from the same station.
|
|
55
101
|
let lastForecast = null;
|
|
56
|
-
let
|
|
102
|
+
let lastPredictor = null;
|
|
57
103
|
const cache = new FileCache(app.getDataDirPath());
|
|
58
|
-
// Mount the Neaps API, with vessel/
|
|
59
|
-
|
|
104
|
+
// Mount the Neaps API, with vessel/default resolved to the configured
|
|
105
|
+
// default station (or the nearest one).
|
|
106
|
+
activeRouter = withVesselPosition(createRoutes({ prefix: API_PATH }), () => lastPosition, () => config.defaultStation ?? null);
|
|
60
107
|
// Register tide predictions as a resource provider
|
|
61
108
|
app.registerResourceProvider({
|
|
62
109
|
type: "tides",
|
|
63
110
|
methods: {
|
|
64
111
|
async listResources() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
const predictor = resolvePredictor();
|
|
113
|
+
if (!predictor) {
|
|
114
|
+
throw new Error("No position or default station available");
|
|
115
|
+
}
|
|
116
|
+
return forecastFor(predictor);
|
|
68
117
|
},
|
|
69
118
|
getResource() {
|
|
70
119
|
throw new Error("Not implemented");
|
|
@@ -89,11 +138,28 @@ export default function (app) {
|
|
|
89
138
|
}, unsubscribes, (subscriptionError) => {
|
|
90
139
|
app.error("Error:" + subscriptionError);
|
|
91
140
|
}, updatePosition);
|
|
92
|
-
function
|
|
141
|
+
function resolvePredictor() {
|
|
142
|
+
if (config.defaultStation && config.defaultStation !== "auto") {
|
|
143
|
+
try {
|
|
144
|
+
return findStation(config.defaultStation);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
app.error(`Configured tide station not found: ${config.defaultStation}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (lastPosition) {
|
|
151
|
+
try {
|
|
152
|
+
return nearestStation(lastPosition);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
app.error("No tide station found near vessel position");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function forecastFor(predictor) {
|
|
93
161
|
const now = new Date();
|
|
94
|
-
return getExtremesPrediction({
|
|
95
|
-
latitude: position.latitude,
|
|
96
|
-
longitude: position.longitude,
|
|
162
|
+
return predictor.getExtremesPrediction({
|
|
97
163
|
start: now,
|
|
98
164
|
end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
99
165
|
});
|
|
@@ -109,16 +175,20 @@ export default function (app) {
|
|
|
109
175
|
if (!lastPosition) {
|
|
110
176
|
lastPosition = (await cache.get("position"));
|
|
111
177
|
}
|
|
112
|
-
|
|
113
|
-
|
|
178
|
+
// A configured default station predicts without a position
|
|
179
|
+
updateForecast();
|
|
114
180
|
}
|
|
115
181
|
function updateForecast() {
|
|
116
|
-
|
|
117
|
-
|
|
182
|
+
const predictor = resolvePredictor();
|
|
183
|
+
if (!predictor) {
|
|
184
|
+
app.debug("No position or default station available, cannot compute tide data");
|
|
185
|
+
lastForecast = null;
|
|
186
|
+
lastPredictor = null;
|
|
118
187
|
return;
|
|
119
188
|
}
|
|
120
189
|
try {
|
|
121
|
-
lastForecast = forecastFor(
|
|
190
|
+
lastForecast = forecastFor(predictor);
|
|
191
|
+
lastPredictor = predictor;
|
|
122
192
|
app.setPluginStatus("Updated tide forecast");
|
|
123
193
|
updateTides();
|
|
124
194
|
}
|
|
@@ -130,17 +200,15 @@ export default function (app) {
|
|
|
130
200
|
}
|
|
131
201
|
}
|
|
132
202
|
function updateTides(now = new Date()) {
|
|
133
|
-
if (!lastForecast || !
|
|
203
|
+
if (!lastForecast || !lastPredictor)
|
|
134
204
|
return;
|
|
135
205
|
// Get the next two upcoming extremes
|
|
136
206
|
const nextTides = lastForecast.extremes
|
|
137
207
|
.filter(({ time }) => time >= now)
|
|
138
208
|
.slice(0, 2);
|
|
139
|
-
const heightNow = getWaterLevelAtTime({
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
time: now,
|
|
143
|
-
}).level;
|
|
209
|
+
const heightNow = lastPredictor.getWaterLevelAtTime({ time: now }).level;
|
|
210
|
+
const state = tideStateAt(lastForecast.extremes, now);
|
|
211
|
+
const secondsToNextExtreme = timeToNextExtreme(lastForecast.extremes, now);
|
|
144
212
|
const delta = {
|
|
145
213
|
context: ("vessels." + app.selfId),
|
|
146
214
|
updates: [
|
|
@@ -155,6 +223,19 @@ export default function (app) {
|
|
|
155
223
|
path: "environment.tide.heightNow",
|
|
156
224
|
value: heightNow,
|
|
157
225
|
},
|
|
226
|
+
// Omitted (not null) when the forecast has no upcoming extreme,
|
|
227
|
+
// matching how the nextTides flatMap below goes empty.
|
|
228
|
+
...(state !== null
|
|
229
|
+
? [{ path: "environment.tide.state", value: state }]
|
|
230
|
+
: []),
|
|
231
|
+
...(secondsToNextExtreme !== null
|
|
232
|
+
? [
|
|
233
|
+
{
|
|
234
|
+
path: "environment.tide.timeToNextExtreme",
|
|
235
|
+
value: secondsToNextExtreme,
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
: []),
|
|
158
239
|
...nextTides.flatMap(({ label, time, level }) => {
|
|
159
240
|
return [
|
|
160
241
|
{
|
|
@@ -169,6 +250,24 @@ export default function (app) {
|
|
|
169
250
|
}),
|
|
170
251
|
],
|
|
171
252
|
},
|
|
253
|
+
{
|
|
254
|
+
timestamp: now.toISOString(),
|
|
255
|
+
meta: [
|
|
256
|
+
{
|
|
257
|
+
path: "environment.tide.state",
|
|
258
|
+
value: {
|
|
259
|
+
description: "Tide trend at the current position: rising or falling",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
path: "environment.tide.timeToNextExtreme",
|
|
264
|
+
value: {
|
|
265
|
+
units: "s",
|
|
266
|
+
description: "Seconds until the next tide extreme (high or low water)",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
172
271
|
],
|
|
173
272
|
};
|
|
174
273
|
if (app.debug.enabled) {
|
|
@@ -185,3 +284,8 @@ export default function (app) {
|
|
|
185
284
|
}
|
|
186
285
|
return plugin;
|
|
187
286
|
}
|
|
287
|
+
function stationTitle(station) {
|
|
288
|
+
const where = [station.region, station.country].filter(Boolean).join(", ");
|
|
289
|
+
const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";
|
|
290
|
+
return `${station.name}${where ? ` (${where})` : ""}${distance}`;
|
|
291
|
+
}
|
package/dist/middleware.js
CHANGED
|
@@ -1,33 +1,46 @@
|
|
|
1
|
-
import { nearestStation } from
|
|
1
|
+
import { findStation, nearestStation } from "neaps";
|
|
2
2
|
/**
|
|
3
|
-
* Middleware that
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Middleware that resolves the vessel/default station alias and bare
|
|
4
|
+
* location-based queries (/extremes, /timeline) against the configured default
|
|
5
|
+
* station, falling back to the station nearest the vessel's position when no
|
|
6
|
+
* default is set.
|
|
6
7
|
*/
|
|
7
|
-
export function withVesselPosition(router, getPosition) {
|
|
8
|
+
export function withVesselPosition(router, getPosition, getDefaultStationId) {
|
|
8
9
|
return (req, res, next) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// HTTP 303 See Other: the response can be found under a different URI
|
|
11
|
+
const redirect = (toPath) => res.redirect(303, `${req.baseUrl}${toPath}`);
|
|
12
|
+
// The configured default station, or null when unset, "auto", or stale.
|
|
13
|
+
const defaultStation = () => {
|
|
14
|
+
const id = getDefaultStationId?.();
|
|
15
|
+
if (!id || id === "auto")
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return findStation(id);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null; // stale or invalid saved id: fall back to position
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// Redirect the vessel/default alias to the configured (or nearest) station.
|
|
25
|
+
if (/^\/stations\/vessel\/default(?:\/|$)/.test(req.path)) {
|
|
26
|
+
const toStation = (station) => redirect(req.url.replace(/^\/stations\/vessel\/default\b/, `/stations/${station.id}`));
|
|
27
|
+
// A configured default station resolves without a position
|
|
28
|
+
const station = defaultStation();
|
|
29
|
+
if (station)
|
|
30
|
+
return toStation(station);
|
|
12
31
|
const pos = getPosition();
|
|
13
32
|
if (!pos) {
|
|
14
|
-
return res.status(503).json({
|
|
15
|
-
message: "Vessel position not available",
|
|
16
|
-
});
|
|
33
|
+
return res.status(503).json({ message: "Vessel position not available" });
|
|
17
34
|
}
|
|
18
35
|
try {
|
|
19
|
-
const
|
|
36
|
+
const nearest = nearestStation({
|
|
20
37
|
latitude: pos.latitude,
|
|
21
38
|
longitude: pos.longitude,
|
|
22
39
|
});
|
|
23
|
-
if (!
|
|
24
|
-
return res.status(404).json({
|
|
25
|
-
message: "No nearby stations found",
|
|
26
|
-
});
|
|
40
|
+
if (!nearest) {
|
|
41
|
+
return res.status(404).json({ message: "No nearby stations found" });
|
|
27
42
|
}
|
|
28
|
-
|
|
29
|
-
// HTTP 303 See Other: the response can be found under a different URI
|
|
30
|
-
return res.redirect(303, `${req.baseUrl}${path}`);
|
|
43
|
+
return toStation(nearest);
|
|
31
44
|
}
|
|
32
45
|
catch {
|
|
33
46
|
return res.status(500).json({
|
|
@@ -35,9 +48,14 @@ export function withVesselPosition(router, getPosition) {
|
|
|
35
48
|
});
|
|
36
49
|
}
|
|
37
50
|
}
|
|
38
|
-
//
|
|
51
|
+
// Bare location-based queries: redirect to the configured station when set,
|
|
52
|
+
// otherwise inject the vessel position so neaps uses the nearest station.
|
|
39
53
|
const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
|
|
40
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
|
+
}
|
|
41
59
|
const pos = getPosition();
|
|
42
60
|
if (pos) {
|
|
43
61
|
req.query.latitude = String(pos.latitude);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-tides",
|
|
3
|
-
"version": "2.0.0
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Tidal predictions for the vessel's position, powered by Neaps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
],
|
|
35
35
|
"license": "Apache-2.0",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@neaps/api": "
|
|
38
|
-
"@neaps/react": "
|
|
39
|
-
"@signalk/server-api": "^2.0",
|
|
37
|
+
"@neaps/api": "^0.5.1",
|
|
38
|
+
"@neaps/react": "^0.1.0",
|
|
39
|
+
"@signalk/server-api": "^2.28.0",
|
|
40
40
|
"express": "^4.0",
|
|
41
|
-
"neaps": "
|
|
41
|
+
"neaps": "^0.7.0",
|
|
42
42
|
"react": "^19.1.0",
|
|
43
43
|
"react-dom": "^19.1.0"
|
|
44
44
|
},
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@types/react": "^19.0.12",
|
|
54
54
|
"@types/react-dom": "^19.0.4",
|
|
55
55
|
"@types/supertest": "^7.2.0",
|
|
56
|
-
"@vitejs/plugin-react": "^
|
|
56
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
57
57
|
"@vitest/browser": "^4.0.18",
|
|
58
58
|
"@vitest/browser-playwright": "^4.0.18",
|
|
59
59
|
"@vitest/ui": "^4.0.18",
|