signalk-tides 1.2.3 → 1.4.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/dependabot.yml +12 -0
- package/README.md +2 -0
- package/dist/cache.js +28 -0
- package/dist/calculations.js +27 -0
- package/dist/index.js +60 -42
- package/dist/sources/noaa.js +14 -15
- package/dist/sources/stormglass.js +4 -8
- package/dist/sources/worldtides.js +5 -8
- package/package.json +5 -7
- package/public/assets/index-DDL3NhwZ.js +49 -0
- package/public/assets/index-v5sE8dbG.css +1 -0
- package/public/index.html +2 -2
- package/CONTRIBUTING.md +0 -1
- package/public/assets/index-b9CEyV9Z.css +0 -1
- package/public/assets/index-yst_zb1r.js +0 -49
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "npm"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
open-pull-requests-limit: 100
|
|
8
|
+
- package-ecosystem: "github-actions"
|
|
9
|
+
directory: "/"
|
|
10
|
+
schedule:
|
|
11
|
+
interval: "weekly"
|
|
12
|
+
open-pull-requests-limit: 100
|
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
|
|
|
18
18
|
* `environment.tide.timeHigh`
|
|
19
19
|
* `environment.tide.heightLow`
|
|
20
20
|
* `environment.tide.timeLow`
|
|
21
|
+
* `environment.tide.heightNow`
|
|
22
|
+
* `environment.tide.stationName`
|
|
21
23
|
|
|
22
24
|
### Tides resource
|
|
23
25
|
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const path_1 = require("path");
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
class FileCache {
|
|
6
|
+
path;
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.path = path;
|
|
9
|
+
}
|
|
10
|
+
async get(key) {
|
|
11
|
+
try {
|
|
12
|
+
const content = await (0, promises_1.readFile)(this.getKey(key), 'utf-8');
|
|
13
|
+
return JSON.parse(content);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async set(key, value) {
|
|
20
|
+
// Ensure directory exists
|
|
21
|
+
await (0, promises_1.mkdir)(this.path, { recursive: true });
|
|
22
|
+
return (0, promises_1.writeFile)(this.getKey(key), JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
getKey(key) {
|
|
25
|
+
return (0, path_1.join)(this.path, `${key}.json`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.default = FileCache;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.approximateTideHeightAt = approximateTideHeightAt;
|
|
4
|
+
/** Given a list of tide extremes, estimate the height at a specific time. */
|
|
5
|
+
function approximateTideHeightAt(extremes, time) {
|
|
6
|
+
const sorted = extremes.slice().sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
|
|
7
|
+
const prev = sorted.filter(h => new Date(h.time) <= time).at(-1);
|
|
8
|
+
const next = sorted.filter(h => new Date(h.time) >= time).at(0);
|
|
9
|
+
if (!prev)
|
|
10
|
+
throw new Error("Missing height data before " + time.toISOString());
|
|
11
|
+
if (!next)
|
|
12
|
+
throw new Error("Missing height data after " + time.toISOString());
|
|
13
|
+
const progress = (time.getTime() - new Date(prev.time).getTime()) /
|
|
14
|
+
(new Date(next.time).getTime() - new Date(prev.time).getTime());
|
|
15
|
+
const value = interpolate(prev.value, next.value, easeSine(progress));
|
|
16
|
+
return parseFloat(value.toFixed(3));
|
|
17
|
+
}
|
|
18
|
+
/** Interpolate between two values using the given progress (0-1). */
|
|
19
|
+
function interpolate(start, end, progress) {
|
|
20
|
+
return start + (end - start) * progress;
|
|
21
|
+
}
|
|
22
|
+
function easeSine(progress) {
|
|
23
|
+
// Map progress [0..1] to angle [0..π]
|
|
24
|
+
const angle = progress * Math.PI;
|
|
25
|
+
// Use sine to ease in/out
|
|
26
|
+
return (1 - Math.cos(angle)) / 2;
|
|
27
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
20
20
|
const noaa_js_1 = __importDefault(require("./sources/noaa.js"));
|
|
21
21
|
const stormglass_js_1 = __importDefault(require("./sources/stormglass.js"));
|
|
22
22
|
const worldtides_js_1 = __importDefault(require("./sources/worldtides.js"));
|
|
23
|
+
const calculations_js_1 = require("./calculations.js");
|
|
24
|
+
const cache_js_1 = __importDefault(require("./cache.js"));
|
|
23
25
|
module.exports = function (app) {
|
|
24
26
|
// Interval to update tide data
|
|
25
27
|
const defaultPeriod = 60; // 1 hour
|
|
@@ -52,7 +54,7 @@ module.exports = function (app) {
|
|
|
52
54
|
period: {
|
|
53
55
|
title: "Update frequency",
|
|
54
56
|
type: "number",
|
|
55
|
-
description: "How often to update tide
|
|
57
|
+
description: "How often to update tide forecast (minutes)",
|
|
56
58
|
default: 60,
|
|
57
59
|
minimum: 1,
|
|
58
60
|
},
|
|
@@ -65,6 +67,9 @@ module.exports = function (app) {
|
|
|
65
67
|
};
|
|
66
68
|
plugin.start = async function (props) {
|
|
67
69
|
app.debug("Starting tides-api: " + JSON.stringify(props));
|
|
70
|
+
let lastForecast = null;
|
|
71
|
+
let lastPosition = null;
|
|
72
|
+
const cache = new cache_js_1.default(app.getDataDirPath());
|
|
68
73
|
// Use the selected source, or the first one if not specified
|
|
69
74
|
const source = sources.find(source => source.id === props.source) || sources[0];
|
|
70
75
|
// Load the selected source
|
|
@@ -74,7 +79,9 @@ module.exports = function (app) {
|
|
|
74
79
|
type: "tides",
|
|
75
80
|
methods: {
|
|
76
81
|
async listResources(query) {
|
|
77
|
-
|
|
82
|
+
if (!lastPosition)
|
|
83
|
+
throw new Error("No position available");
|
|
84
|
+
return provider({ position: lastPosition, ...query });
|
|
78
85
|
},
|
|
79
86
|
getResource() {
|
|
80
87
|
throw new Error("Not implemented");
|
|
@@ -98,46 +105,23 @@ module.exports = function (app) {
|
|
|
98
105
|
],
|
|
99
106
|
}, unsubscribes, (subscriptionError) => {
|
|
100
107
|
app.error("Error:" + subscriptionError);
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
}, updatePosition);
|
|
109
|
+
async function updatePosition() {
|
|
110
|
+
lastPosition = app.getSelfPath('navigation.position.value') || await cache.get('position') || null;
|
|
111
|
+
if (lastPosition) {
|
|
112
|
+
await cache.set('position', lastPosition);
|
|
113
|
+
await updateForecast();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function updateForecast() {
|
|
117
|
+
if (!lastPosition) {
|
|
118
|
+
app.debug("No position available, cannot fetch tide data");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
113
121
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const nextTide = {};
|
|
118
|
-
extremes.forEach(({ type, value, time }) => {
|
|
119
|
-
// Get the first tide of this type after now
|
|
120
|
-
if (!nextTide[type] && new Date(time) > now) {
|
|
121
|
-
nextTide[type] = { time, value };
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
const delta = {
|
|
125
|
-
context: "vessels." + app.selfId,
|
|
126
|
-
updates: [
|
|
127
|
-
{
|
|
128
|
-
timestamp: now.toISOString(),
|
|
129
|
-
values: Object.entries(nextTide).flatMap(([type, { time, value }]) => {
|
|
130
|
-
return [
|
|
131
|
-
{ path: `environment.tide.height${type}`, value: value },
|
|
132
|
-
{ path: `environment.tide.time${type}`, value: time },
|
|
133
|
-
];
|
|
134
|
-
}),
|
|
135
|
-
},
|
|
136
|
-
],
|
|
137
|
-
};
|
|
138
|
-
app.debug("Sending delta: " + JSON.stringify(delta));
|
|
139
|
-
app.handleMessage(plugin.id, delta);
|
|
140
|
-
app.setPluginStatus("Updated tide data");
|
|
122
|
+
lastForecast = await provider({ position: lastPosition });
|
|
123
|
+
app.setPluginStatus("Updated tide forecast from " + source.title);
|
|
124
|
+
updateTides();
|
|
141
125
|
}
|
|
142
126
|
catch (e) {
|
|
143
127
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -146,8 +130,42 @@ module.exports = function (app) {
|
|
|
146
130
|
app.error(e);
|
|
147
131
|
}
|
|
148
132
|
}
|
|
133
|
+
async function updateTides(now = new Date()) {
|
|
134
|
+
if (!lastForecast)
|
|
135
|
+
return;
|
|
136
|
+
// Get the next two upcoming extremes
|
|
137
|
+
const nextTides = lastForecast.extremes.filter(({ time }) => new Date(time) >= now).slice(0, 2);
|
|
138
|
+
const delta = {
|
|
139
|
+
context: "vessels." + app.selfId,
|
|
140
|
+
updates: [
|
|
141
|
+
{
|
|
142
|
+
timestamp: now.toISOString(),
|
|
143
|
+
values: [
|
|
144
|
+
{
|
|
145
|
+
path: "environment.tide.stationName",
|
|
146
|
+
value: lastForecast.station.name
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
path: "environment.tide.heightNow",
|
|
150
|
+
value: (0, calculations_js_1.approximateTideHeightAt)(lastForecast.extremes, now)
|
|
151
|
+
},
|
|
152
|
+
...nextTides.flatMap(({ type, time, value }) => {
|
|
153
|
+
return [
|
|
154
|
+
{ path: `environment.tide.height${type}`, value },
|
|
155
|
+
{ path: `environment.tide.time${type}`, value: time },
|
|
156
|
+
];
|
|
157
|
+
})
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
app.debug("Sending delta: " + JSON.stringify(delta));
|
|
163
|
+
app.handleMessage(plugin.id, delta);
|
|
164
|
+
}
|
|
149
165
|
// Perform initial update on startup after short delay to allow gnss position to be populated
|
|
150
|
-
delay(4000).then(
|
|
166
|
+
delay(4000).then(updatePosition);
|
|
167
|
+
// Update every minute
|
|
168
|
+
setInterval(updateTides, 60 * 1000);
|
|
151
169
|
};
|
|
152
170
|
function delay(time) {
|
|
153
171
|
return new Promise((resolve) => setTimeout(resolve, time));
|
package/dist/sources/noaa.js
CHANGED
|
@@ -5,9 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.default = default_1;
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
|
-
const promises_1 =
|
|
8
|
+
const promises_1 = require("fs/promises");
|
|
9
9
|
const geolib_1 = require("geolib");
|
|
10
10
|
const moment_1 = __importDefault(require("moment"));
|
|
11
|
+
const cache_js_1 = __importDefault(require("../cache.js"));
|
|
11
12
|
const stationsUrl = `https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=tidepredictions`;
|
|
12
13
|
const dataGetterUrl = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
|
|
13
14
|
const datum = 'MLLW';
|
|
@@ -16,11 +17,12 @@ function default_1(app) {
|
|
|
16
17
|
id: 'noaa',
|
|
17
18
|
title: 'NOAA (US only)',
|
|
18
19
|
async start() {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
const cache = new cache_js_1.default(path_1.default.join(app.getDataDirPath(), 'noaa'));
|
|
21
|
+
const stations = await StationList.load(cache, app);
|
|
22
|
+
// Remove old cache file
|
|
23
|
+
(0, promises_1.unlink)(path_1.default.join(app.config.configPath, 'noaastations.json')).catch(() => { });
|
|
24
|
+
return async (params) => {
|
|
25
|
+
const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
|
|
24
26
|
const station = stations.closestTo(position);
|
|
25
27
|
const endpoint = new URL(dataGetterUrl);
|
|
26
28
|
endpoint.search = new URLSearchParams({
|
|
@@ -71,21 +73,18 @@ function default_1(app) {
|
|
|
71
73
|
}
|
|
72
74
|
;
|
|
73
75
|
class StationList extends Map {
|
|
74
|
-
static async load(app) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
data = JSON.parse(await promises_1.default.readFile(filename, 'utf-8'));
|
|
79
|
-
app.debug("NOAA: Loaded cached tide stations from " + filename);
|
|
76
|
+
static async load(cache, app) {
|
|
77
|
+
let data = await cache.get('stations');
|
|
78
|
+
if (data) {
|
|
79
|
+
app.debug("NOAA: Loaded cached tide stations");
|
|
80
80
|
}
|
|
81
|
-
|
|
82
|
-
app.debug(`NOAA: failed to load cached tide stations: ${e}`);
|
|
81
|
+
else {
|
|
83
82
|
app.debug('NOAA: Downloading tide stations');
|
|
84
83
|
const res = await fetch(stationsUrl);
|
|
85
84
|
if (!res.ok)
|
|
86
85
|
throw new Error(`Failed to download stations: ${res.statusText}`);
|
|
87
86
|
data = await res.json();
|
|
88
|
-
await
|
|
87
|
+
await cache.set('stations', data);
|
|
89
88
|
}
|
|
90
89
|
return new this(data.stations);
|
|
91
90
|
}
|
|
@@ -17,18 +17,14 @@ function default_1(app) {
|
|
|
17
17
|
},
|
|
18
18
|
start({ stormglassApiKey } = {}) {
|
|
19
19
|
app.debug("Using StormGlass.io API");
|
|
20
|
-
return async (
|
|
21
|
-
const { date = (0, moment_1.default)().subtract(1, "days").toISOString() } = params;
|
|
20
|
+
return async ({ position, date = (0, moment_1.default)().subtract(1, "days").toISOString() }) => {
|
|
22
21
|
const endPoint = new URL("https://api.stormglass.io/v2/tide/extremes/point");
|
|
23
|
-
const position = app.getSelfPath("navigation.position.value");
|
|
24
|
-
if (!position)
|
|
25
|
-
throw new Error("no position");
|
|
26
22
|
endPoint.search = new URLSearchParams({
|
|
27
23
|
start: (0, moment_1.default)(date).format("YYYY-MM-DD"),
|
|
28
24
|
end: (0, moment_1.default)(date).add(7, "days").format("YYYY-MM-DD"),
|
|
29
25
|
// datum: "CD",
|
|
30
|
-
lat: position.latitude,
|
|
31
|
-
lng: position.longitude
|
|
26
|
+
lat: position.latitude.toString(),
|
|
27
|
+
lng: position.longitude.toString()
|
|
32
28
|
}).toString();
|
|
33
29
|
app.debug("Fetching StormGlass.io: " + endPoint.toString());
|
|
34
30
|
const res = await fetch(endPoint, {
|
|
@@ -40,7 +36,7 @@ function default_1(app) {
|
|
|
40
36
|
app.debug(JSON.stringify(data, null, 2));
|
|
41
37
|
return {
|
|
42
38
|
station: {
|
|
43
|
-
name: data.meta.station.name
|
|
39
|
+
name: `${data.meta.station.name} (${data.meta.station.source})`,
|
|
44
40
|
position: {
|
|
45
41
|
latitude: data.meta.station.lat,
|
|
46
42
|
longitude: data.meta.station.lng,
|
|
@@ -17,20 +17,17 @@ function default_1(app) {
|
|
|
17
17
|
},
|
|
18
18
|
start({ worldtidesApiKey = '' } = {}) {
|
|
19
19
|
app.debug("Using WorldTides API");
|
|
20
|
-
return async (params
|
|
21
|
-
const { date = (0, moment_1.default)().subtract(1, "days") } = params;
|
|
20
|
+
return async (params) => {
|
|
21
|
+
const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
|
|
22
22
|
const endPoint = new URL("https://www.worldtides.info/api/v3");
|
|
23
|
-
const position = app.getSelfPath("navigation.position.value");
|
|
24
|
-
if (!position)
|
|
25
|
-
throw new Error("no position");
|
|
26
23
|
endPoint.search = new URLSearchParams({
|
|
27
24
|
date: (0, moment_1.default)(date).format("YYYY-MM-DD"),
|
|
28
25
|
datum: "CD",
|
|
29
26
|
days: "7",
|
|
30
27
|
extremes: "true",
|
|
31
28
|
key: worldtidesApiKey,
|
|
32
|
-
lat: position.latitude,
|
|
33
|
-
lon: position.longitude,
|
|
29
|
+
lat: position.latitude.toString(),
|
|
30
|
+
lon: position.longitude.toString(),
|
|
34
31
|
}).toString();
|
|
35
32
|
app.debug("Fetching worldtides: " + endPoint.toString());
|
|
36
33
|
const res = await fetch(endPoint);
|
|
@@ -42,7 +39,7 @@ function default_1(app) {
|
|
|
42
39
|
throw new Error("worldtides data: " + data.error ? data.error : "none");
|
|
43
40
|
return {
|
|
44
41
|
station: {
|
|
45
|
-
name:
|
|
42
|
+
name: `${data.station} (${data.atlas})`,
|
|
46
43
|
position: {
|
|
47
44
|
latitude: data.responseLat,
|
|
48
45
|
longitude: data.responseLon,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-tides",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Tidal predictions for the vessel's position from various online sources.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -38,24 +38,22 @@
|
|
|
38
38
|
"@eslint/js": "^9.21.0",
|
|
39
39
|
"@tailwindcss/vite": "^4.1.3",
|
|
40
40
|
"@types/d3": "^7.4.3",
|
|
41
|
-
"@types/node": "^
|
|
41
|
+
"@types/node": "^24.3.1",
|
|
42
42
|
"@types/react": "^19.0.12",
|
|
43
43
|
"@types/react-dom": "^19.0.4",
|
|
44
|
-
"@
|
|
45
|
-
"@vitejs/plugin-react": "^4.3.4",
|
|
44
|
+
"@vitejs/plugin-react": "^5.0.2",
|
|
46
45
|
"d3": "^7.9.0",
|
|
47
46
|
"eslint": "^9.21.0",
|
|
48
47
|
"eslint-plugin-react-hooks": "^5.1.0",
|
|
49
48
|
"eslint-plugin-react-refresh": "^0.4.19",
|
|
50
|
-
"globals": "^
|
|
49
|
+
"globals": "^16.3.0",
|
|
51
50
|
"react": "^19.1.0",
|
|
52
51
|
"react-dom": "^19.1.0",
|
|
53
|
-
"recharts": "^2.15.1",
|
|
54
52
|
"signalk-server": "^2.13.5",
|
|
55
53
|
"tailwindcss": "^4.1.3",
|
|
56
54
|
"typescript": "^5.8.2",
|
|
57
55
|
"typescript-eslint": "^8.24.1",
|
|
58
|
-
"vite": "^
|
|
56
|
+
"vite": "^7.1.5"
|
|
59
57
|
},
|
|
60
58
|
"repository": {
|
|
61
59
|
"type": "git",
|