signalk-tides 2.2.0 → 2.2.1

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,3 @@
1
+ # Lockfile checks and npm trusted publishing use Node 24.
2
+ [tools]
3
+ node = "24"
@@ -13,6 +13,8 @@ jobs:
13
13
 
14
14
  - name: Setup Node.js
15
15
  uses: actions/setup-node@v7
16
+ with:
17
+ node-version-file: mise.toml
16
18
 
17
19
  - name: Install dependencies
18
20
  run: npm install
@@ -31,6 +33,8 @@ jobs:
31
33
 
32
34
  - name: Setup Node.js
33
35
  uses: actions/setup-node@v7
36
+ with:
37
+ node-version-file: mise.toml
34
38
 
35
39
  - name: Install dependencies
36
40
  run: npm install
@@ -54,6 +58,8 @@ jobs:
54
58
  - uses: actions/checkout@v7
55
59
 
56
60
  - uses: actions/setup-node@v7
61
+ with:
62
+ node-version-file: mise.toml
57
63
 
58
64
  - name: Install modules
59
65
  run: npm install
@@ -0,0 +1,31 @@
1
+ name: Lockfile
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ # A pull request can slip a package in through package-lock.json, which
13
+ # reviewers rarely read. npm keeps existing resolved URLs when it regenerates,
14
+ # so the diff alone misses a swapped tarball; lockfile-lint catches that.
15
+ lockfile:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: actions/setup-node@v7
20
+ with:
21
+ node-version-file: .github/mise.node24.toml
22
+ - name: Check the lockfile regenerates from package.json
23
+ run: |
24
+ git ls-files --error-unmatch package-lock.json
25
+ npm install --package-lock-only --ignore-scripts
26
+ git diff --exit-code -- package-lock.json
27
+ - name: Check every package resolves from the npm registry
28
+ # @isaacs/cliui installs CommonJS releases of these packages under -cjs aliases.
29
+ run: |
30
+ npx -y lockfile-lint@5.0.1 --path package-lock.json --allowed-hosts npm --validate-https --validate-integrity --validate-package-names \
31
+ --allowed-package-name-aliases string-width-cjs:string-width strip-ansi-cjs:strip-ansi wrap-ansi-cjs:wrap-ansi
@@ -17,7 +17,7 @@ jobs:
17
17
 
18
18
  - uses: actions/setup-node@v7
19
19
  with:
20
- node-version: 24 # npm >= 11.5.1 for trusted publishing
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
23
  - name: Install dependencies
