signalk-tides 2.2.1 → 2.3.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.
@@ -20,6 +20,21 @@ jobs:
20
20
  node-version-file: .github/mise.node24.toml # npm >= 11.5.1 for trusted publishing
21
21
  registry-url: https://registry.npmjs.org
22
22
 
23
+ # Before anything installs, so a preview build is neither run nor published.
24
+ # Overrides don't reach people installing the plugin, so these specs are
25
+ # what they get.
26
+ - name: Refuse dependencies from outside the registry
27
+ run: |
28
+ node -e '
29
+ const { dependencies = {}, optionalDependencies = {} } = require("./package.json");
30
+ const outside = Object.entries({ ...dependencies, ...optionalDependencies })
31
+ .filter(([, spec]) => /[:/]/.test(spec));
32
+ if (outside.length) {
33
+ console.error("Not from the registry:", Object.fromEntries(outside));
34
+ process.exit(1);
35
+ }
36
+ '
37
+
23
38
  - name: Install dependencies
24
39
  run: npm install
25
40
 
package/README.md CHANGED
@@ -21,12 +21,13 @@ 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.stationDistance` — metres from the vessel to the station, when it was chosen by position
24
25
  * `environment.tide.state` — tide trend, `rising` or `falling`
25
26
  * `environment.tide.timeToNextExtreme` — seconds until the next high or low water
26
27
 
27
28
  ### Tides API
28
29
 
29
- The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) 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
+ The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) 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 with tide predictions within 100 km when none is set or the configured one has no predictions. With none in range, the plugin publishes no tide and says so in its status, and the API answers 404:
30
31
 
31
32
  ```
32
33
  $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
