signalk-tides 2.2.0 → 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.
@@ -1,78 +1,103 @@
1
- import { findStation, nearestStation } from "neaps";
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 neaps 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/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ node = "22"
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "signalk-tides",
3
- "version": "2.2.0",
4
- "description": "Tidal predictions for the vessel's position, powered by Neaps.",
3
+ "version": "2.3.0",
4
+ "description": "Tidal predictions for the vessel's position, powered by Slackwater.",
5
+ "homepage": "https://openwaters.io/tides/slackwater/",
5
6
  "type": "module",
6
7
  "main": "./dist/index.js",
7
8
  "exports": "./dist/index.js",
@@ -34,42 +35,42 @@
34
35
  ],
35
36
  "license": "Apache-2.0",
36
37
  "dependencies": {
37
- "@neaps/api": "^0.7.0",
38
- "@neaps/react": "^0.2.0",
39
- "@signalk/server-api": "^2.28.0",
40
- "express": "^5.2.1",
41
- "neaps": "^0.8.0",
38
+ "@signalk/server-api": "^2.35.0",
39
+ "@slackwater/api": "^1.0.0-beta.4",
40
+ "@slackwater/react": "^1.0.0-beta.2",
41
+ "express": "^5.3.0",
42
42
  "react": "^19.1.0",
43
- "react-dom": "^19.1.0",
44
- "signalk-plotterext-bus": "^0.15.0"
43
+ "react-dom": "^19.3.1",
44
+ "signalk-plotterext-bus": "^0.24.0",
45
+ "slackwater": "^1.0.0-beta.4"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@eslint/js": "^9.21.0",
48
49
  "@tailwindcss/vite": "^4.1.3",
49
50
  "@testing-library/dom": "^10.4.1",
50
51
  "@testing-library/react": "^16.3.2",
51
- "@testing-library/user-event": "^14.6.1",
52
+ "@testing-library/user-event": "^14.6.8",
52
53
  "@types/express": "^5.0.6",
53
- "@types/node": "^26.0.1",
54
+ "@types/node": "^26.6.5",
54
55
  "@types/react": "^19.0.12",
55
56
  "@types/react-dom": "^19.0.4",
56
57
  "@types/supertest": "^7.2.0",
57
- "@vitejs/plugin-react": "^6.0.2",
58
- "@vitest/browser": "5.0.2",
59
- "@vitest/browser-playwright": "5.0.2",
60
- "@vitest/ui": "5.0.2",
61
- "jsdom": "^30.0.1",
62
- "maplibre-gl": "^6.9.0",
63
- "oxlint": "^1.74.0",
64
- "pkg-pr-new": "^0.0.88",
65
- "playwright": "^1.58.2",
58
+ "@vitejs/plugin-react": "^6.1.2",
59
+ "@vitest/browser": "5.0.5",
60
+ "@vitest/browser-playwright": "5.0.5",
61
+ "@vitest/ui": "5.0.5",
62
+ "jsdom": "^30.1.3",
63
+ "maplibre-gl": "^6.15.0",
64
+ "oxlint": "^1.89.0",
65
+ "pkg-pr-new": "^0.0.92",
66
+ "playwright": "^1.64.1",
66
67
  "react-map-gl": "^8.1.0",
67
- "signalk-server": "^2.13.5",
68
- "supertest": "^7.2.2",
68
+ "signalk-server": "^2.35.1",
69
+ "supertest": "^7.3.1",
69
70
  "tailwindcss": "^4.1.3",
70
71
  "typescript": "^7.0.2",
71
- "vite": "^8.0.16",
72
- "vitest": "5.0.2"
72
+ "vite": "^8.3.5",
73
+ "vitest": "5.0.5"
73
74
  },
74
75
  "overrides": {
75
76
  "vite-plus": {
@@ -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,{}));