signalk-tides 2.1.0 → 2.1.2
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.
- package/package.json +1 -1
- package/public/assets/{index-D7RIUA1w.js → index-BYJ4l3Sy.js} +2 -2
- package/public/assets/{index-W6DTmxq6.css → index-CityHj4f.css} +1 -1
- package/public/assets/maplibre-gl-BOnKX8T3.js +748 -0
- package/public/index.html +2 -2
- package/.playwright-mcp/console-2026-06-06T09-48-18-232Z.log +0 -1
- package/.playwright-mcp/console-2026-06-12T13-01-03-706Z.log +0 -1
- package/.playwright-mcp/console-2026-06-12T13-15-30-205Z.log +0 -44
- package/.playwright-mcp/console-2026-06-12T13-16-00-389Z.log +0 -1
- package/.playwright-mcp/console-2026-06-12T14-41-49-989Z.log +0 -60
- package/.playwright-mcp/console-2026-06-12T14-51-31-609Z.log +0 -5
- package/dist/routes.js +0 -176
- package/dist/sources/index.js +0 -7
- package/dist/sources/neaps.js +0 -47
- package/dist/sources/noaa.js +0 -266
- package/dist/sources/stormglass.js +0 -199
- package/dist/sources/worldtides.js +0 -225
- package/dist/types.js +0 -1
- package/public/assets/maplibre-gl-c4hHMhPw.js +0 -748
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import { getDistance } from "geolib";
|
|
3
|
-
import makeFetchHappen from "make-fetch-happen";
|
|
4
|
-
const BASE_URL = "https://api.stormglass.io/v2/tide";
|
|
5
|
-
// StormGlass tide predictions accept only these two datums — its API rejects
|
|
6
|
-
// anything else (e.g. "LAT not valid. Should be one of: MLLW, MSL"). It exposes
|
|
7
|
-
// no datum offset table, so the values are placeholders; the @neaps UI only
|
|
8
|
-
// uses the keys to populate the datum selector. MSL is StormGlass's default.
|
|
9
|
-
const STORMGLASS_DEFAULT_DATUM = "MSL";
|
|
10
|
-
const STORMGLASS_DATUMS = { MSL: 0, MLLW: 0 };
|
|
11
|
-
function toStation(s) {
|
|
12
|
-
return {
|
|
13
|
-
id: `stormglass/${s.name}`,
|
|
14
|
-
name: s.name,
|
|
15
|
-
latitude: s.lat,
|
|
16
|
-
longitude: s.lng,
|
|
17
|
-
source: { id: "stormglass", name: s.source },
|
|
18
|
-
datums: { ...STORMGLASS_DATUMS },
|
|
19
|
-
defaultDatum: STORMGLASS_DEFAULT_DATUM,
|
|
20
|
-
...("distance" in s ? { distance: s.distance * 1000 } : {}), // km → meters
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
function toStationSummary(s) {
|
|
24
|
-
return {
|
|
25
|
-
id: `stormglass/${s.name}`,
|
|
26
|
-
name: s.name,
|
|
27
|
-
latitude: s.lat,
|
|
28
|
-
longitude: s.lng,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function toExtreme(e) {
|
|
32
|
-
const high = e.type === "high";
|
|
33
|
-
return {
|
|
34
|
-
time: new Date(e.time),
|
|
35
|
-
level: e.height,
|
|
36
|
-
high,
|
|
37
|
-
low: !high,
|
|
38
|
-
label: high ? "High" : "Low",
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
export default function (app) {
|
|
42
|
-
return {
|
|
43
|
-
id: "stormglass",
|
|
44
|
-
title: "StormGlass.io",
|
|
45
|
-
properties: {
|
|
46
|
-
stormglassApiKey: {
|
|
47
|
-
type: "string",
|
|
48
|
-
title: "StormGlass.io API key",
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
start(props = {}) {
|
|
52
|
-
const { stormglassApiKey } = props;
|
|
53
|
-
app.debug("Using StormGlass.io API");
|
|
54
|
-
const fetch = props._fetch ?? makeFetchHappen.defaults({
|
|
55
|
-
cachePath: path.join(app.getDataDirPath(), "stormglass", "http-cache"),
|
|
56
|
-
headers: { Authorization: stormglassApiKey ?? "" },
|
|
57
|
-
});
|
|
58
|
-
let cachedStations = null;
|
|
59
|
-
async function loadStations() {
|
|
60
|
-
if (cachedStations)
|
|
61
|
-
return cachedStations;
|
|
62
|
-
app.debug("StormGlass: Loading stations list");
|
|
63
|
-
const res = await fetch(`${BASE_URL}/stations`);
|
|
64
|
-
if (!res.ok)
|
|
65
|
-
throw new Error("Failed to fetch StormGlass stations: " + res.statusText);
|
|
66
|
-
const data = (await res.json());
|
|
67
|
-
cachedStations = data.data;
|
|
68
|
-
return cachedStations;
|
|
69
|
-
}
|
|
70
|
-
function predictionParams(options) {
|
|
71
|
-
const params = {
|
|
72
|
-
start: options.start.toISOString(),
|
|
73
|
-
end: options.end.toISOString(),
|
|
74
|
-
};
|
|
75
|
-
if (options.datum)
|
|
76
|
-
params.datum = options.datum;
|
|
77
|
-
return params;
|
|
78
|
-
}
|
|
79
|
-
async function findStationByName(id) {
|
|
80
|
-
const stations = await loadStations();
|
|
81
|
-
const rawName = id.replace(/^stormglass\//, "");
|
|
82
|
-
const found = stations.find((s) => s.name === rawName);
|
|
83
|
-
if (!found)
|
|
84
|
-
throw new Error(`Station not found: ${id}`);
|
|
85
|
-
return found;
|
|
86
|
-
}
|
|
87
|
-
const provider = {
|
|
88
|
-
async getExtremesPrediction(options) {
|
|
89
|
-
const endpoint = new URL(`${BASE_URL}/extremes/point`);
|
|
90
|
-
endpoint.search = new URLSearchParams({
|
|
91
|
-
lat: String(options.latitude),
|
|
92
|
-
lng: String(options.longitude),
|
|
93
|
-
...predictionParams(options),
|
|
94
|
-
}).toString();
|
|
95
|
-
app.debug("Fetching StormGlass extremes: " + endpoint);
|
|
96
|
-
const res = await fetch(endpoint.toString());
|
|
97
|
-
if (!res.ok)
|
|
98
|
-
throw new Error("Failed to fetch StormGlass extremes: " + res.statusText);
|
|
99
|
-
const data = (await res.json());
|
|
100
|
-
const station = toStation(data.meta.station);
|
|
101
|
-
return {
|
|
102
|
-
datum: data.meta.datum ?? options.datum,
|
|
103
|
-
units: options.units ?? "meters",
|
|
104
|
-
station,
|
|
105
|
-
distance: data.meta.station.distance * 1000,
|
|
106
|
-
extremes: data.data.map(toExtreme),
|
|
107
|
-
};
|
|
108
|
-
},
|
|
109
|
-
async getTimelinePrediction(options) {
|
|
110
|
-
const endpoint = new URL(`${BASE_URL}/sea-level/point`);
|
|
111
|
-
endpoint.search = new URLSearchParams({
|
|
112
|
-
lat: String(options.latitude),
|
|
113
|
-
lng: String(options.longitude),
|
|
114
|
-
...predictionParams(options),
|
|
115
|
-
}).toString();
|
|
116
|
-
app.debug("Fetching StormGlass sea level: " + endpoint);
|
|
117
|
-
const res = await fetch(endpoint.toString());
|
|
118
|
-
if (!res.ok)
|
|
119
|
-
throw new Error("Failed to fetch StormGlass sea level: " + res.statusText);
|
|
120
|
-
const data = (await res.json());
|
|
121
|
-
const station = toStation(data.meta.station);
|
|
122
|
-
return {
|
|
123
|
-
datum: data.meta.datum ?? options.datum,
|
|
124
|
-
units: options.units ?? "meters",
|
|
125
|
-
station,
|
|
126
|
-
distance: data.meta.station.distance * 1000,
|
|
127
|
-
timeline: data.data.map((e) => ({
|
|
128
|
-
time: new Date(e.time),
|
|
129
|
-
level: e.sg,
|
|
130
|
-
})),
|
|
131
|
-
};
|
|
132
|
-
},
|
|
133
|
-
async findStation(id) {
|
|
134
|
-
return toStation(await findStationByName(id));
|
|
135
|
-
},
|
|
136
|
-
async searchStations(query, options) {
|
|
137
|
-
const stations = await loadStations();
|
|
138
|
-
const q = query.toLowerCase();
|
|
139
|
-
const limit = options?.maxResults ?? 10;
|
|
140
|
-
return stations
|
|
141
|
-
.filter((s) => s.name.toLowerCase().includes(q))
|
|
142
|
-
.slice(0, limit)
|
|
143
|
-
.map(toStationSummary);
|
|
144
|
-
},
|
|
145
|
-
async stationsNear(options) {
|
|
146
|
-
const stations = await loadStations();
|
|
147
|
-
const withDist = stations.map((s) => ({
|
|
148
|
-
...s,
|
|
149
|
-
distance: getDistance(options, {
|
|
150
|
-
latitude: s.lat,
|
|
151
|
-
longitude: s.lng,
|
|
152
|
-
}),
|
|
153
|
-
}));
|
|
154
|
-
let sorted = withDist.sort((a, b) => a.distance - b.distance);
|
|
155
|
-
if (options.maxDistance) {
|
|
156
|
-
sorted = sorted.filter((s) => s.distance <= options.maxDistance);
|
|
157
|
-
}
|
|
158
|
-
return sorted.slice(0, options.maxResults ?? 10).map((s) => ({
|
|
159
|
-
...toStation(s),
|
|
160
|
-
distance: s.distance,
|
|
161
|
-
}));
|
|
162
|
-
},
|
|
163
|
-
async stationsWithin(bbox) {
|
|
164
|
-
const [minLon, minLat, maxLon, maxLat] = bbox;
|
|
165
|
-
// StormGlass format: topRight:bottomLeft → maxLat,maxLon:minLat,minLon
|
|
166
|
-
const box = `${maxLat},${maxLon}:${minLat},${minLon}`;
|
|
167
|
-
const endpoint = new URL(`${BASE_URL}/stations/area`);
|
|
168
|
-
endpoint.search = new URLSearchParams({ box }).toString();
|
|
169
|
-
app.debug("Fetching StormGlass stations area: " + endpoint);
|
|
170
|
-
const res = await fetch(endpoint.toString());
|
|
171
|
-
if (!res.ok)
|
|
172
|
-
throw new Error("Failed to fetch StormGlass stations: " + res.statusText);
|
|
173
|
-
const data = (await res.json());
|
|
174
|
-
return data.data.map(toStationSummary);
|
|
175
|
-
},
|
|
176
|
-
async stations() {
|
|
177
|
-
return (await loadStations()).map(toStationSummary);
|
|
178
|
-
},
|
|
179
|
-
async getStationExtremes(id, options) {
|
|
180
|
-
const station = await findStationByName(id);
|
|
181
|
-
return provider.getExtremesPrediction({
|
|
182
|
-
latitude: station.lat,
|
|
183
|
-
longitude: station.lng,
|
|
184
|
-
...options,
|
|
185
|
-
});
|
|
186
|
-
},
|
|
187
|
-
async getStationTimeline(id, options) {
|
|
188
|
-
const station = await findStationByName(id);
|
|
189
|
-
return provider.getTimelinePrediction({
|
|
190
|
-
latitude: station.lat,
|
|
191
|
-
longitude: station.lng,
|
|
192
|
-
...options,
|
|
193
|
-
});
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
return provider;
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
}
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import makeFetchHappen from "make-fetch-happen";
|
|
3
|
-
import FileCache from "../cache.js";
|
|
4
|
-
const BASE_URL = "https://www.worldtides.info/api/v3";
|
|
5
|
-
function formatDate(date) {
|
|
6
|
-
return date.toISOString().slice(0, 10);
|
|
7
|
-
}
|
|
8
|
-
function daysBetween(start, end) {
|
|
9
|
-
return Math.min(7, Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000))));
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* WorldTides is purely position-based — it has no get-station-by-id API — so we
|
|
13
|
-
* encode the station's coordinates into its ID. That lets the station-detail
|
|
14
|
-
* endpoints (findStation/getStation*) resolve back to a position to query,
|
|
15
|
-
* which is what the vessel/current redirect flow needs.
|
|
16
|
-
*/
|
|
17
|
-
function stationId(latitude, longitude) {
|
|
18
|
-
return `worldtides/${latitude},${longitude}`;
|
|
19
|
-
}
|
|
20
|
-
function parseStationId(id) {
|
|
21
|
-
const parts = id.replace(/^worldtides\//, "").split(",");
|
|
22
|
-
const [latitude, longitude] = parts.map(Number);
|
|
23
|
-
if (parts.length !== 2 || Number.isNaN(latitude) || Number.isNaN(longitude)) {
|
|
24
|
-
throw new Error(`Invalid WorldTides station ID: ${id}`);
|
|
25
|
-
}
|
|
26
|
-
return { latitude, longitude };
|
|
27
|
-
}
|
|
28
|
-
function toDatums(datums = []) {
|
|
29
|
-
return Object.fromEntries(datums.map((d) => [d.name, d.height]));
|
|
30
|
-
}
|
|
31
|
-
function toExtreme(e) {
|
|
32
|
-
const high = e.type === "High";
|
|
33
|
-
return {
|
|
34
|
-
time: new Date(e.dt * 1000),
|
|
35
|
-
level: e.height,
|
|
36
|
-
high,
|
|
37
|
-
low: !high,
|
|
38
|
-
label: high ? "High" : "Low",
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
const source = {
|
|
42
|
-
id: "worldtides",
|
|
43
|
-
name: "WorldTides.info",
|
|
44
|
-
url: "https://www.worldtides.info",
|
|
45
|
-
};
|
|
46
|
-
export default function (app) {
|
|
47
|
-
return {
|
|
48
|
-
id: "worldtides",
|
|
49
|
-
title: "WorldTides.info",
|
|
50
|
-
properties: {
|
|
51
|
-
worldtidesApiKey: {
|
|
52
|
-
type: "string",
|
|
53
|
-
title: "WorldTides.info API key",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
start(props = {}) {
|
|
57
|
-
const { worldtidesApiKey = "" } = props;
|
|
58
|
-
app.debug("Using WorldTides API");
|
|
59
|
-
const fetch = props._fetch ?? makeFetchHappen.defaults({
|
|
60
|
-
cachePath: path.join(app.getDataDirPath(), "worldtides", "http-cache"),
|
|
61
|
-
});
|
|
62
|
-
// Datums are location-specific values but a fixed catalog of names; cache
|
|
63
|
-
// them per station so we only spend an API credit on them once.
|
|
64
|
-
const datumsCache = new FileCache(path.join(app.getDataDirPath(), "worldtides", "datums"));
|
|
65
|
-
const datumsInFlight = new Map();
|
|
66
|
-
async function apiRequest(params) {
|
|
67
|
-
const endpoint = new URL(BASE_URL);
|
|
68
|
-
endpoint.search = new URLSearchParams({
|
|
69
|
-
key: worldtidesApiKey,
|
|
70
|
-
...params,
|
|
71
|
-
}).toString();
|
|
72
|
-
app.debug("Fetching WorldTides: " + endpoint);
|
|
73
|
-
const res = await fetch(endpoint.toString());
|
|
74
|
-
if (!res.ok)
|
|
75
|
-
throw new Error("Failed to fetch WorldTides: " + res.statusText);
|
|
76
|
-
const data = (await res.json());
|
|
77
|
-
if (data.status !== 200)
|
|
78
|
-
throw new Error(data.error ?? "WorldTides API error");
|
|
79
|
-
return data;
|
|
80
|
-
}
|
|
81
|
-
function fetchDatums(latitude, longitude) {
|
|
82
|
-
const key = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
|
83
|
-
let pending = datumsInFlight.get(key);
|
|
84
|
-
if (!pending) {
|
|
85
|
-
pending = loadDatums(latitude, longitude, key);
|
|
86
|
-
datumsInFlight.set(key, pending);
|
|
87
|
-
}
|
|
88
|
-
return pending;
|
|
89
|
-
}
|
|
90
|
-
async function loadDatums(latitude, longitude, key) {
|
|
91
|
-
const cached = (await datumsCache.get(key));
|
|
92
|
-
if (cached)
|
|
93
|
-
return cached;
|
|
94
|
-
try {
|
|
95
|
-
const data = await apiRequest({
|
|
96
|
-
datums: "",
|
|
97
|
-
lat: String(latitude),
|
|
98
|
-
lon: String(longitude),
|
|
99
|
-
});
|
|
100
|
-
const datums = toDatums(data.datums);
|
|
101
|
-
await datumsCache.set(key, datums);
|
|
102
|
-
return datums;
|
|
103
|
-
}
|
|
104
|
-
catch (e) {
|
|
105
|
-
// Don't cache failures, so they're retried next time.
|
|
106
|
-
app.debug(`WorldTides: failed to fetch datums for ${key}: ${e}`);
|
|
107
|
-
return {};
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Build a full Station from an API response. If the response already
|
|
112
|
-
* carries datums (the datums endpoint returns them inline) they're used
|
|
113
|
-
* directly; otherwise they're fetched (and cached) for the station.
|
|
114
|
-
*/
|
|
115
|
-
async function buildStation(data) {
|
|
116
|
-
const datums = data.datums
|
|
117
|
-
? toDatums(data.datums)
|
|
118
|
-
: await fetchDatums(data.responseLat, data.responseLon);
|
|
119
|
-
return {
|
|
120
|
-
id: stationId(data.responseLat, data.responseLon),
|
|
121
|
-
name: `${data.station} (${data.atlas})`,
|
|
122
|
-
latitude: data.responseLat,
|
|
123
|
-
longitude: data.responseLon,
|
|
124
|
-
timezone: data.timezone,
|
|
125
|
-
source,
|
|
126
|
-
datums,
|
|
127
|
-
defaultDatum: data.responseDatum,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
function toStationSummary(s) {
|
|
131
|
-
return {
|
|
132
|
-
id: stationId(s.lat, s.lon),
|
|
133
|
-
name: s.name,
|
|
134
|
-
latitude: s.lat,
|
|
135
|
-
longitude: s.lon,
|
|
136
|
-
timezone: s.timezone,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
function positionParams(options) {
|
|
140
|
-
return {
|
|
141
|
-
lat: String(options.latitude),
|
|
142
|
-
lon: String(options.longitude),
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
function predictionParams(options) {
|
|
146
|
-
const params = {
|
|
147
|
-
date: formatDate(options.start),
|
|
148
|
-
days: String(daysBetween(options.start, options.end)),
|
|
149
|
-
};
|
|
150
|
-
if (options.datum)
|
|
151
|
-
params.datum = options.datum;
|
|
152
|
-
return params;
|
|
153
|
-
}
|
|
154
|
-
const provider = {
|
|
155
|
-
async getExtremesPrediction(options) {
|
|
156
|
-
const data = await apiRequest({
|
|
157
|
-
extremes: "",
|
|
158
|
-
...positionParams(options),
|
|
159
|
-
...predictionParams(options),
|
|
160
|
-
});
|
|
161
|
-
return {
|
|
162
|
-
datum: data.responseDatum ?? options.datum,
|
|
163
|
-
units: options.units ?? "meters",
|
|
164
|
-
station: await buildStation(data),
|
|
165
|
-
extremes: (data.extremes ?? []).map(toExtreme),
|
|
166
|
-
};
|
|
167
|
-
},
|
|
168
|
-
async getTimelinePrediction(options) {
|
|
169
|
-
const data = await apiRequest({
|
|
170
|
-
heights: "",
|
|
171
|
-
step: "600", // 10-minute intervals
|
|
172
|
-
...positionParams(options),
|
|
173
|
-
...predictionParams(options),
|
|
174
|
-
});
|
|
175
|
-
return {
|
|
176
|
-
datum: data.responseDatum ?? options.datum,
|
|
177
|
-
units: options.units ?? "meters",
|
|
178
|
-
station: await buildStation(data),
|
|
179
|
-
timeline: (data.heights ?? []).map((h) => ({
|
|
180
|
-
time: new Date(h.dt * 1000),
|
|
181
|
-
level: h.height,
|
|
182
|
-
})),
|
|
183
|
-
};
|
|
184
|
-
},
|
|
185
|
-
async stationsNear(options) {
|
|
186
|
-
const distKm = options.maxDistance
|
|
187
|
-
? Math.ceil(options.maxDistance / 1000)
|
|
188
|
-
: 50;
|
|
189
|
-
const data = await apiRequest({
|
|
190
|
-
stations: "",
|
|
191
|
-
...positionParams(options),
|
|
192
|
-
stationDistance: String(distKm),
|
|
193
|
-
});
|
|
194
|
-
return (data.stations ?? [])
|
|
195
|
-
.slice(0, options.maxResults ?? 10)
|
|
196
|
-
.map((s) => ({ ...toStationSummary(s), source }));
|
|
197
|
-
},
|
|
198
|
-
async findStation(id) {
|
|
199
|
-
// Resolve the station's coordinates back to a position and fetch its
|
|
200
|
-
// datums (which the response returns inline).
|
|
201
|
-
const { latitude, longitude } = parseStationId(id);
|
|
202
|
-
const data = await apiRequest({
|
|
203
|
-
datums: "",
|
|
204
|
-
lat: String(latitude),
|
|
205
|
-
lon: String(longitude),
|
|
206
|
-
});
|
|
207
|
-
return buildStation(data);
|
|
208
|
-
},
|
|
209
|
-
getStationExtremes(id, options) {
|
|
210
|
-
return provider.getExtremesPrediction({
|
|
211
|
-
...parseStationId(id),
|
|
212
|
-
...options,
|
|
213
|
-
});
|
|
214
|
-
},
|
|
215
|
-
getStationTimeline(id, options) {
|
|
216
|
-
return provider.getTimelinePrediction({
|
|
217
|
-
...parseStationId(id),
|
|
218
|
-
...options,
|
|
219
|
-
});
|
|
220
|
-
},
|
|
221
|
-
};
|
|
222
|
-
return provider;
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
}
|
package/dist/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|