signalk-tides 1.4.0 → 1.5.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.
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push: {}
5
+
6
+ jobs:
7
+ lint:
8
+ name: Lint
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout repository
12
+ uses: actions/checkout@v6
13
+
14
+ - name: Setup Node.js
15
+ uses: actions/setup-node@v6
16
+
17
+ - name: Install dependencies
18
+ run: npm install
19
+
20
+ - name: Run linter
21
+ run: npm run lint
22
+
23
+ build:
24
+ name: Build
25
+ runs-on: ubuntu-latest
26
+ env:
27
+ CI: true
28
+ steps:
29
+ - name: Checkout repository
30
+ uses: actions/checkout@v6
31
+
32
+ - name: Setup Node.js
33
+ uses: actions/setup-node@v6
34
+
35
+ - name: Install dependencies
36
+ run: npm install
37
+
38
+ - name: Run build
39
+ run: npm run build
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # signalk-tides
2
2
 
3
- A SignalK plugin that provides tidal predictions for the vessel's position from various online sources.
3
+ A SignalK plugin that provides tidal predictions for the vessel's position from various [sources](#sources).
4
4
 
5
5
  ## Installation
6
6
 
@@ -206,9 +206,18 @@ $ curl http://localhost:3000/signalk/v2/api/resources/tides
206
206
 
207
207
  ## Sources
208
208
 