@@ -1,3 +1,23 @@
1
+ /**
2
+ * How far from the vessel a station may be and still stand in for its tide. Past
3
+ * this a station gives a plausible tide for other water, so the plugin publishes
4
+ * none. Half of the CHS positions with no model have a station that predicts
5
+ * within 28 km, and 184 of 1,057 have none within 100 km. This project's
6
+ * definition, not a published figure.
7
+ */
8
+ export const MAX_STATION_KM = 100;
9
+ /** Whether a location lookup found nothing in range. */
10
+ export function isNoStationNearby(e) {
11
+ return e instanceof Error && e.message.startsWith("No stations found");
12
+ }
13
+ /** Whether a lookup failed on the id, rather than on loading the database. */
14
+ export function isStationNotFound(e) {
15
+ return e instanceof Error && e.message.startsWith("Station not found");
16
+ }
17
+ // CHS stations carry identity only, and predicting from one gives a flat zero tide.
18
+ export function hasTideModel(station) {
19
+ return station.harmonic_constituents.length > 0 || station.offsets !== undefined;
20
+ }
1
21
  /**
2
22
  * Trend of the tide at `time`: "rising" if the next extreme is a High,
3
23
  * "falling" if it is a Low.
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { createRoutes } from "@slackwater/api";
17
17
  import { findStation, nearestStation, stationsNear } from "slackwater";
18
- import { tideStateAt, timeToNextExtreme } from "./calculations.js";
18
+ import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, tideStateAt, timeToNextExtreme, } from "./calculations.js";
19
19
  import FileCache from "./cache.js";
20
20
  import { withVesselPosition } from "./middleware.js";
21
21
  import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-extension.js";
@@ -23,6 +23,17 @@ import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-exten
23
23
  // by slackwater, so this is cheap and needs no configuration.
24
24
  const UPDATE_INTERVAL = 60 * 1000; // 1 minute
25
25
  const API_PATH = "/signalk/v2/api/tides";
26
+ const TIDE_PATHS = [
27
+ "environment.tide.stationName",
28
+ "environment.tide.stationDistance",
29
+ "environment.tide.heightNow",
30
+ "environment.tide.state",
31
+ "environment.tide.timeToNextExtreme",
32
+ "environment.tide.heightHigh",
33
+ "environment.tide.timeHigh",
34
+ "environment.tide.heightLow",
35
+ "environment.tide.timeLow",
36
+ ];
26
37
  // Tide heights are water levels, so clients should show them in the user's
27
38
  // depth unit. Without a category the server picks one from the unit alone, and
28
39
  // for meters that is distance: kilometers or miles, depending on the preset.
@@ -50,7 +61,8 @@ export default function (app) {
50
61
  id: "tides",
51
62
  name: "Tides",
52
63
  description: "Offline tidal predictions for the vessel's position, powered by Slackwater.",
53
- schema: () => ({
64
+ // signalk-server awaits this, but a rejection would fail its whole plugin list
65
+ schema: async () => ({
54
66
  title: "Tides",
55
67
  type: "object",
56
68
  properties: {
@@ -59,7 +71,7 @@ export default function (app) {
59
71
  title: "Default tide station",
60
72
  description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
61
73
  default: "auto",
62
- oneOf: defaultStationOptions(),
74
+ oneOf: await defaultStationOptions(),
63
75
  },
64
76
  },
65
77
  }),
@@ -70,11 +82,20 @@ export default function (app) {
70
82
  activeRouter = null;
71
83
  },
72
84
  };
73
- function defaultStationOptions() {
85
+ async function defaultStationOptions() {
74
86
  const options = [{ const: "auto", title: "Closest station" }];
75
87
  if (lastPosition) {
76
- for (const station of stationsNear({ ...lastPosition, maxResults: 10 })) {
77
- options.push({ const: station.id, title: stationTitle(station) });
88
+ try {
89
+ const nearby = await stationsNear({
90
+ ...lastPosition,
91
+ maxResults: 10,
92
+ });
93
+ for (const station of nearby) {
94
+ options.push({ const: station.id, title: stationTitle(station) });
95
+ }
96
+ }
97
+ catch (e) {
98
+ app.error(`Could not list nearby tide stations: ${errorMessage(e)}`);
78
99
  }
79
100
  }
80
101
  // Keep the saved station selectable even when it is not in the nearby
@@ -84,10 +105,16 @@ export default function (app) {
84
105
  if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
85
106
  let title = saved;
86
107
  try {
87
- title = stationTitle(findStation(saved));
108
+ const station = await findStation(saved);
109
+ title = hasTideModel(station)
110
+ ? stationTitle(station)
111
+ : `${stationTitle(station)} (no tide predictions)`;
88
112
  }
89
- catch {
113
+ catch (e) {
90
114
  // Unknown id: keep the raw value as the label
115
+ if (!isStationNotFound(e)) {
116
+ app.error(`Could not load saved tide station: ${errorMessage(e)}`);
117
+ }
91
118
  }
92
119
  options.splice(1, 0, { const: saved, title });
93
120
  }
@@ -121,7 +148,18 @@ export default function (app) {
121
148
  return undefined;
122
149
  }
123
150
  }
124
- async function start(options) {
151
+ // Not async: signalk-server only rolls back a start() that throws synchronously,
152
+ // and that rollback does not call stop().
153
+ function start(options) {
154
+ try {
155
+ startPublishing(options);
156
+ }
157
+ catch (e) {
158
+ plugin.stop();
159
+ throw e;
160
+ }
161
+ }
162
+ function startPublishing(options) {
125
163
  app.debug("Starting tides");
126
164
  config = (options ?? {});
127
165
  void suggestDepthUnits();
@@ -129,6 +167,13 @@ export default function (app) {
129
167
  // extremes and the current height are always read from the same station.
130
168
  let lastForecast = null;
131
169
  let lastPredictor = null;
170
+ let latestUpdate = 0;
171
+ let stopped = false;
172
+ let tidesCleared = false;
173
+ let positionWrite = Promise.resolve();
174
+ unsubscribes.push(() => {
175
+ stopped = true;
176
+ });
132
177
  const cache = new FileCache(app.getDataDirPath());
133
178
  // Mount the Slackwater API, with vessel/default resolved to the configured
134
179
  // default station (or the nearest one).
@@ -143,7 +188,7 @@ export default function (app) {
143
188
  type: "tides",
144
189
  methods: {
145
190
  async listResources() {
146
- const predictor = resolvePredictor();
191
+ const predictor = await resolvePredictor();
147
192
  if (!predictor) {
148
193
  throw new Error("No position or default station available");
149
194
  }
@@ -193,25 +238,51 @@ export default function (app) {
193
238
  }, unsubscribes, (subscriptionError) => {
194
239
  app.error("Error:" + subscriptionError);
195
240
  }, updatePosition);
196
- function resolvePredictor() {
241
+ async function resolvePredictor(report = reportLookupFailure) {
242
+ let failed = false;
197
243
  if (config.defaultStation && config.defaultStation !== "auto") {
198
244
  try {
199
- return findStation(config.defaultStation);
245
+ const station = await findStation(config.defaultStation);
246
+ if (hasTideModel(station))
247
+ return station;
248
+ throw new Error("Station has no tide predictions");
200
249
  }
201
- catch {
202
- app.error(`Configured tide station not found: ${config.defaultStation}`);
250
+ catch (e) {
251
+ failed = true;
252
+ report(`Could not load tide station ${config.defaultStation}: ${errorMessage(e)}`);
203
253
  }
204
254
  }
205
255
  if (lastPosition) {
206
256
  try {
207
- return nearestStation(lastPosition);
257
+ return await nearestStation({
258
+ ...lastPosition,
259
+ maxDistance: MAX_STATION_KM,
260
+ });
208
261
  }
209
- catch {
210
- app.error("No tide station found near vessel position");
262
+ catch (e) {
263
+ if (isNoStationNearby(e)) {
264
+ // A status would replace the error about the saved station
265
+ if (!stopped && !failed) {
266
+ app.setPluginStatus(`No tide station within ${MAX_STATION_KM} km of the vessel`);
267
+ }
268
+ return null;
269
+ }
270
+ report(`Could not find a tide station near vessel position: ${errorMessage(e)}`);
211
271
  }
212
272
  }
213
273
  return null;
214
274
  }
275
+ function reportLookupFailure(message) {
276
+ if (stopped)
277
+ return;
278
+ app.error(message);
279
+ app.setPluginError(message);
280
+ }
281
+ function forecastStatus({ station, distance }) {
282
+ return distance === undefined
283
+ ? "Updated tide forecast"
284
+ : `Updated tide forecast from ${station.name}, ${Math.round(distance)} km away`;
285
+ }
215
286
  function forecastFor(predictor) {
216
287
  const now = new Date();
217
288
  return predictor.getExtremesPrediction({
@@ -219,32 +290,63 @@ export default function (app) {
219
290
  end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
220
291
  });
221
292
  }
293
+ // Runs from a timer and a delta callback, which both drop its promise, so
294
+ // it must never reject.
222
295
  async function updatePosition() {
223
- const newPosition = app.getSelfPath("navigation.position.value");
224
- // New position received, save it to the cache
225
- if (newPosition) {
226
- lastPosition = newPosition;
227
- await cache.set("position", lastPosition);
296
+ try {
297
+ const newPosition = app.getSelfPath("navigation.position.value");
298
+ // New position received, save it to the cache
299
+ if (newPosition) {
300
+ lastPosition = newPosition;
301
+ // Writes run in turn and each saves the newest position, so an older
302
+ // one can't land last
303
+ positionWrite = positionWrite
304
+ .catch(() => { })
305
+ .then(() => cache.set("position", lastPosition));
306
+ await positionWrite;
307
+ }
308
+ // No last known position, try to load from cache.
309
+ if (!lastPosition) {
310
+ const cached = (await cache.get("position"));
311
+ // A delta may have set a newer position during the read
312
+ lastPosition ??= cached ?? null;
313
+ }
228
314
  }
229
- // No last known position, try to load from cache.
230
- if (!lastPosition) {
231
- lastPosition = (await cache.get("position"));
315
+ catch (e) {
316
+ app.error(`Could not update cached position: ${errorMessage(e)}`);
232
317
  }
233
318
  // A configured default station predicts without a position
234
- updateForecast();
319
+ await updateForecast();
235
320
  }
236
- function updateForecast() {
237
- const predictor = resolvePredictor();
238
- if (!predictor) {
239
- app.debug("No position or default station available, cannot compute tide data");
240
- lastForecast = null;
241
- lastPredictor = null;
242
- return;
243
- }
321
+ async function updateForecast() {
322
+ const update = ++latestUpdate;
244
323
  try {
324
+ let lookupFailed = false;
325
+ const predictor = await resolvePredictor((message) => {
326
+ lookupFailed = true;
327
+ reportLookupFailure(message);
328
+ });
329
+ // The first lookup waits for the database, so a newer update or stop()
330
+ // can overtake this one.
331
+ if (stopped || update !== latestUpdate)
332
+ return;
333
+ if (!predictor) {
334
+ app.debug("No position or default station available, cannot compute tide data");
335
+ // Once per loss, and once after a restart, which can't tell what the
336
+ // run before it published
337
+ if (!tidesCleared)
338
+ clearTides();
339
+ tidesCleared = true;
340
+ lastForecast = null;
341
+ lastPredictor = null;
342
+ return;
343
+ }
245
344
  lastForecast = forecastFor(predictor);
246
345
  lastPredictor = predictor;
247
- app.setPluginStatus("Updated tide forecast");
346
+ tidesCleared = false;
347
+ // A fallback keeps the lookup's error showing
348
+ if (!lookupFailed)
349
+ app.setPluginStatus(forecastStatus(lastForecast));
248
350
  updateTides();
249
351
  }
250
352
  catch (e) {
@@ -254,6 +356,19 @@ export default function (app) {
254
356
  app.error(e);
255
357
  }
256
358
  }
359
+ // Signal K keeps a path's last value, so a tide that no longer applies has
360
+ // to be cleared or it stays on displays, frozen.
361
+ function clearTides() {
362
+ app.handleMessage(plugin.id, {
363
+ context: ("vessels." + app.selfId),
364
+ updates: [
365
+ {
366
+ timestamp: new Date().toISOString(),
367
+ values: TIDE_PATHS.map((path) => ({ path, value: null })),
368
+ },
369
+ ],
370
+ });
371
+ }
257
372
  function updateTides(now = new Date()) {
258
373
  if (!lastForecast || !lastPredictor)
259
374
  return;
@@ -274,6 +389,13 @@ export default function (app) {
274
389
  path: "environment.tide.stationName",
275
390
  value: lastForecast.station.name,
276
391
  },
392
+ // Null for a configured station, which isn't chosen by distance
393
+ {
394
+ path: "environment.tide.stationDistance",
395
+ value: lastForecast.distance === undefined
396
+ ? null
397
+ : lastForecast.distance * 1000,
398
+ },
277
399
  {
278
400
  path: "environment.tide.heightNow",
279
401
  value: heightNow,
@@ -314,6 +436,13 @@ export default function (app) {
314
436
  description: "Tide trend at the current position: rising or falling",
315
437
  },
316
438
  },
439
+ {
440
+ path: "environment.tide.stationDistance",
441
+ value: {
442
+ units: "m",
443
+ description: "Distance from the vessel to the station it was given",
444
+ },
445
+ },
317
446
  {
318
447
  path: "environment.tide.timeToNextExtreme",
319
448
  value: {
@@ -339,6 +468,9 @@ export default function (app) {
339
468
  }
340
469
  return plugin;
341
470
  }
471
+ function errorMessage(e) {
472
+ return e instanceof Error ? e.message : String(e);
473
+ }
342
474
  function stationTitle(station) {
343
475
  const where = [station.region, station.country].filter(Boolean).join(", ");
344
476
  const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";
@@ -1,78 +1,103 @@
1
1
  import { findStation, nearestStation } from "slackwater";
2
+ import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, } from "./calculations.js";
3
+ const nearestToVessel = ({ latitude, longitude }) => nearestStation({
4
+ latitude,
5
+ longitude,
6
+ maxDistance: MAX_STATION_KM,
7
+ });
2
8
  /**
3
9
  * Middleware that resolves the vessel/default station alias and bare
4
10
  * location-based queries (/extremes, /timeline) against the configured default
5
11
  * station, falling back to the station nearest the vessel's position when no
6
- * default is set.
12
+ * default is set or it can't be used.
7
13
  */
