signalk-tides 1.5.0 → 2.0.0-beta.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 +28 -2
- package/.playwright-mcp/console-2026-06-06T09-48-18-232Z.log +1 -0
- package/.playwright-mcp/console-2026-06-12T13-01-03-706Z.log +1 -0
- package/.playwright-mcp/console-2026-06-12T13-15-30-205Z.log +44 -0
- package/.playwright-mcp/console-2026-06-12T13-16-00-389Z.log +1 -0
- package/README.md +25 -187
- package/dist/calculations.js +6 -6
- package/dist/index.js +79 -59
- package/dist/middleware.js +49 -0
- package/dist/routes.js +176 -0
- package/dist/sources/neaps.js +39 -31
- package/dist/sources/noaa.js +241 -80
- package/dist/sources/stormglass.js +188 -39
- package/dist/sources/worldtides.js +211 -39
- package/package.json +34 -18
- package/public/assets/index-8PLt-an_.css +1 -0
- package/public/assets/index-CvXA6qTu.js +12 -0
- package/public/assets/maplibre-gl-lX2dHdJ0.js +800 -0
- package/public/index.html +3 -3
- package/tides-dev-server.ts +119 -0
- package/tsconfig.json +2 -1
- package/tsconfig.test.json +8 -0
- package/vitest.config.browser.ts +27 -0
- package/vitest.config.node.ts +18 -0
- package/public/assets/index-ClNCI7kN.js +0 -9
- package/public/assets/index-D8qkCL0i.css +0 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -20,8 +20,8 @@ jobs:
|
|
|
20
20
|
- name: Run linter
|
|
21
21
|
run: npm run lint
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
name:
|
|
23
|
+
test:
|
|
24
|
+
name: Test
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
env:
|
|
27
27
|
CI: true
|
|
@@ -35,5 +35,31 @@ jobs:
|
|
|
35
35
|
- name: Install dependencies
|
|
36
36
|
run: npm install
|
|
37
37
|
|
|
38
|
+
- name: Install Playwright browsers
|
|
39
|
+
run: npx playwright install chromium
|
|
40
|
+
|
|
38
41
|
- name: Run build
|
|
39
42
|
run: npm run build
|
|
43
|
+
|
|
44
|
+
- name: Run Tests
|
|
45
|
+
run: npm run test
|
|
46
|
+
|
|
47
|
+
publish:
|
|
48
|
+
needs: [lint, test]
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
pull-requests: write
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@v6
|
|
55
|
+
|
|
56
|
+
- uses: actions/setup-node@v6
|
|
57
|
+
|
|
58
|
+
- name: Install modules
|
|
59
|
+
run: npm install
|
|
60
|
+
|
|
61
|
+
- name: Build
|
|
62
|
+
run: npm run build
|
|
63
|
+
|
|
64
|
+
- name: Publish to pkg.pr.new
|
|
65
|
+
run: npx pkg-pr-new publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[ 2058ms] [ERROR] Failed to load resource: net::ERR_NETWORK_IO_SUSPENDED @ https://docs.stormglass.io/_media/logo.svg:0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[ 239ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk-tides/assets/icon.svg:0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[ 260ms] [LOG] [ConnectionStateMachine] Service initialized @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
2
|
+
[ 265ms] [LOG] [AppInit Network Service] Connection Configuration not found. Creating configuration using Auto-Discovery URL: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
3
|
+
[ 268ms] [LOG] [ConnectionStateMachine] Starting HTTP discovery: Connecting to http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
4
|
+
[ 268ms] [LOG] [Connection Service] Connecting to: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
5
|
+
[ 279ms] [LOG] [ConnectionStateMachine] HTTP discovery successful @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
6
|
+
[ 279ms] [LOG] [AppSettings Service] Service startup... @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:3
|
|
7
|
+
[ 279ms] [LOG] [AppSettings Service] LocalStorage enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
8
|
+
[ 279ms] [LOG] [AppSettings Service] Error loading appConfig config. Force loading appConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
9
|
+
[ 279ms] [LOG] [AppSettings Service] Error loading dashboardsConfig config. Force loading dashboardsConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
10
|
+
[ 279ms] [LOG] [AppSettings Service] Error loading themeConfig config. Force loading themeConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
11
|
+
[ 280ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
12
|
+
[ 280ms] [LOG] [DatasetStreamService] Auto Starting 0 Datasets @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
|
|
13
|
+
[ 280ms] [LOG] [AppInit Network Service] Initialization completed @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
14
|
+
[ 280ms] [LOG] [ConnectionStateMachine] WebSocket mode enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
15
|
+
[ 280ms] [LOG] [AppInit Network Service] Starting WebSocket connection after initialization @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
16
|
+
[ 280ms] [LOG] [ConnectionStateMachine] Starting WebSocket connection @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
17
|
+
[ 280ms] [LOG] [Delta Service] ConnectionStateMachine request: WebSocket opening... @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
|
18
|
+
[ 287ms] [WARNING] [Dashboard Service] No dashboards found in settings, creating blank dashboard @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
|
|
19
|
+
[ 289ms] [LOG] [Remote Dashboards] Cleaning paths on server @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
|
|
20
|
+
[ 290ms] [LOG] [UI Event Service] PWA mode: false @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:49
|
|
21
|
+
[ 300ms] [LOG] *********** KIP Version Information *********** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
22
|
+
[ 300ms] [LOG] ** App Version: 4.8.0 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
23
|
+
[ 300ms] [LOG] ** Browser Version: Chrome 149 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
24
|
+
[ 300ms] [LOG] ** OS Version: macOS @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
25
|
+
[ 300ms] [LOG] *********************************************** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
|
|
26
|
+
[ 305ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
|
|
27
|
+
[ 323ms] [LOG] [Delta Service] WebSocket connected without Authorization Token @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
|
28
|
+
[ 324ms] [LOG] [ConnectionStateMachine] WebSocket connected @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
|
|
29
|
+
[ 324ms] [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
|
|
30
|
+
[ 437ms] [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
|
|
31
|
+
[ 437ms] [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
|
|
32
|
+
[ 437ms] [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
|
|
33
|
+
[ 450ms] [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
|
|
34
|
+
[ 515ms] [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
|
|
35
|
+
[ 1311ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
|
|
36
|
+
[ 1387ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip:0
|
|
37
|
+
[ 1387ms] [LOG] [KipSeriesApiClientService] POST http://localhost:3000/plugins/kip/series/reconcile (0 series) @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
|
|
38
|
+
[ 1403ms] [INFO] Internet connection detected. @ http://localhost:3000/@signalk/freeboard-sk/main-YHMZXR5G.js:249
|
|
39
|
+
[ 1414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip/series/reconcile:0
|
|
40
|
+
[ 1414ms] [ERROR] [KipSeriesApiClientService] Series reconcile request failed: Ar @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
|
|
41
|
+
[ 1517ms] [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
|
|
42
|
+
[ 1784ms] [WARNING] Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. Consider using the inert attribute instead, which will also prevent focus. For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.
|
|
43
|
+
Element with focus: <div>
|
|
44
|
+
Ancestor with aria-hidden: <app-root> @ http://localhost:3000/@signalk/freeboard-sk/:0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[ 68ms] [LOG] [Delta Service] App suspended @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
|
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# signalk-tides
|
|
2
2
|
|
|
3
|
-
A SignalK plugin that provides tidal predictions for the vessel's position
|
|
3
|
+
A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Neaps](https://github.com/neaps/neaps).
|
|
4
|
+
|
|
5
|
+
Since 2.0, predictions are computed locally from harmonic constituents — no network access or API keys required.
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Install `signalk-tides` from the SignalK Appstore or manually by running `npm install signalk-tides` in the SignalK server directory (`~/.signalk`).
|
|
8
10
|
|
|
9
|
-
2. Optionally go to the plugin settings in "Server => Plugin Config => Tides" and configure which source to use.
|
|
10
11
|
|
|
11
12
|
## Usage
|
|
12
13
|
|
|
@@ -21,204 +22,41 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
|
|
|
21
22
|
* `environment.tide.heightNow`
|
|
22
23
|
* `environment.tide.stationName`
|
|
23
24
|
|
|
24
|
-
### Tides
|
|
25
|
+
### Tides API
|
|
25
26
|
|
|
26
|
-
|
|
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/current` resolves to the nearest station to the vessel:
|
|
27
28
|
|
|
28
29
|
```
|
|
29
|
-
$ curl http://localhost:3000/signalk/v2/api/
|
|
30
|
+
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/current/extremes
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
### Tides resource
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
It also registers a `tides` resource, which returns the next 7 days of tide extremes for the vessel's position.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
$ curl http://localhost:3000/signalk/v2/api/resources/tides
|
|
39
|
+
```
|
|
35
40
|
|
|
36
41
|
##### Response
|
|
37
42
|
|
|
38
43
|
```json
|
|
39
44
|
{
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
"time" : "2025-03-29T13:18:00.000Z",
|
|
53
|
-
"type" : "Low",
|
|
54
|
-
"value" : 0.044
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"time" : "2025-03-29T19:45:00.000Z",
|
|
58
|
-
"type" : "High",
|
|
59
|
-
"value" : 1.815
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"time" : "2025-03-30T01:24:00.000Z",
|
|
63
|
-
"type" : "Low",
|
|
64
|
-
"value" : 0.164
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
"time" : "2025-03-30T07:52:00.000Z",
|
|
68
|
-
"type" : "High",
|
|
69
|
-
"value" : 2.024
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"time" : "2025-03-30T14:05:00.000Z",
|
|
73
|
-
"type" : "Low",
|
|
74
|
-
"value" : -0.131
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"time" : "2025-03-30T20:43:00.000Z",
|
|
78
|
-
"type" : "High",
|
|
79
|
-
"value" : 1.733
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
"time" : "2025-03-31T02:05:00.000Z",
|
|
83
|
-
"type" : "Low",
|
|
84
|
-
"value" : 0.334
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
"time" : "2025-03-31T08:27:00.000Z",
|
|
88
|
-
"type" : "High",
|
|
89
|
-
"value" : 2.084
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
"time" : "2025-03-31T14:53:00.000Z",
|
|
93
|
-
"type" : "Low",
|
|
94
|
-
"value" : -0.247
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
"time" : "2025-03-31T21:44:00.000Z",
|
|
98
|
-
"type" : "High",
|
|
99
|
-
"value" : 1.632
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
"time" : "2025-04-01T02:48:00.000Z",
|
|
103
|
-
"type" : "Low",
|
|
104
|
-
"value" : 0.517
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
"time" : "2025-04-01T09:06:00.000Z",
|
|
108
|
-
"type" : "High",
|
|
109
|
-
"value" : 2.094
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
"time" : "2025-04-01T15:46:00.000Z",
|
|
113
|
-
"type" : "Low",
|
|
114
|
-
"value" : -0.29
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
"time" : "2025-04-01T22:51:00.000Z",
|
|
118
|
-
"type" : "High",
|
|
119
|
-
"value" : 1.529
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
"time" : "2025-04-02T03:37:00.000Z",
|
|
123
|
-
"type" : "Low",
|
|
124
|
-
"value" : 0.695
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
"time" : "2025-04-02T09:49:00.000Z",
|
|
128
|
-
"type" : "High",
|
|
129
|
-
"value" : 2.05
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
"time" : "2025-04-02T16:43:00.000Z",
|
|
133
|
-
"type" : "Low",
|
|
134
|
-
"value" : -0.263
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
"time" : "2025-04-03T00:04:00.000Z",
|
|
138
|
-
"type" : "High",
|
|
139
|
-
"value" : 1.448
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
"time" : "2025-04-03T04:35:00.000Z",
|
|
143
|
-
"type" : "Low",
|
|
144
|
-
"value" : 0.847
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
"time" : "2025-04-03T10:39:00.000Z",
|
|
148
|
-
"type" : "High",
|
|
149
|
-
"value" : 1.957
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
"time" : "2025-04-03T17:50:00.000Z",
|
|
153
|
-
"type" : "Low",
|
|
154
|
-
"value" : -0.189
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
"time" : "2025-04-04T01:24:00.000Z",
|
|
158
|
-
"type" : "High",
|
|
159
|
-
"value" : 1.421
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
"time" : "2025-04-04T05:49:00.000Z",
|
|
163
|
-
"type" : "Low",
|
|
164
|
-
"value" : 0.946
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
"time" : "2025-04-04T11:40:00.000Z",
|
|
168
|
-
"type" : "High",
|
|
169
|
-
"value" : 1.837
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
"time" : "2025-04-04T19:07:00.000Z",
|
|
173
|
-
"type" : "Low",
|
|
174
|
-
"value" : -0.11
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
"time" : "2025-04-05T02:40:00.000Z",
|
|
178
|
-
"type" : "High",
|
|
179
|
-
"value" : 1.455
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
"time" : "2025-04-05T07:19:00.000Z",
|
|
183
|
-
"type" : "Low",
|
|
184
|
-
"value" : 0.958
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
"time" : "2025-04-05T12:53:00.000Z",
|
|
188
|
-
"type" : "High",
|
|
189
|
-
"value" : 1.728
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
"time" : "2025-04-05T20:25:00.000Z",
|
|
193
|
-
"type" : "Low",
|
|
194
|
-
"value" : -0.061
|
|
195
|
-
}
|
|
196
|
-
],
|
|
197
|
-
"station" : {
|
|
198
|
-
"name" : "Rincon Point, Pier 22 1/2",
|
|
199
|
-
"position" : {
|
|
200
|
-
"latitude" : 37.79,
|
|
201
|
-
"longitude" : -122.387
|
|
202
|
-
}
|
|
203
|
-
}
|
|
45
|
+
"datum": "MLLW",
|
|
46
|
+
"units": "meters",
|
|
47
|
+
"station": {
|
|
48
|
+
"id": "noaa/9414290",
|
|
49
|
+
"name": "San Francisco",
|
|
50
|
+
"latitude": 37.806,
|
|
51
|
+
"longitude": -122.465
|
|
52
|
+
},
|
|
53
|
+
"extremes": [
|
|
54
|
+
{ "time": "2025-03-29T00:45:00.000Z", "level": 0.025, "high": false, "low": true, "label": "Low" },
|
|
55
|
+
{ "time": "2025-03-29T07:20:00.000Z", "level": 1.928, "high": true, "low": false, "label": "High" }
|
|
56
|
+
]
|
|
204
57
|
}
|
|
205
58
|
```
|
|
206
59
|
|
|
207
|
-
## Sources
|
|
208
|
-
|
|
209
|
-
- [Neaps](https://github.com/neaps/neaps)
|
|
210
|
-
- Offline, open source, runs locally
|
|
211
|
-
- Coverage: Regional — United States and territories (currently)
|
|
212
|
-
- [NOAA](https://tidesandcurrents.noaa.gov/web_services_info.html)
|
|
213
|
-
- Online (NOAA web services)
|
|
214
|
-
- Regional — United States and territories
|
|
215
|
-
- [WorldTides API](https://www.worldtides.info/)
|
|
216
|
-
- Online (requires API key, subject to API quotas)
|
|
217
|
-
- Coverage: Global - station and model coverage varies by location
|
|
218
|
-
- [StormGlass.io](https://stormglass.io/)
|
|
219
|
-
- Online (requires API key)
|
|
220
|
-
- Coverage: Global - aggregated marine data; tide availability varies
|
|
221
|
-
|
|
222
60
|
## License
|
|
223
61
|
|
|
224
62
|
This plugin is a fork of the [signalk-tides-api](https://github.com/joabakk/signalk-tides-api) plugin (which is no longer working) and is licensed under the [Apache License 2.0](LICENSE). Kudos to @joabakk and @sbender9 for the original work.
|
package/dist/calculations.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/** Given a list of tide extremes, estimate the height at a specific time. */
|
|
2
2
|
export function approximateTideHeightAt(extremes, time) {
|
|
3
|
-
const sorted = extremes.slice().sort((a, b) =>
|
|
4
|
-
const prev = sorted.filter(h =>
|
|
5
|
-
const next = sorted.filter(h =>
|
|
3
|
+
const sorted = extremes.slice().sort((a, b) => a.time.getTime() - b.time.getTime());
|
|
4
|
+
const prev = sorted.filter(h => h.time <= time).at(-1);
|
|
5
|
+
const next = sorted.filter(h => h.time >= time).at(0);
|
|
6
6
|
if (!prev)
|
|
7
7
|
throw new Error("Missing height data before " + time.toISOString());
|
|
8
8
|
if (!next)
|
|
9
9
|
throw new Error("Missing height data after " + time.toISOString());
|
|
10
|
-
const progress = (time.getTime() -
|
|
11
|
-
(
|
|
12
|
-
const value = interpolate(prev.
|
|
10
|
+
const progress = (time.getTime() - prev.time.getTime()) /
|
|
11
|
+
(next.time.getTime() - prev.time.getTime());
|
|
12
|
+
const value = interpolate(prev.level, next.level, easeSine(progress));
|
|
13
13
|
return parseFloat(value.toFixed(3));
|
|
14
14
|
}
|
|
15
15
|
/** Interpolate between two values using the given progress (0-1). */
|
package/dist/index.js
CHANGED
|
@@ -13,65 +13,58 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { createRoutes } from "@neaps/api";
|
|
17
|
+
import { getExtremesPrediction, getWaterLevelAtTime } from "neaps";
|
|
17
18
|
import FileCache from "./cache.js";
|
|
18
|
-
import
|
|
19
|
+
import { withVesselPosition } from "./middleware.js";
|
|
20
|
+
// Recompute and republish on a fixed interval. Predictions are computed locally
|
|
21
|
+
// by neaps, so this is cheap and needs no configuration.
|
|
22
|
+
const UPDATE_INTERVAL = 60 * 1000; // 1 minute
|
|
23
|
+
const API_PATH = "/signalk/v2/api/tides";
|
|
19
24
|
export default function (app) {
|
|
20
|
-
// Interval to update tide data
|
|
21
|
-
const defaultPeriod = 60; // 1 hour
|
|
22
25
|
let unsubscribes = [];
|
|
23
|
-
|
|
26
|
+
let activeRouter = null;
|
|
27
|
+
// Mount forwarding middleware once (Express doesn't support unmounting)
|
|
28
|
+
// @ts-expect-error: app is an Express app at runtime
|
|
29
|
+
app.use(API_PATH, (req, res, next) => {
|
|
30
|
+
if (activeRouter) {
|
|
31
|
+
activeRouter(req, res, next);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
next();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
24
37
|
const plugin = {
|
|
25
38
|
id: "tides",
|
|
26
39
|
name: "Tides",
|
|
27
|
-
description: "
|
|
40
|
+
description: "Offline tidal predictions for the vessel's position, powered by Neaps.",
|
|
28
41
|
schema: () => ({
|
|
29
|
-
title: "Tides
|
|
42
|
+
title: "Tides",
|
|
30
43
|
type: "object",
|
|
31
|
-
properties: {
|
|
32
|
-
source: {
|
|
33
|
-
title: "Data source",
|
|
34
|
-
type: "string",
|
|
35
|
-
anyOf: sources.map(({ id, title }) => ({
|
|
36
|
-
const: id,
|
|
37
|
-
title,
|
|
38
|
-
})),
|
|
39
|
-
default: sources[0].id,
|
|
40
|
-
},
|
|
41
|
-
// Update plugin schema with sources
|
|
42
|
-
...sources.reduce((properties, source) => Object.assign(properties, source.properties ?? {}), {}),
|
|
43
|
-
period: {
|
|
44
|
-
title: "Update frequency",
|
|
45
|
-
type: "number",
|
|
46
|
-
description: "How often to update tide forecast (minutes)",
|
|
47
|
-
default: 60,
|
|
48
|
-
minimum: 1,
|
|
49
|
-
},
|
|
50
|
-
},
|
|
44
|
+
properties: {},
|
|
51
45
|
}),
|
|
52
46
|
start,
|
|
53
47
|
stop() {
|
|
54
48
|
unsubscribes.forEach((f) => f());
|
|
55
49
|
unsubscribes = [];
|
|
50
|
+
activeRouter = null;
|
|
56
51
|
},
|
|
57
52
|
};
|
|
58
|
-
async function start(
|
|
59
|
-
app.debug("Starting tides
|
|
53
|
+
async function start() {
|
|
54
|
+
app.debug("Starting tides");
|
|
60
55
|
let lastForecast = null;
|
|
61
56
|
let lastPosition = null;
|
|
62
57
|
const cache = new FileCache(app.getDataDirPath());
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
const provider = await source.start(props);
|
|
67
|
-
// Register the source as a resource provider
|
|
58
|
+
// Mount the Neaps API, with vessel/current resolved to the nearest station.
|
|
59
|
+
activeRouter = withVesselPosition(createRoutes({ prefix: API_PATH }), () => lastPosition);
|
|
60
|
+
// Register tide predictions as a resource provider
|
|
68
61
|
app.registerResourceProvider({
|
|
69
62
|
type: "tides",
|
|
70
63
|
methods: {
|
|
71
|
-
async listResources(
|
|
64
|
+
async listResources() {
|
|
72
65
|
if (!lastPosition)
|
|
73
66
|
throw new Error("No position available");
|
|
74
|
-
return
|
|
67
|
+
return forecastFor(lastPosition);
|
|
75
68
|
},
|
|
76
69
|
getResource() {
|
|
77
70
|
throw new Error("Not implemented");
|
|
@@ -89,29 +82,44 @@ export default function (app) {
|
|
|
89
82
|
subscribe: [
|
|
90
83
|
{
|
|
91
84
|
path: "navigation.position",
|
|
92
|
-
period:
|
|
85
|
+
period: UPDATE_INTERVAL,
|
|
93
86
|
policy: "fixed",
|
|
94
87
|
},
|
|
95
88
|
],
|
|
96
89
|
}, unsubscribes, (subscriptionError) => {
|
|
97
90
|
app.error("Error:" + subscriptionError);
|
|
98
91
|
}, updatePosition);
|
|
92
|
+
function forecastFor(position) {
|
|
93
|
+
const now = new Date();
|
|
94
|
+
return getExtremesPrediction({
|
|
95
|
+
latitude: position.latitude,
|
|
96
|
+
longitude: position.longitude,
|
|
97
|
+
start: now,
|
|
98
|
+
end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
99
101
|
async function updatePosition() {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
102
|
+
const newPosition = app.getSelfPath("navigation.position.value");
|
|
103
|
+
// New position received, save it to the cache
|
|
104
|
+
if (newPosition) {
|
|
105
|
+
lastPosition = newPosition;
|
|
103
106
|
await cache.set("position", lastPosition);
|
|
104
|
-
await updateForecast();
|
|
105
107
|
}
|
|
108
|
+
// No last known position, try to load from cache.
|
|
109
|
+
if (!lastPosition) {
|
|
110
|
+
lastPosition = (await cache.get("position"));
|
|
111
|
+
}
|
|
112
|
+
if (lastPosition)
|
|
113
|
+
updateForecast();
|
|
106
114
|
}
|
|
107
|
-
|
|
115
|
+
function updateForecast() {
|
|
108
116
|
if (!lastPosition) {
|
|
109
|
-
app.debug("No position available, cannot
|
|
117
|
+
app.debug("No position available, cannot compute tide data");
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
try {
|
|
113
|
-
lastForecast =
|
|
114
|
-
app.setPluginStatus("Updated tide forecast
|
|
121
|
+
lastForecast = forecastFor(lastPosition);
|
|
122
|
+
app.setPluginStatus("Updated tide forecast");
|
|
115
123
|
updateTides();
|
|
116
124
|
}
|
|
117
125
|
catch (e) {
|
|
@@ -121,13 +129,18 @@ export default function (app) {
|
|
|
121
129
|
app.error(e);
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
|
-
|
|
125
|
-
if (!lastForecast)
|
|
132
|
+
function updateTides(now = new Date()) {
|
|
133
|
+
if (!lastForecast || !lastPosition)
|
|
126
134
|
return;
|
|
127
135
|
// Get the next two upcoming extremes
|
|
128
136
|
const nextTides = lastForecast.extremes
|
|
129
|
-
.filter(({ time }) =>
|
|
137
|
+
.filter(({ time }) => time >= now)
|
|
130
138
|
.slice(0, 2);
|
|
139
|
+
const heightNow = getWaterLevelAtTime({
|
|
140
|
+
latitude: lastPosition.latitude,
|
|
141
|
+
longitude: lastPosition.longitude,
|
|
142
|
+
time: now,
|
|
143
|
+
}).level;
|
|
131
144
|
const delta = {
|
|
132
145
|
context: ("vessels." + app.selfId),
|
|
133
146
|
updates: [
|
|
@@ -140,28 +153,35 @@ export default function (app) {
|
|
|
140
153
|
},
|
|
141
154
|
{
|
|
142
155
|
path: "environment.tide.heightNow",
|
|
143
|
-
value:
|
|
156
|
+
value: heightNow,
|
|
144
157
|
},
|
|
145
|
-
...nextTides.flatMap(({
|
|
158
|
+
...nextTides.flatMap(({ label, time, level }) => {
|
|
146
159
|
return [
|
|
147
|
-
{
|
|
148
|
-
|
|
160
|
+
{
|
|
161
|
+
path: `environment.tide.height${label}`,
|
|
162
|
+
value: level,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
path: `environment.tide.time${label}`,
|
|
166
|
+
value: time.toISOString(),
|
|
167
|
+
},
|
|
149
168
|
];
|
|
150
169
|
}),
|
|
151
170
|
],
|
|
152
171
|
},
|
|
153
172
|
],
|
|
154
173
|
};
|
|
155
|
-
app.debug
|
|
174
|
+
if (app.debug.enabled) {
|
|
175
|
+
app.debug("Sending delta: " + JSON.stringify(delta));
|
|
176
|
+
}
|
|
156
177
|
app.handleMessage(plugin.id, delta);
|
|
157
178
|
}
|
|
158
179
|
// Perform initial update on startup after short delay to allow gnss position to be populated
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return new Promise((resolve) => setTimeout(resolve, time));
|
|
180
|
+
const startupTimer = setTimeout(updatePosition, 4000);
|
|
181
|
+
unsubscribes.push(() => clearTimeout(startupTimer));
|
|
182
|
+
// Recompute the current height and next tides every minute
|
|
183
|
+
const updateTimer = setInterval(updateTides, UPDATE_INTERVAL);
|
|
184
|
+
unsubscribes.push(() => clearInterval(updateTimer));
|
|
165
185
|
}
|
|
166
186
|
return plugin;
|
|
167
187
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { nearestStation } from 'neaps';
|
|
2
|
+
/**
|
|
3
|
+
* Middleware that handles vessel/current station endpoints by redirecting to
|
|
4
|
+
* the nearest real station, and injects the default vessel position for
|
|
5
|
+
* location-based queries.
|
|
6
|
+
*/
|
|
7
|
+
export function withVesselPosition(router, getPosition) {
|
|
8
|
+
return (req, res, next) => {
|
|
9
|
+
const isVesselCurrentPath = /^\/stations\/vessel\/current(?:\/|$)/.test(req.path);
|
|
10
|
+
// Handle vessel/current station endpoints by redirecting to the nearest real station
|
|
11
|
+
if (isVesselCurrentPath) {
|
|
12
|
+
const pos = getPosition();
|
|
13
|
+
if (!pos) {
|
|
14
|
+
return res.status(503).json({
|
|
15
|
+
message: "Vessel position not available",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const station = nearestStation({
|
|
20
|
+
latitude: pos.latitude,
|
|
21
|
+
longitude: pos.longitude,
|
|
22
|
+
});
|
|
23
|
+
if (!station) {
|
|
24
|
+
return res.status(404).json({
|
|
25
|
+
message: "No nearby stations found",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const path = req.url.replace(/^\/stations\/vessel\/current\b/, `/stations/${station.id}`);
|
|
29
|
+
// HTTP 303 See Other: the response can be found under a different URI
|
|
30
|
+
return res.redirect(303, `${req.baseUrl}${path}`);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return res.status(500).json({
|
|
34
|
+
message: "Failed to find nearest station",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Inject default position for location-based queries
|
|
39
|
+
const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
|
|
40
|
+
if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
|
|
41
|
+
const pos = getPosition();
|
|
42
|
+
if (pos) {
|
|
43
|
+
req.query.latitude = String(pos.latitude);
|
|
44
|
+
req.query.longitude = String(pos.longitude);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
router(req, res, next);
|
|
48
|
+
};
|
|
49
|
+
}
|