signalk-tides 2.0.0-beta.1 → 2.1.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/.github/workflows/release.yml +41 -0
- package/README.md +2 -2
- package/dist/index.js +107 -27
- package/dist/middleware.js +51 -22
- package/package.json +9 -9
- package/public/assets/{index-TySP7WUU.js → index-D7RIUA1w.js} +1 -1
- package/public/assets/{index-COM80frb.css → index-W6DTmxq6.css} +1 -1
- package/public/assets/{maplibre-gl-Nnrw_cBX.js → maplibre-gl-c4hHMhPw.js} +1 -1
- package/public/index.html +2 -2
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,41 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write # npm trusted publishing
|
|
12
|
+
contents: write # commit the version bump
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v7
|
|
15
|
+
with:
|
|
16
|
+
ref: ${{ github.event.release.target_commitish }}
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-node@v6
|
|
19
|
+
with:
|
|
20
|
+
node-version: 24 # npm >= 11.5.1 for trusted publishing
|
|
21
|
+
registry-url: https://registry.npmjs.org
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm install
|
|
25
|
+
|
|
26
|
+
- name: Set version to release tag
|
|
27
|
+
run: npm version "${TAG#v}" --no-git-tag-version --allow-same-version
|
|
28
|
+
env:
|
|
29
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
30
|
+
|
|
31
|
+
- name: Publish to npm
|
|
32
|
+
run: npm publish # prepack runs the build
|
|
33
|
+
|
|
34
|
+
# Only after a successful publish, so a failed release never mutates the repo
|
|
35
|
+
- name: Commit version bump
|
|
36
|
+
run: |
|
|
37
|
+
git config user.name "github-actions[bot]"
|
|
38
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
39
|
+
git diff --quiet || { git commit -am "Release ${TAG#v}" && git push; }
|
|
40
|
+
env:
|
|
41
|
+
TAG: ${{ github.event.release.tag_name }}
|
package/README.md
CHANGED
|
@@ -26,10 +26,10 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
|
|
|
26
26
|
|
|
27
27
|
### Tides API
|
|
28
28
|
|
|
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/
|
|
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:
|
|
30
30
|
|
|
31
31
|
```
|
|
32
|
-
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/
|
|
32
|
+
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
### Tides resource
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
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
18
|
import { tideStateAt, timeToNextExtreme } from "./calculations.js";
|
|
19
19
|
import FileCache from "./cache.js";
|
|
20
20
|
import { withVesselPosition } from "./middleware.js";
|
|
@@ -25,6 +25,8 @@ const API_PATH = "/signalk/v2/api/tides";
|
|
|
25
25
|
export default function (app) {
|
|
26
26
|
let unsubscribes = [];
|
|
27
27
|
let activeRouter = null;
|
|
28
|
+
let lastPosition = null;
|
|
29
|
+
let config = {};
|
|
28
30
|
// Mount forwarding middleware once (Express doesn't support unmounting)
|
|
29
31
|
// @ts-expect-error: app is an Express app at runtime
|
|
30
32
|
app.use(API_PATH, (req, res, next) => {
|
|
@@ -42,7 +44,15 @@ export default function (app) {
|
|
|
42
44
|
schema: () => ({
|
|
43
45
|
title: "Tides",
|
|
44
46
|
type: "object",
|
|
45
|
-
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
|
+
},
|
|
46
56
|
}),
|
|
47
57
|
start,
|
|
48
58
|
stop() {
|
|
@@ -51,21 +61,59 @@ export default function (app) {
|
|
|
51
61
|
activeRouter = null;
|
|
52
62
|
},
|
|
53
63
|
};
|
|
54
|
-
|
|
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) {
|
|
55
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.
|
|
56
101
|
let lastForecast = null;
|
|
57
|
-
let
|
|
102
|
+
let lastPredictor = null;
|
|
58
103
|
const cache = new FileCache(app.getDataDirPath());
|
|
59
|
-
// Mount the Neaps API, with vessel/
|
|
60
|
-
|
|
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);
|
|
61
107
|
// Register tide predictions as a resource provider
|
|
62
108
|
app.registerResourceProvider({
|
|
63
109
|
type: "tides",
|
|
64
110
|
methods: {
|
|
65
111
|
async listResources() {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
112
|
+
const predictor = resolvePredictor();
|
|
113
|
+
if (!predictor) {
|
|
114
|
+
throw new Error("No position or default station available");
|
|
115
|
+
}
|
|
116
|
+
return forecastFor(predictor);
|
|
69
117
|
},
|
|
70
118
|
getResource() {
|
|
71
119
|
throw new Error("Not implemented");
|
|
@@ -90,11 +138,28 @@ export default function (app) {
|
|
|
90
138
|
}, unsubscribes, (subscriptionError) => {
|
|
91
139
|
app.error("Error:" + subscriptionError);
|
|
92
140
|
}, updatePosition);
|
|
93
|
-
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) {
|
|
94
161
|
const now = new Date();
|
|
95
|
-
return getExtremesPrediction({
|
|
96
|
-
latitude: position.latitude,
|
|
97
|
-
longitude: position.longitude,
|
|
162
|
+
return predictor.getExtremesPrediction({
|
|
98
163
|
start: now,
|
|
99
164
|
end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
100
165
|
});
|
|
@@ -110,16 +175,20 @@ export default function (app) {
|
|
|
110
175
|
if (!lastPosition) {
|
|
111
176
|
lastPosition = (await cache.get("position"));
|
|
112
177
|
}
|
|
113
|
-
|
|
114
|
-
|
|
178
|
+
// A configured default station predicts without a position
|
|
179
|
+
updateForecast();
|
|
115
180
|
}
|
|
116
181
|
function updateForecast() {
|
|
117
|
-
|
|
118
|
-
|
|
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;
|
|
119
187
|
return;
|
|
120
188
|
}
|
|
121
189
|
try {
|
|
122
|
-
lastForecast = forecastFor(
|
|
190
|
+
lastForecast = forecastFor(predictor);
|
|
191
|
+
lastPredictor = predictor;
|
|
123
192
|
app.setPluginStatus("Updated tide forecast");
|
|
124
193
|
updateTides();
|
|
125
194
|
}
|
|
@@ -131,17 +200,13 @@ export default function (app) {
|
|
|
131
200
|
}
|
|
132
201
|
}
|
|
133
202
|
function updateTides(now = new Date()) {
|
|
134
|
-
if (!lastForecast || !
|
|
203
|
+
if (!lastForecast || !lastPredictor)
|
|
135
204
|
return;
|
|
136
205
|
// Get the next two upcoming extremes
|
|
137
206
|
const nextTides = lastForecast.extremes
|
|
138
207
|
.filter(({ time }) => time >= now)
|
|
139
208
|
.slice(0, 2);
|
|
140
|
-
const heightNow = getWaterLevelAtTime({
|
|
141
|
-
latitude: lastPosition.latitude,
|
|
142
|
-
longitude: lastPosition.longitude,
|
|
143
|
-
time: now,
|
|
144
|
-
}).level;
|
|
209
|
+
const heightNow = lastPredictor.getWaterLevelAtTime({ time: now }).level;
|
|
145
210
|
const state = tideStateAt(lastForecast.extremes, now);
|
|
146
211
|
const secondsToNextExtreme = timeToNextExtreme(lastForecast.extremes, now);
|
|
147
212
|
const delta = {
|
|
@@ -164,7 +229,12 @@ export default function (app) {
|
|
|
164
229
|
? [{ path: "environment.tide.state", value: state }]
|
|
165
230
|
: []),
|
|
166
231
|
...(secondsToNextExtreme !== null
|
|
167
|
-
? [
|
|
232
|
+
? [
|
|
233
|
+
{
|
|
234
|
+
path: "environment.tide.timeToNextExtreme",
|
|
235
|
+
value: secondsToNextExtreme,
|
|
236
|
+
},
|
|
237
|
+
]
|
|
168
238
|
: []),
|
|
169
239
|
...nextTides.flatMap(({ label, time, level }) => {
|
|
170
240
|
return [
|
|
@@ -185,11 +255,16 @@ export default function (app) {
|
|
|
185
255
|
meta: [
|
|
186
256
|
{
|
|
187
257
|
path: "environment.tide.state",
|
|
188
|
-
value: {
|
|
258
|
+
value: {
|
|
259
|
+
description: "Tide trend at the current position: rising or falling",
|
|
260
|
+
},
|
|
189
261
|
},
|
|
190
262
|
{
|
|
191
263
|
path: "environment.tide.timeToNextExtreme",
|
|
192
|
-
value: {
|
|
264
|
+
value: {
|
|
265
|
+
units: "s",
|
|
266
|
+
description: "Seconds until the next tide extreme (high or low water)",
|
|
267
|
+
},
|
|
193
268
|
},
|
|
194
269
|
],
|
|
195
270
|
},
|
|
@@ -209,3 +284,8 @@ export default function (app) {
|
|
|
209
284
|
}
|
|
210
285
|
return plugin;
|
|
211
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,13 +48,29 @@ 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
|
-
req.query
|
|
44
|
-
|
|
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
|
+
});
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
router(req, res, next);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-tides",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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",
|
|
40
|
-
"express": "^
|
|
41
|
-
"neaps": "
|
|
37
|
+
"@neaps/api": "^0.6.0",
|
|
38
|
+
"@neaps/react": "^0.1.1",
|
|
39
|
+
"@signalk/server-api": "^2.28.0",
|
|
40
|
+
"express": "^5.2.1",
|
|
41
|
+
"neaps": "^0.7.0",
|
|
42
42
|
"react": "^19.1.0",
|
|
43
43
|
"react-dom": "^19.1.0"
|
|
44
44
|
},
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@testing-library/dom": "^10.4.1",
|
|
49
49
|
"@testing-library/react": "^16.3.2",
|
|
50
50
|
"@testing-library/user-event": "^14.6.1",
|
|
51
|
-
"@types/express": "^
|
|
52
|
-
"@types/node": "^
|
|
51
|
+
"@types/express": "^5.0.6",
|
|
52
|
+
"@types/node": "^26.0.1",
|
|
53
53
|
"@types/react": "^19.0.12",
|
|
54
54
|
"@types/react-dom": "^19.0.4",
|
|
55
55
|
"@types/supertest": "^7.2.0",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
|
79
|
-
"url": "git+https://github.com/
|
|
79
|
+
"url": "git+https://github.com/openwatersio/signalk-tides.git"
|
|
80
80
|
},
|
|
81
81
|
"signalk-plugin-enabled-by-default": true,
|
|
82
82
|
"signalk": {
|