8
14
  export function withVesselPosition(router, getPosition, getDefaultStationId) {
9
- return (req, res, next) => {
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);
31
- const pos = getPosition();
32
- if (!pos) {
33
- return res.status(503).json({ message: "Vessel position not available" });
34
- }
35
- try {
36
- const nearest = nearestStation({
37
- latitude: pos.latitude,
38
- longitude: pos.longitude,
39
- });
40
- if (!nearest) {
41
- return res.status(404).json({ message: "No nearby stations found" });
15
+ // signalk-server runs Express 4, which ignores a rejected handler and leaves
16
+ // the request hanging, so every failure has to reach next().
17
+ return async (req, res, next) => {
18
+ try {
19
+ // HTTP 303 See Other: the response can be found under a different URI
20
+ const redirect = (toPath) => res.redirect(303, `${req.baseUrl}${toPath}`);
21
+ // The configured default station, or null when unset, "auto", stale, or unpredictable.
22
+ const defaultStation = async () => {
23
+ const id = getDefaultStationId?.();
24
+ if (!id || id === "auto")
25
+ return null;
26
+ try {
27
+ const station = await findStation(id);
28
+ return hasTideModel(station) ? station : null;
29
+ }
30
+ catch (e) {
31
+ // A stale or invalid saved id falls back to position; a database that
32
+ // failed to load has to reach next().
33
+ if (isStationNotFound(e))
34
+ return null;
35
+ throw e;
36
+ }
37
+ };
38
+ // Redirect the vessel/default alias to the configured (or nearest) station.
39
+ if (/^\/stations\/vessel\/default(?:\/|$)/.test(req.path)) {
40
+ const toStation = (station) => redirect(req.url.replace(/^\/stations\/vessel\/default\b/, `/stations/${station.id}`));
41
+ // A configured default station resolves without a position
42
+ const station = await defaultStation();
43
+ if (station)
44
+ return toStation(station);
45
+ const pos = getPosition();
46
+ if (!pos) {
47
+ res.status(503).json({ message: "Vessel position not available" });
48
+ return;
49
+ }
50
+ try {
51
+ return toStation(await nearestToVessel(pos));
52
+ }
53
+ catch (e) {
54
+ if (isNoStationNearby(e)) {
55
+ res.status(404).json({ message: "No nearby stations found" });
56
+ return;
57
+ }
58
+ res.status(500).json({ message: "Failed to find nearest station" });
59
+ return;
42
60
  }
43
- return toStation(nearest);
44
61
  }
45
- catch {
46
- return res.status(500).json({
47
- message: "Failed to find nearest station",
48
- });
62
+ // Bare location-based queries: redirect to the configured station when set,
63
+ // otherwise inject the vessel position so slackwater uses the nearest station.
64
+ const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
65
+ if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
66
+ const station = await defaultStation();
67
+ if (station) {
68
+ return redirect(req.url.replace(/^\/(extremes|timeline)\b/, `/stations/${station.id}/$1`));
69
+ }
70
+ const pos = getPosition();
71
+ if (pos) {
72
+ // The Slackwater route takes any distance, so check the range here
73
+ try {
74
+ await nearestToVessel(pos);
75
+ }
76
+ catch (e) {
77
+ if (!isNoStationNearby(e))
78
+ throw e;
79
+ res.status(404).json({ message: "No nearby stations found" });
80
+ return;
81
+ }
82
+ // Express 5 makes `req.query` a read-only getter, so assigning to its
83
+ // properties is silently lost. Shadow it with an own data property,
84
+ // which behaves identically on Express 4 and 5.
85
+ Object.defineProperty(req, "query", {
86
+ value: {
87
+ ...req.query,
88
+ latitude: String(pos.latitude),
89
+ longitude: String(pos.longitude),
90
+ },
91
+ writable: true,
92
+ configurable: true,
93
+ enumerable: true,
94
+ });
95
+ }
49
96
  }
97
+ router(req, res, next);
50
98
  }
51
- // Bare location-based queries: redirect to the configured station when set,
52
- // otherwise inject the vessel position so slackwater uses the nearest station.
53
- const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
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
- }
59
- const pos = getPosition();
60
- if (pos) {
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
- });
74
- }
99
+ catch (error) {
100
+ next(error);
75
101
  }
76
- router(req, res, next);
77
102
  };