@@ -0,0 +1,11 @@
1
+ # Contributing
2
+
3
+ ## Releasing
4
+
5
+ Publishing to npm runs from [release.yml](.github/workflows/release.yml) when a GitHub release is created. A person creates the release by hand: choose or create a `vX.Y.Z` tag, then fill in the title and notes.
6
+
7
+ Title and notes follow the Open Waters [release notes guidance](https://github.com/openwatersio/.github/blob/main/docs/agents/releases.md#release-notes). Write them for boaters running Signal K, not for developers reading commit subjects:
8
+
9
+ - Title the release with a short headline of what changed, in sentence case with no period and no version. Patch releases get a headline too. Put a new requirement in the headline, such as "requires Node 24".
10
+ - Open the notes with what is new and who it is for. Put breaking changes and upgrade steps first.
11
+ - List the changes users will notice, one per bullet, each linking its pull request. Leave out chores, CI changes, and bot dependency bumps unless a runtime dependency change matters to users.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # signalk-tides
2
2
 
3
- A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Neaps](https://github.com/neaps/neaps).
3
+ A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Slackwater](https://openwaters.io/tides/slackwater/).
4
4
 
5
5
  Since 2.0, predictions are computed locally from harmonic constituents — no network access or API keys required.
6
6
 
@@ -26,7 +26,7 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
26
26
 
27
27
  ### Tides API
28
28
 
29
- 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/default` resolves to the configured default station, or the nearest station to the vessel when none is set:
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
30
 
31
31
  ```
32
32
  $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
package/dist/index.js CHANGED
@@ -13,16 +13,24 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { createRoutes } from "@neaps/api";
17
- import { findStation, nearestStation, stationsNear } from "neaps";
16
+ import { createRoutes } from "@slackwater/api";
17
+ import { findStation, nearestStation, stationsNear } from "slackwater";
18
18
  import { 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";
22
22
  // Recompute and republish on a fixed interval. Predictions are computed locally
23
- // by neaps, so this is cheap and needs no configuration.
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
+ // Tide heights are water levels, so clients should show them in the user's
27
+ // depth unit. Without a category the server picks one from the unit alone, and
28
+ // for meters that is distance: kilometers or miles, depending on the preset.
29
+ const TIDE_HEIGHT_PATHS = [
30
+ "environment.tide.heightHigh",
31
+ "environment.tide.heightNow",
32
+ "environment.tide.heightLow",
33
+ ];
26
34
  export default function (app) {
27
35
  let unsubscribes = [];
28
36
  let activeRouter = null;
@@ -41,7 +49,7 @@ export default function (app) {
41
49
  const plugin = {
42
50
  id: "tides",
43
51
  name: "Tides",
44
- description: "Offline tidal predictions for the vessel's position, powered by Neaps.",
52
+ description: "Offline tidal predictions for the vessel's position, powered by Slackwater.",
45
53
  schema: () => ({
46
54
  title: "Tides",
47
55
  type: "object",
@@ -85,6 +93,25 @@ export default function (app) {
85
93
  }
86
94
  return options;
87
95
  }
96
+ // The server keeps display units the user chose for a path, and stores the
97
+ // suggestion, so later starts leave a path alone while it has display units.
98
+ async function suggestDepthUnits() {
99
+ // Signal K server 2.30 added setDefaultMetadata
100
+ if (typeof app.setDefaultMetadata !== "function")
101
+ return;
102
+ for (const path of TIDE_HEIGHT_PATHS) {
103
+ try {
104
+ // MetaValue types displayUnits as the server resolves it; a suggestion
105
+ // needs only the category.
106
+ await app.setDefaultMetadata(path, {
107
+ displayUnits: { category: "depth" },
108
+ });
109
+ }
110
+ catch (e) {
111
+ app.error(`Could not suggest display units for ${path}: ${e}`);
112
+ }
113
+ }
114
+ }
88
115
  function savedDefaultStation() {
89
116
  try {
90
117
  const saved = app.readPluginOptions();
@@ -97,15 +124,16 @@ export default function (app) {
97
124
  async function start(options) {
98
125
  app.debug("Starting tides");
99
126
  config = (options ?? {});
127
+ void suggestDepthUnits();
100
128
  // Keep the forecast and the predictor that produced it together, so the
101
129
  // extremes and the current height are always read from the same station.
102
130
  let lastForecast = null;
103
131
  let lastPredictor = null;
104
132
  const cache = new FileCache(app.getDataDirPath());
105
- // Mount the Neaps API, with vessel/default resolved to the configured
133
+ // Mount the Slackwater API, with vessel/default resolved to the configured
106
134
  // default station (or the nearest one).
107
135
  activeRouter = withVesselPosition(
108
- // @neaps/api bundles its own Express declarations, so its Router is
136
+ // @slackwater/api bundles its own Express declarations, so its Router is
109
137
  // callable at runtime but not structurally compatible with ours.
110
138
  // The OpenAPI server URL comes from req.baseUrl, set by the
111
139
  // app.use(API_PATH, ...) mount above.
@@ -1,4 +1,4 @@
1
- import { findStation, nearestStation } from "neaps";
1
+ import { findStation, nearestStation } from "slackwater";
2
2
  /**
3
3
  * Middleware that resolves the vessel/default station alias and bare
4
4
  * location-based queries (/extremes, /timeline) against the configured default
@@ -49,7 +49,7 @@ export function withVesselPosition(router, getPosition, getDefaultStationId) {
49
49
  }
50
50
  }
51
51
  // Bare location-based queries: redirect to the configured station when set,
52
- // otherwise inject the vessel position so neaps uses the nearest station.
52
+ // otherwise inject the vessel position so slackwater uses the nearest station.
53
53
  const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
54
54
  if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
55
55
  const station = defaultStation();
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.2.1",
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.0",
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.3"
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}=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,{}));