209
- - [NOAA](https://tidesandcurrents.noaa.gov/web_services_info.html) (US only)
210
- - [WorldTides API](https://www.worldtides.info/) (requires an API key)
211
- - [StormGlass.io](https://stormglass.io/) (requires an API key)
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
212
221
 
213
222
  ## License
214
223
 
package/dist/cache.js CHANGED
@@ -1,28 +1,25 @@
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 {
1
+ import { join } from "path";
2
+ import { writeFile, readFile, mkdir } from "fs/promises";
3
+ export default class FileCache {
6
4
  path;
7
5
  constructor(path) {
8
6
  this.path = path;
9
7
  }
10
8
  async get(key) {
11
9
  try {
12
- const content = await (0, promises_1.readFile)(this.getKey(key), 'utf-8');
10
+ const content = await readFile(this.getKey(key), "utf-8");
13
11
  return JSON.parse(content);
14
12
  }
15
- catch (err) {
13
+ catch {
16
14
  return undefined;
17
15
  }
18
16
  }
19
17
  async set(key, value) {
20
18
  // 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));
19
+ await mkdir(this.path, { recursive: true });
20
+ return writeFile(this.getKey(key), JSON.stringify(value));
23
21
  }
24
22
  getKey(key) {
25
- return (0, path_1.join)(this.path, `${key}.json`);
23
+ return join(this.path, `${key}.json`);
26
24
  }
27
25
  }
28
- exports.default = FileCache;
@@ -1,8 +1,5 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.approximateTideHeightAt = approximateTideHeightAt;
4
1
  /** Given a list of tide extremes, estimate the height at a specific time. */
5
- function approximateTideHeightAt(extremes, time) {
2
+ export function approximateTideHeightAt(extremes, time) {
6
3
  const sorted = extremes.slice().sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
7
4
  const prev = sorted.filter(h => new Date(h.time) <= time).at(-1);
8
5
  const next = sorted.filter(h => new Date(h.time) >= time).at(0);
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /*
3
2
  * Copyright 2017 Scott Bender <scott@scottbender.net> and Joachim Bakke
4
3
  * Copyright 2025 Brandon Keepers <brandon@opensoul.org>
@@ -14,27 +13,17 @@
14
13
  * See the License for the specific language governing permissions and
15
14
  * limitations under the License.
16
15
  */
17
- var __importDefault = (this && this.__importDefault) || function (mod) {
18
- return (mod && mod.__esModule) ? mod : { "default": mod };
19
- };
20
- const noaa_js_1 = __importDefault(require("./sources/noaa.js"));
21
- const stormglass_js_1 = __importDefault(require("./sources/stormglass.js"));
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"));
25
- module.exports = function (app) {
16
+ import { approximateTideHeightAt } from "./calculations.js";
17
+ import FileCache from "./cache.js";
18
+ import createSources from "./sources/index.js";
19
+ export default function (app) {
26
20
  // Interval to update tide data
27
21
  const defaultPeriod = 60; // 1 hour
28
22
  let unsubscribes = [];
29
- const sources = [
30
- (0, noaa_js_1.default)(app),
31
- (0, stormglass_js_1.default)(app),
32
- (0, worldtides_js_1.default)(app),
33
- ];
23
+ const sources = createSources(app);
34
24
  const plugin = {
35
25
  id: "tides",
36
26
  name: "Tides",
37
- // @ts-expect-error: TODO[TS]: fix Plugin type upstream
38
27
  description: "Tidal predictions for the vessel's position from various online sources.",
39
28
  schema: () => ({
40
29
  title: "Tides API",
@@ -43,9 +32,9 @@ module.exports = function (app) {
43
32
  source: {
44
33
  title: "Data source",
45
34
  type: "string",
46
- "anyOf": sources.map(({ id, title }) => ({
35
+ anyOf: sources.map(({ id, title }) => ({
47
36
  const: id,
48
- title
37
+ title,
49
38
  })),
50
39
  default: sources[0].id,
51
40
  },
@@ -58,20 +47,21 @@ module.exports = function (app) {
58
47
  default: 60,
59
48
  minimum: 1,
60
49
  },
61
- }
50
+ },
62
51
  }),
52
+ start,
63
53
  stop() {
64
54
  unsubscribes.forEach((f) => f());
65
55
  unsubscribes = [];
66
- }
56
+ },
67
57
  };
68
- plugin.start = async function (props) {
58
+ async function start(props) {
69
59
  app.debug("Starting tides-api: " + JSON.stringify(props));
70
60
  let lastForecast = null;
71
61
  let lastPosition = null;
72
- const cache = new cache_js_1.default(app.getDataDirPath());
62
+ const cache = new FileCache(app.getDataDirPath());
73
63
  // Use the selected source, or the first one if not specified
74
- const source = sources.find(source => source.id === props.source) || sources[0];
64
+ const source = sources.find((source) => source.id === props.source) || sources[0];
75
65
  // Load the selected source
76
66
  const provider = await source.start(props);
77
67
  // Register the source as a resource provider
@@ -91,11 +81,11 @@ module.exports = function (app) {
91
81
  },
92
82
  deleteResource() {
93
83
  throw new Error("Not implemented");
94
- }
95
- }
84
+ },
85
+ },
96
86
  });
97
87
  app.subscriptionmanager.subscribe({
98
- context: "vessels." + app.selfId,
88
+ context: ("vessels." + app.selfId),
99
89
  subscribe: [
100
90
  {
101
91
  path: "navigation.position",
@@ -107,9 +97,10 @@ module.exports = function (app) {
107
97
  app.error("Error:" + subscriptionError);
108
98
  }, updatePosition);
109
99
  async function updatePosition() {
110
- lastPosition = app.getSelfPath('navigation.position.value') || await cache.get('position') || null;
100
+ lastPosition =
101
+ app.getSelfPath("navigation.position.value") || (await cache.get("position")) || null;
111
102
  if (lastPosition) {
112
- await cache.set('position', lastPosition);
103
+ await cache.set("position", lastPosition);
113
104
  await updateForecast();
114
105
  }
115
106
  }
@@ -134,28 +125,30 @@ module.exports = function (app) {
134
125
  if (!lastForecast)
135
126
  return;
136
127
  // Get the next two upcoming extremes
137
- const nextTides = lastForecast.extremes.filter(({ time }) => new Date(time) >= now).slice(0, 2);
128
+ const nextTides = lastForecast.extremes
129
+ .filter(({ time }) => new Date(time) >= now)
130
+ .slice(0, 2);
138
131
  const delta = {
139
- context: "vessels." + app.selfId,
132
+ context: ("vessels." + app.selfId),
140
133
  updates: [
141
134
  {
142
135
  timestamp: now.toISOString(),
143
136
  values: [
144
137
  {
145
138
  path: "environment.tide.stationName",
146
- value: lastForecast.station.name
139
+ value: lastForecast.station.name,
147
140
  },
148
141
  {
149
142
  path: "environment.tide.heightNow",
150
- value: (0, calculations_js_1.approximateTideHeightAt)(lastForecast.extremes, now)
143
+ value: approximateTideHeightAt(lastForecast.extremes, now),
151
144
  },
152
145
  ...nextTides.flatMap(({ type, time, value }) => {
153
146
  return [
154
147
  { path: `environment.tide.height${type}`, value },
155
148
  { path: `environment.tide.time${type}`, value: time },
156
149
  ];
157
- })
158
- ]
150
+ }),
151
+ ],
159
152
  },
160
153
  ],
161
154
  };
@@ -166,9 +159,9 @@ module.exports = function (app) {
166
159
  delay(4000).then(updatePosition);
167
160
  // Update every minute
168
161
  setInterval(updateTides, 60 * 1000);
169
- };
162
+ }
170
163
  function delay(time) {
171
164
  return new Promise((resolve) => setTimeout(resolve, time));
172
165
  }
173
166
  return plugin;
174
- };
167
+ }
@@ -0,0 +1,7 @@
1
+ import neaps from "./neaps.js";
2
+ import noaa from "./noaa.js";
3
+ import stormglass from "./stormglass.js";
4
+ import worldtides from "./worldtides.js";
5
+ export default function createSources(app) {
6
+ return [neaps, noaa, stormglass, worldtides].map(s => s(app));
7
+ }
@@ -0,0 +1,39 @@
1
+ import { getExtremesPrediction } from "neaps";
2
+ import moment from 'moment';
3
+ export default function (app) {
4
+ return {
5
+ id: 'neaps',
6
+ title: 'Neaps',
7
+ properties: {},
8
+ start() {
9
+ app.debug("Using Neaps");
10
+ return async ({ position, date = moment().subtract(1, "days").toISOString() }) => {
11
+ const { station, extremes } = getExtremesPrediction({
12
+ ...position,
13
+ start: new Date(date),
14
+ end: moment(date).add(7, "days").toDate(),
15
+ labels: {
16
+ high: "High",
17
+ low: "Low"
18
+ }
19
+ });
20
+ return {
21
+ station: {
22
+ name: station.name,
23
+ position: {
24
+ latitude: station.latitude,
25
+ longitude: station.longitude,
26
+ },
27
+ },
28
+ extremes: extremes.map(({ time, level, label }) => {
29
+ return {
30
+ type: label,
31
+ value: level,
32
+ time: time.toISOString(),
33
+ };
34
+ }),
35
+ };
36
+ };
37
+ }
38
+ };
39
+ }
@@ -1,35 +1,32 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.default = default_1;
7
- const path_1 = __importDefault(require("path"));
8
- const promises_1 = require("fs/promises");
9
- const geolib_1 = require("geolib");
10
- const moment_1 = __importDefault(require("moment"));
11
- const cache_js_1 = __importDefault(require("../cache.js"));
1
+ import path from "path";
2
+ import { unlink } from "fs/promises";
3
+ import { getDistance } from "geolib";
4
+ import moment from "moment";
5
+ import FileCache from "../cache.js";
12
6
  const stationsUrl = `https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=tidepredictions`;
13
- const dataGetterUrl = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
14
- const datum = 'MLLW';
15
- function default_1(app) {
7
+ const dataGetterUrl = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter";
8
+ const datum = "MLLW";
9
+ export default function (app) {
16
10
  return {
17
- id: 'noaa',
18
- title: 'NOAA (US only)',
11
+ id: "noaa",
12
+ title: "NOAA (US only)",
19
13
  async start() {
20
- const cache = new cache_js_1.default(path_1.default.join(app.getDataDirPath(), 'noaa'));
14
+ const cache = new FileCache(path.join(app.getDataDirPath(), "noaa"));
21
15
  const stations = await StationList.load(cache, app);
22
16
  // Remove old cache file
23
- (0, promises_1.unlink)(path_1.default.join(app.config.configPath, 'noaastations.json')).catch(() => { });
17
+ // @ts-expect-error: configPath exists, just not part of the types
18
+ unlink(path.join(app.config.configPath, "noaastations.json")).catch(() => {
19
+ /* ignore */
20
+ });
24
21
  return async (params) => {
25
- const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
22
+ const { position, date = moment().subtract(1, "days") } = params;
26
23
  const station = stations.closestTo(position);
27
24
  const endpoint = new URL(dataGetterUrl);
28
25
  endpoint.search = new URLSearchParams({
29
26
  product: "predictions",
30
27
  application: "signalk.org/node-server",
31
- begin_date: (0, moment_1.default)(date).format("YYYYMMDD"),
32
- end_date: (0, moment_1.default)(date).add(7, "days").format("YYYYMMDD"),
28
+ begin_date: moment(date).format("YYYYMMDD"),
29
+ end_date: moment(date).add(7, "days").format("YYYYMMDD"),
33
30
  datum,
34
31
  station: station.id,
35
32
  time_zone: "gmt",
@@ -42,7 +39,7 @@ function default_1(app) {
42
39
  const res = await fetch(endpoint.toString());
43
40
  if (!res.ok)
44
41
  throw new Error("Failed to fetch NOAA tides: " + res.statusText);
45
- const body = await res.json();
42
+ const body = (await res.json());
46
43
  app.debug("NOAA response: \n" + JSON.stringify(body, null, 2));
47
44
  if (body.error)
48
45
  throw new Error(body.error.message);
@@ -68,23 +65,22 @@ function default_1(app) {
68
65
  throw err;
69
66
  }
70
67
  };
71
- }
68
+ },
72
69
  };
73
70
  }
74
- ;
75
71
  class StationList extends Map {
76
72
  static async load(cache, app) {
77
- let data = await cache.get('stations');
73
+ let data = (await cache.get("stations"));
78
74
  if (data) {
79
75
  app.debug("NOAA: Loaded cached tide stations");
80
76
  }
81
77
  else {
82
- app.debug('NOAA: Downloading tide stations');
78
+ app.debug("NOAA: Downloading tide stations");
83
79
  const res = await fetch(stationsUrl);
84
80
  if (!res.ok)
85
81
  throw new Error(`Failed to download stations: ${res.statusText}`);
86
- data = await res.json();
87
- await cache.set('stations', data);
82
+ data = (await res.json());
83
+ await cache.set("stations", data);
88
84
  }
89
85
  return new this(data.stations);
90
86
  }
@@ -97,8 +93,13 @@ class StationList extends Map {
97
93
  near(position, limit = 10) {
98
94
  const stationsWithDistances = Array.from(this.values()).map((station) => ({
99
95
  ...station,
100
- distance: (0, geolib_1.getDistance)(position, { latitude: station.lat, longitude: station.lng })
96
+ distance: getDistance(position, {
97
+ latitude: station.lat,
98
+ longitude: station.lng,
99
+ }),
101
100
  }));
102
- return stationsWithDistances.sort((a, b) => a.distance - b.distance).slice(0, limit);
101
+ return stationsWithDistances
102
+ .sort((a, b) => a.distance - b.distance)
103
+ .slice(0, limit);
103
104
  }
104
105
  }
@@ -1,11 +1,5 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.default = default_1;
7
- const moment_1 = __importDefault(require("moment"));
8
- function default_1(app) {
1
+ import moment from 'moment';
2
+ export default function (app) {
9
3
  return {
10
4
  id: 'stormglass',
11
5
  title: 'StormGlass.io',
@@ -17,11 +11,11 @@ function default_1(app) {
17
11
  },
18
12
  start({ stormglassApiKey } = {}) {
19
13
  app.debug("Using StormGlass.io API");
20
- return async ({ position, date = (0, moment_1.default)().subtract(1, "days").toISOString() }) => {
14
+ return async ({ position, date = moment().subtract(1, "days").toISOString() }) => {
21
15
  const endPoint = new URL("https://api.stormglass.io/v2/tide/extremes/point");
22
16
  endPoint.search = new URLSearchParams({
23
- start: (0, moment_1.default)(date).format("YYYY-MM-DD"),
24
- end: (0, moment_1.default)(date).add(7, "days").format("YYYY-MM-DD"),
17
+ start: moment(date).format("YYYY-MM-DD"),
18
+ end: moment(date).add(7, "days").format("YYYY-MM-DD"),
25
19
  // datum: "CD",
26
20
  lat: position.latitude.toString(),
27
21
  lng: position.longitude.toString()
@@ -1,11 +1,5 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.default = default_1;
7
- const moment_1 = __importDefault(require("moment"));
8
- function default_1(app) {
1
+ import moment from 'moment';
2
+ export default function (app) {
9
3
  return {
10
4
  id: 'WorldTides.info',
11
5
  title: 'worldtides.info',
@@ -18,10 +12,10 @@ function default_1(app) {
18
12
  start({ worldtidesApiKey = '' } = {}) {
19
13
  app.debug("Using WorldTides API");
20
14
  return async (params) => {
21
- const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
15
+ const { position, date = moment().subtract(1, "days") } = params;
22
16
  const endPoint = new URL("https://www.worldtides.info/api/v3");
23
17
  endPoint.search = new URLSearchParams({
24
- date: (0, moment_1.default)(date).format("YYYY-MM-DD"),
18
+ date: moment(date).format("YYYY-MM-DD"),
25
19
  datum: "CD",
26
20
  days: "7",
27
21
  extremes: "true",
package/dist/types.js CHANGED
@@ -1,2 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
1
+ export {};
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "signalk-tides",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Tidal predictions for the vessel's position from various online sources.",
5
+ "type": "module",
5
6
  "main": "./dist/index.js",
7
+ "exports": "./dist/index.js",
6
8
  "scripts": {
7
9
  "build": "tsc -b && vite build",
8
10
  "dev": "vite",
@@ -29,22 +31,23 @@
29
31
  ],
30
32
  "license": "Apache-2.0",
31
33
  "dependencies": {
32
- "@react-hook/resize-observer": "^2.0.2",
33
34
  "@signalk/server-api": "^2.0",
34
35
  "geolib": "^3.3.4",
35
- "moment": "^2.30.1"
36
+ "moment": "^2.30.1",
37
+ "neaps": "^0.2.0"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@eslint/js": "^9.21.0",
41
+ "@react-hook/resize-observer": "^2.0.2",
39
42
  "@tailwindcss/vite": "^4.1.3",
40
43
  "@types/d3": "^7.4.3",
41
- "@types/node": "^24.3.1",
44
+ "@types/node": "^25.0.3",
42
45
  "@types/react": "^19.0.12",
43
46
  "@types/react-dom": "^19.0.4",
44
47
  "@vitejs/plugin-react": "^5.0.2",
45
48
  "d3": "^7.9.0",
46
49
  "eslint": "^9.21.0",
47
- "eslint-plugin-react-hooks": "^5.1.0",
50
+ "eslint-plugin-react-hooks": "^7.0.1",
48
51
  "eslint-plugin-react-refresh": "^0.4.19",
49
52
  "globals": "^16.3.0",
50
53
  "react": "^19.1.0",