78
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-tides",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Tidal predictions for the vessel's position, powered by Slackwater.",
5
5
  "homepage": "https://openwaters.io/tides/slackwater/",
6
6
  "type": "module",
@@ -36,13 +36,13 @@
36
36
  "license": "Apache-2.0",
37
37
  "dependencies": {
38
38
  "@signalk/server-api": "^2.35.0",
39
- "@slackwater/api": "^1.0.0-beta.0",
39
+ "@slackwater/api": "^1.0.0-beta.4",
40
40
  "@slackwater/react": "^1.0.0-beta.2",
41
41
  "express": "^5.3.0",
42
42
  "react": "^19.1.0",
43
43
  "react-dom": "^19.3.1",
44
44
  "signalk-plotterext-bus": "^0.24.0",
45
- "slackwater": "^1.0.0-beta.3"
45
+ "slackwater": "^1.0.0-beta.4"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^9.21.0",
@@ -0,0 +1 @@
1
+ import{a as e,c as t,d as n,f as r,i,l as a,n as o,r as s,s as c,t as l,u}from"./useUnitPreferences-Bnl2Hfv0.js";var d=u(),f=r(n(),1),p=()=>new URLSearchParams(window.location.search).get(`station`)||`vessel/default`;function m(){let[e,t]=(0,f.useState)(p);return(0,f.useEffect)(()=>{let e=()=>t(p());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),[e,(0,f.useCallback)(e=>{let n=e.trim()||`vessel/default`,r=new URL(window.location.href);n===`vessel/default`?r.searchParams.delete(`station`):r.searchParams.set(`station`,n),window.history.pushState(window.history.state,``,r),t(n)},[])]}var h=a(),g={version:8,sources:{osm:{type:`raster`,tiles:[`https://tile.openstreetmap.org/{z}/{x}/{y}.png`],tileSize:256,attribution:`© OpenStreetMap contributors`}},layers:[{id:`osm`,type:`raster`,source:`osm`}]};function _({stationId:n,onSelect:r}){let a=(0,f.useRef)(null),[s,c]=(0,f.useState)(!1),[l,u]=(0,f.useState)(!1),[d,p]=(0,f.useState)(`list`),{data:m,error:_}=t(n),v=()=>{u(window.matchMedia(`(min-width: 1024px)`).matches),p(`list`),c(!0),a.current?.showModal()},y=e=>{r(e.id),a.current?.close()};return(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(`button`,{type:`button`,onClick:v,"aria-label":`Browse stations`,className:`absolute hidden min-[20rem]:block right-2 top-2 sm:right-4 sm:top-4 md:right-6 md:top-6 lg:right-8 lg:top-8 xl:right-20 xl:top-20 z-10 rounded-md border border-(--slackwater-border) bg-(--slackwater-bg-subtle) p-2 text-(--slackwater-text) hover:bg-(--slackwater-bg)`,children:(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`21`,y2:`18`})]})}),(0,h.jsx)(`dialog`,{ref:a,onClose:()=>c(!1),onClick:e=>{e.target===e.currentTarget&&e.currentTarget.close()},className:`m-auto w-[min(92vw,900px)] max-h-[85vh] rounded-lg border border-(--slackwater-border) bg-(--slackwater-bg) p-0 text-(--slackwater-text) backdrop:bg-black/50`,children:s&&(0,h.jsxs)(`div`,{className:`flex max-h-[85vh] flex-col`,children:[(0,h.jsxs)(`div`,{className:`flex items-center justify-between border-b border-(--slackwater-border) p-3`,children:[(0,h.jsx)(`button`,{type:`button`,onClick:()=>a.current?.close(),"aria-label":`Close`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text)`,children:`✕`}),(0,h.jsx)(`h2`,{className:`text-base font-semibold`,children:`Stations`}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>p(d===`map`?`list`:`map`),"aria-label":d===`map`?`Show list`:`Show map`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text) lg:invisible`,children:d===`map`?(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`8`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`8`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`8`,y1:`18`,x2:`21`,y2:`18`}),(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`3.01`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`3.01`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`3.01`,y2:`18`})]}):(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`polygon`,{points:`3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21 3 6`}),(0,h.jsx)(`line`,{x1:`9`,y1:`3`,x2:`9`,y2:`18`}),(0,h.jsx)(`line`,{x1:`15`,y1:`6`,x2:`15`,y2:`21`})]})})]}),(0,h.jsxs)(`div`,{className:`flex min-h-0 flex-col gap-4 p-3 lg:flex-row`,children:[(0,h.jsxs)(`div`,{className:`w-full flex-col gap-3 overflow-y-auto lg:flex lg:w-80 ${d===`map`?`hidden`:`flex`}`,children:[(0,h.jsx)(i,{onSelect:y}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>y({id:`vessel/default`}),className:`rounded-md border border-(--slackwater-border) px-3 py-2 text-left text-sm hover:bg-(--slackwater-bg-subtle)`,children:`📍 Vessel position`}),(0,h.jsx)(o,{stationId:n,onStationSelect:y})]}),(l||d===`map`)&&(m||_)&&(0,h.jsx)(`div`,{className:`h-[60vh] overflow-hidden rounded-md border border-(--slackwater-border) lg:h-auto lg:min-h-100 lg:flex-1`,children:(0,h.jsx)(e,{mapStyle:g,initialViewState:m?{longitude:m.longitude,latitude:m.latitude,zoom:10}:{longitude:0,latitude:20,zoom:1},focusStation:m?.id,onStationSelect:y})})]})]})})]})}var{VITE_SIGNALK_URL:v=window.location.toString()}={BASE_URL:`./`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1},y=new URL(`/signalk/v2/api`,v).toString();function b(){let{units:e,ready:t}=l(),[n,r]=m();return t?(0,h.jsx)(s,{baseUrl:y,units:e,children:(0,h.jsxs)(`div`,{className:`relative`,children:[(0,h.jsx)(_,{stationId:n,onSelect:r}),(0,h.jsx)(c,{id:n,className:`overflow-y-auto p-2 sm:p-4 md:p-6 lg:p-8 xl:p-20`})]})}):null}(0,d.createRoot)(document.getElementById(`app`)).render((0,h.jsx)(b,{}));
package/public/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./icon.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>Tides</title>
9
- <script type="module" crossorigin src="./assets/main-BvKlaM8p.js"></script>
9
+ <script type="module" crossorigin src="./assets/main-FMwgC28o.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="./assets/useUnitPreferences-Bnl2Hfv0.js">
11
11
  <link rel="stylesheet" crossorigin href="./assets/useUnitPreferences-D6HIp3mv.css">
