Skip to content

Commit 8cb4a99

Browse files
committed
Add @neaps/api, and mapping layer for other providers
1 parent ed4f9d5 commit 8cb4a99

File tree

6 files changed

+246
-22
lines changed

6 files changed

+246
-22
lines changed

app/components/TideChart.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export function TideChart({
3939
setWidth(height / 4 * data.length)
4040
}, [height, data])
4141

42-
function displayDepth(value: number) {
43-
return units === "m" ? `${value.toFixed(2)} m` : `${(value * 3.28084).toFixed(1)} ft`;
42+
function displayDepth(level: number) {
43+
return units === "m" ? `${level.toFixed(2)} m` : `${(level * 3.28084).toFixed(1)} ft`;
4444
}
4545

4646
function displayTime(value: string) {
@@ -56,7 +56,7 @@ export function TideChart({
5656
.domain([min, max])
5757
.range([marginLeft, width - marginRight]);
5858

59-
const [yMin = 0, yMax = 0] = d3.extent(data, d => d.value)
59+
const [yMin = 0, yMax = 0] = d3.extent(data, d => d.level)
6060
const yPad = (yMax - yMin) * .3;
6161

6262
// Declare the y (vertical position) scale.
@@ -68,13 +68,13 @@ export function TideChart({
6868
area.current = d3.area<TideExtreme>()
6969
.curve(d3.curveMonotoneX)
7070
.x(d => x.current!(new Date(d.time)))
71-
.y0(y.current!(d3.min(data, d => d.value - yPadding) ?? 0))
72-
.y1(d => y.current!(d.value));
71+
.y0(y.current!(d3.min(data, d => d.level - yPadding) ?? 0))
72+
.y1(d => y.current!(d.level));
7373

7474
line.current = d3.line<TideExtreme>()
7575
.curve(d3.curveMonotoneX)
7676
.x(d => x.current!(new Date(d.time)))
77-
.y(d => y.current!(d.value))
77+
.y(d => y.current!(d.level))
7878

7979
if (gx.current) d3.select(gx.current).call(d3.axisBottom(x.current));
8080
}, [data, height, width, marginBottom, marginLeft, marginRight, marginTop])
@@ -121,19 +121,19 @@ export function TideChart({
121121
<circle
122122
className="TideChart__DataPoint"
123123
cx={x.current?.(new Date(d.time))}
124-
cy={y.current?.(d.value)}
124+
cy={y.current?.(d.level)}
125125
r={5}
126126
/>
127127
{
128128
(i !== 0 && i !== data.length - 1) &&
129129
<text
130-
className={["TideChart__Text", `TideChart__Text--${d.type}`].join(" ")}
131-
y={d.type === "High" ? marginTop + textPadding : height - marginBottom - textPadding}
130+
className={["TideChart__Text", `TideChart__Text--${d.label}`].join(" ")}
131+
y={d.label === "High" ? marginTop + textPadding : height - marginBottom - textPadding}
132132
>
133-
<tspan className="TideChart__Depth" x={x.current?.(new Date(d.time))} dy={d.type === "High" ? "1.5em" : "-1.5em"}>
134-
{displayDepth(d.value)}
133+
<tspan className="TideChart__Depth" x={x.current?.(new Date(d.time))} dy={d.label === "High" ? "1.5em" : "-1.5em"}>
134+
{displayDepth(d.level)}
135135
</tspan>
136-
<tspan className="TideChart__Time" x={x.current?.(new Date(d.time))} dy={d.type === "High" ? "-1.5em" : "1.5em"}>
136+
<tspan className="TideChart__Time" x={x.current?.(new Date(d.time))} dy={d.label === "High" ? "-1.5em" : "1.5em"}>
137137
{displayTime(d.time)}
138138
</tspan>
139139
</text>

app/hooks/useTideData.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
11
import { useEffect, useState } from "react";
2-
import { TideForecastResult, TideExtreme } from "../../src/types";
32

43
const { VITE_SIGNALK_URL = window.location.toString() } = import.meta.env;
5-
export const API_URL = new URL("/signalk/v2/api/resources/tides", VITE_SIGNALK_URL).toString();
4+
export const API_URL = new URL("/signalk/v2/api/tides/extremes", VITE_SIGNALK_URL).toString();
65
export const SETTINGS_URL = new URL("/#/serverConfiguration/plugins/tides", VITE_SIGNALK_URL).toString();
76

8-
export type { TideForecastResult, TideExtreme }
7+
export interface TideExtreme {
8+
time: string;
9+
level: number;
10+
high: boolean;
11+
low: boolean;
12+
label: string;
13+
}
14+
15+
export interface TideStation {
16+
name: string;
17+
latitude: number;
18+
longitude: number;
19+
}
20+
21+
export interface TideExtremesResult {
22+
station: TideStation;
23+
extremes: TideExtreme[];
24+
}
925

1026
export function useTideData() {
11-
const [data, setData] = useState<TideForecastResult>()
27+
const [data, setData] = useState<TideExtremesResult>()
1228

1329
useEffect(() => {
1430
(async () => {
15-
const res = await fetch(API_URL)
16-
setData(await res.json())
31+
const res = await fetch(API_URL);
32+
setData(await res.json());
1733
})()
1834
}, []);
1935

app/views/TidesView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function TidesView() {
1111
<header className="p-6 mb-8 flex gap-8">
1212
<div className="flex-1">
1313
<h1 className="text-2xl font-semibold tracking-tight m-0 flex-1">{data?.station?.name}</h1>
14-
{data?.station?.position && <Position {...data?.station?.position} />}
14+
{data?.station && <Position latitude={data.station.latitude} longitude={data.station.longitude} />}
1515
</div>
1616
<div>
1717
<a href={SETTINGS_URL}>

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
],
3232
"license": "Apache-2.0",
3333
"dependencies": {
34+
"@neaps/api": "^0.4.0",
3435
"@signalk/server-api": "^2.0",
36+
"express": "^4.0",
3537
"geolib": "^3.3.4",
3638
"moment": "^2.30.1",
3739
"neaps": "^0.2.0"
@@ -41,6 +43,7 @@
4143
"@react-hook/resize-observer": "^2.0.2",
4244
"@tailwindcss/vite": "^4.1.3",
4345
"@types/d3": "^7.4.3",
46+
"@types/express": "^4.0",
4447
"@types/node": "^25.0.3",
4548
"@types/react": "^19.0.12",
4649
"@types/react-dom": "^19.0.4",

src/index.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,34 @@
1515
*/
1616

1717
import { Context, Delta, Path, Plugin, Position, Timestamp } from "@signalk/server-api";
18+
import { RequestHandler } from "express";
19+
import { createRoutes } from "@neaps/api";
1820
import type { SignalKApp, Config, TideForecastResult } from "./types.js";
1921
import { approximateTideHeightAt } from "./calculations.js";
2022
import FileCache from "./cache.js";
2123
import createSources from "./sources/index.js";
24+
import { createAdapterRoutes } from "./routes.js";
2225

2326
export default function (app: SignalKApp): Plugin {
2427
// Interval to update tide data
2528
const defaultPeriod = 60; // 1 hour
2629
let unsubscribes: (() => void)[] = [];
30+
let activeRouter: RequestHandler | null = null;
2731

2832
const sources = createSources(app);
2933

34+
const MOUNT_PATH = "/signalk/v2/api/tides";
35+
36+
// Mount forwarding middleware once (Express doesn't support unmounting)
37+
// @ts-expect-error: app is an Express app at runtime
38+
app.use(MOUNT_PATH, (req, res, next) => {
39+
if (activeRouter) {
40+
activeRouter(req, res, next);
41+
} else {
42+
next();
43+
}
44+
});
45+
3046
const plugin: Plugin = {
3147
id: "tides",
3248
name: "Tides",
@@ -62,6 +78,7 @@ export default function (app: SignalKApp): Plugin {
6278
stop() {
6379
unsubscribes.forEach((f) => f());
6480
unsubscribes = [];
81+
activeRouter = null;
6582
},
6683
};
6784

@@ -73,11 +90,29 @@ export default function (app: SignalKApp): Plugin {
7390
const cache = new FileCache(app.getDataDirPath());
7491

7592
// Use the selected source, or the first one if not specified
76-
const source = sources.find((source) => source.id === props.source) || sources[0];
93+
const source =
94+
sources.find((source) => source.id === props.source) || sources[0];
7795

7896
// Load the selected source
7997
const provider = await source.start(props);
8098

99+
const getDefaultPosition = () => lastPosition;
100+
101+
// Set active router based on source
102+
if (source.id === "neaps") {
103+
const neapsRoutes = createRoutes({ prefix: MOUNT_PATH });
104+
// Cast needed: @neaps/api bundles its own Express types that conflict with local ones
105+
activeRouter = withDefaultPosition(
106+
neapsRoutes as unknown as RequestHandler,
107+
getDefaultPosition,
108+
);
109+
} else {
110+
activeRouter = withDefaultPosition(
111+
createAdapterRoutes(provider),
112+
getDefaultPosition,
113+
);
114+
}
115+
81116
// Register the source as a resource provider
82117
app.registerResourceProvider({
83118
type: "tides",
@@ -113,12 +148,14 @@ export default function (app: SignalKApp): Plugin {
113148
(subscriptionError) => {
114149
app.error("Error:" + subscriptionError);
115150
},
116-
updatePosition
151+
updatePosition,
117152
);
118153

119154
async function updatePosition() {
120155
lastPosition =
121-
app.getSelfPath("navigation.position.value") || (await cache.get("position")) || null;
156+
app.getSelfPath("navigation.position.value") ||
157+
(await cache.get("position")) ||
158+
null;
122159

123160
if (lastPosition) {
124161
await cache.set("position", lastPosition);
@@ -185,6 +222,24 @@ export default function (app: SignalKApp): Plugin {
185222
// Update every minute
186223
setInterval(updateTides, 60 * 1000);
187224
}
225+
226+
/** Middleware that injects default position into query when not provided */
227+
function withDefaultPosition(
228+
router: RequestHandler,
229+
getPosition: () => Position | null,
230+
): RequestHandler {
231+
return (req, res, next) => {
232+
if (!req.query.latitude && !req.query.longitude) {
233+
const pos = getPosition();
234+
if (pos) {
235+
req.query.latitude = String(pos.latitude);
236+
req.query.longitude = String(pos.longitude);
237+
}
238+
}
239+
router(req, res, next);
240+
};
241+
}
242+
188243
function delay(time: number) {
189244
return new Promise((resolve) => setTimeout(resolve, time));
190245
}

src/routes.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Router } from "express";
2+
import { openapi } from "@neaps/api";
3+
import { approximateTideHeightAt } from "./calculations.js";
4+
import type { TideExtreme, TideForecastFunction } from "./types.js";
5+
6+
/**
7+
* Create Express routes that implement a @neaps/api-compatible API
8+
* backed by the given tide forecast provider.
9+
*/
10+
export function createAdapterRoutes(provider: TideForecastFunction) {
11+
const router = Router();
12+
13+
router.get("/openapi.json", (_req, res) => {
14+
res.json(openapi);
15+
});
16+
17+
router.get("/extremes", async (req, res) => {
18+
const { latitude, longitude, start, end } = req.query;
19+
20+
if (!latitude || !longitude) {
21+
return res.status(400).json({ message: "latitude and longitude are required" });
22+
}
23+
24+
try {
25+
const result = await provider({
26+
position: {
27+
latitude: Number(latitude),
28+
longitude: Number(longitude),
29+
},
30+
date: start ? String(start) : undefined,
31+
});
32+
33+
const extremes = filterByTimeRange(result.extremes, start, end);
34+
35+
res.json({
36+
station: result.station,
37+
extremes: extremes.map(toNeapsExtreme),
38+
});
39+
} catch (error: unknown) {
40+
res.status(500).json({ message: (error as Error).message });
41+
}
42+
});
43+
44+
router.get("/timeline", async (req, res) => {
45+
const { latitude, longitude, start, end } = req.query;
46+
47+
if (!latitude || !longitude) {
48+
return res.status(400).json({ message: "latitude and longitude are required" });
49+
}
50+
51+
try {
52+
const result = await provider({
53+
position: {
54+
latitude: Number(latitude),
55+
longitude: Number(longitude),
56+
},
57+
date: start ? String(start) : undefined,
58+
});
59+
60+
const startTime = start ? new Date(String(start)) : new Date();
61+
const endTime = end
62+
? new Date(String(end))
63+
: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000);
64+
65+
const timeline = generateTimeline(result.extremes, startTime, endTime);
66+
67+
res.json({
68+
station: result.station,
69+
timeline,
70+
});
71+
} catch (error: unknown) {
72+
res.status(500).json({ message: (error as Error).message });
73+
}
74+
});
75+
76+
// Station endpoints are not supported by non-neaps sources
77+
router.get("/stations", (_req, res) => {
78+
res.status(501).json({ message: "Station discovery is not supported by this data source" });
79+
});
80+
81+
router.get("/stations/:source/:id", (_req, res) => {
82+
res.status(501).json({ message: "Station lookup is not supported by this data source" });
83+
});
84+
85+
router.get("/stations/:source/:id/extremes", (_req, res) => {
86+
res.status(501).json({ message: "Station lookup is not supported by this data source" });
87+
});
88+
89+
router.get("/stations/:source/:id/timeline", (_req, res) => {
90+
res.status(501).json({ message: "Station lookup is not supported by this data source" });
91+
});
92+
93+
return router;
94+
}
95+
96+
/** Convert a TideExtreme to the @neaps/api extreme format */
97+
function toNeapsExtreme(e: TideExtreme) {
98+
return {
99+
time: e.time,
100+
level: e.value,
101+
high: e.type === "High",
102+
low: e.type === "Low",
103+
label: e.type,
104+
};
105+
}
106+
107+
function filterByTimeRange(
108+
extremes: TideExtreme[],
109+
start: unknown,
110+
end: unknown
111+
) {
112+
let filtered = extremes;
113+
if (start) {
114+
const startTime = new Date(String(start)).getTime();
115+
filtered = filtered.filter((e) => new Date(e.time).getTime() >= startTime);
116+
}
117+
if (end) {
118+
const endTime = new Date(String(end)).getTime();
119+
filtered = filtered.filter((e) => new Date(e.time).getTime() <= endTime);
120+
}
121+
return filtered;
122+
}
123+
124+
/**
125+
* Generate a timeline of water levels at regular intervals by interpolating
126+
* between known extremes using sine-eased approximation.
127+
*/
128+
function generateTimeline(
129+
extremes: TideExtreme[],
130+
start: Date,
131+
end: Date,
132+
intervalMinutes = 10
133+
) {
134+
const timeline: { time: string; level: number }[] = [];
135+
const intervalMs = intervalMinutes * 60 * 1000;
136+
137+
for (let t = start.getTime(); t <= end.getTime(); t += intervalMs) {
138+
const time = new Date(t);
139+
try {
140+
const level = approximateTideHeightAt(extremes, time);
141+
if (level !== null) {
142+
timeline.push({ time: time.toISOString(), level });
143+
}
144+
} catch {
145+
// Skip points outside the range of known extremes
146+
}
147+
}
148+
149+
return timeline;
150+
}

0 commit comments

Comments
 (0)