12
12
  </head>
@@ -1 +0,0 @@
1
- import{a as e,c as t,d as n,f as r,i,l as a,n as o,r as s,s as c,t as l,u}from"./useUnitPreferences-Bnl2Hfv0.js";var d=u(),f=r(n(),1),p=()=>new URLSearchParams(window.location.search).get(`station`)||`vessel/default`;function m(){let[e,t]=(0,f.useState)(p);return(0,f.useEffect)(()=>{let e=()=>t(p());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),[e,(0,f.useCallback)(e=>{let n=e.trim()||`vessel/default`,r=new URL(window.location.href);n===`vessel/default`?r.searchParams.delete(`station`):r.searchParams.set(`station`,n),window.history.pushState(window.history.state,``,r),t(n)},[])]}var h=a(),g={version:8,sources:{osm:{type:`raster`,tiles:[`https://tile.openstreetmap.org/{z}/{x}/{y}.png`],tileSize:256,attribution:`© OpenStreetMap contributors`}},layers:[{id:`osm`,type:`raster`,source:`osm`}]};function _({stationId:n,onSelect:r}){let a=(0,f.useRef)(null),[s,c]=(0,f.useState)(!1),[l,u]=(0,f.useState)(!1),[d,p]=(0,f.useState)(`list`),{data:m}=t(n),_=()=>{u(window.matchMedia(`(min-width: 1024px)`).matches),p(`list`),c(!0),a.current?.showModal()},v=e=>{r(e.id),a.current?.close()};return(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(`button`,{type:`button`,onClick:_,"aria-label":`Browse stations`,className:`absolute hidden min-[20rem]:block right-2 top-2 sm:right-4 sm:top-4 md:right-6 md:top-6 lg:right-8 lg:top-8 xl:right-20 xl:top-20 z-10 rounded-md border border-(--slackwater-border) bg-(--slackwater-bg-subtle) p-2 text-(--slackwater-text) hover:bg-(--slackwater-bg)`,children:(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`21`,y2:`18`})]})}),(0,h.jsx)(`dialog`,{ref:a,onClose:()=>c(!1),onClick:e=>{e.target===e.currentTarget&&e.currentTarget.close()},className:`m-auto w-[min(92vw,900px)] max-h-[85vh] rounded-lg border border-(--slackwater-border) bg-(--slackwater-bg) p-0 text-(--slackwater-text) backdrop:bg-black/50`,children:s&&(0,h.jsxs)(`div`,{className:`flex max-h-[85vh] flex-col`,children:[(0,h.jsxs)(`div`,{className:`flex items-center justify-between border-b border-(--slackwater-border) p-3`,children:[(0,h.jsx)(`button`,{type:`button`,onClick:()=>a.current?.close(),"aria-label":`Close`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text)`,children:`✕`}),(0,h.jsx)(`h2`,{className:`text-base font-semibold`,children:`Stations`}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>p(d===`map`?`list`:`map`),"aria-label":d===`map`?`Show list`:`Show map`,className:`grid size-11 place-items-center rounded-md text-(--slackwater-text-muted) hover:text-(--slackwater-text) lg:invisible`,children:d===`map`?(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`line`,{x1:`8`,y1:`6`,x2:`21`,y2:`6`}),(0,h.jsx)(`line`,{x1:`8`,y1:`12`,x2:`21`,y2:`12`}),(0,h.jsx)(`line`,{x1:`8`,y1:`18`,x2:`21`,y2:`18`}),(0,h.jsx)(`line`,{x1:`3`,y1:`6`,x2:`3.01`,y2:`6`}),(0,h.jsx)(`line`,{x1:`3`,y1:`12`,x2:`3.01`,y2:`12`}),(0,h.jsx)(`line`,{x1:`3`,y1:`18`,x2:`3.01`,y2:`18`})]}):(0,h.jsxs)(`svg`,{width:`20`,height:`20`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,h.jsx)(`polygon`,{points:`3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21 3 6`}),(0,h.jsx)(`line`,{x1:`9`,y1:`3`,x2:`9`,y2:`18`}),(0,h.jsx)(`line`,{x1:`15`,y1:`6`,x2:`15`,y2:`21`})]})})]}),(0,h.jsxs)(`div`,{className:`flex min-h-0 flex-col gap-4 p-3 lg:flex-row`,children:[(0,h.jsxs)(`div`,{className:`w-full flex-col gap-3 overflow-y-auto lg:flex lg:w-80 ${d===`map`?`hidden`:`flex`}`,children:[(0,h.jsx)(i,{onSelect:v}),(0,h.jsx)(`button`,{type:`button`,onClick:()=>v({id:`vessel/default`}),className:`rounded-md border border-(--slackwater-border) px-3 py-2 text-left text-sm hover:bg-(--slackwater-bg-subtle)`,children:`📍 Vessel position`}),(0,h.jsx)(o,{stationId:n,onStationSelect:v})]}),(l||d===`map`)&&m&&(0,h.jsx)(`div`,{className:`h-[60vh] overflow-hidden rounded-md border border-(--slackwater-border) lg:h-auto lg:min-h-100 lg:flex-1`,children:(0,h.jsx)(e,{mapStyle:g,initialViewState:{longitude:m.longitude,latitude:m.latitude,zoom:10},focusStation:m.id,onStationSelect:v})})]})]})})]})}var{VITE_SIGNALK_URL:v=window.location.toString()}={BASE_URL:`./`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1},y=new URL(`/signalk/v2/api`,v).toString();function b(){let{units:e,ready:t}=l(),[n,r]=m();return t?(0,h.jsx)(s,{baseUrl:y,units:e,children:(0,h.jsxs)(`div`,{className:`relative`,children:[(0,h.jsx)(_,{stationId:n,onSelect:r}),(0,h.jsx)(c,{id:n,className:`overflow-y-auto p-2 sm:p-4 md:p-6 lg:p-8 xl:p-20`})]})}):null}(0,d.createRoot)(document.getElementById(`app`)).render((0,h.jsx)(b,{}));