diff --git a/.changeset/free-pumas-occur.md b/.changeset/free-pumas-occur.md new file mode 100644 index 0000000..1909cde --- /dev/null +++ b/.changeset/free-pumas-occur.md @@ -0,0 +1,6 @@ +--- +"@neaps/tide-predictor": minor +"neaps": minor +--- + +Moved `useStation` into @neaps/tide-predictor so it can be used without the heavy dependency of @neaps/tide-database. diff --git a/packages/neaps/src/index.ts b/packages/neaps/src/index.ts index 44c90df..197b6ee 100644 --- a/packages/neaps/src/index.ts +++ b/packages/neaps/src/index.ts @@ -2,27 +2,17 @@ import { stations, near, nearest, - type Station, type NearOptions, type NearestOptions, } from "@neaps/tide-database"; -import tidePredictor, { type TimeSpan, type ExtremesInput } from "@neaps/tide-predictor"; - -type Units = "meters" | "feet"; -type PredictionOptions = { - /** Datum to return predictions in. Defaults to 'MLLW' if available for the nearest station. */ - datum?: string; - - /** Units for returned water levels. Defaults to 'meters'. */ - units?: Units; -}; - -export type ExtremesOptions = ExtremesInput & PredictionOptions; -export type TimelineOptions = TimeSpan & PredictionOptions; -export type WaterLevelOptions = { time: Date } & PredictionOptions; - -const feetPerMeter = 3.2808399; -const defaultUnits: Units = "meters"; +import { + useStation, + type Station, + type StationPredictor, + type StationExtremesOptions, + type StationTimelineOptions, + type StationWaterLevelOptions, +} from "@neaps/tide-predictor"; /** * Get extremes prediction using the nearest station to the given position. @@ -39,21 +29,21 @@ const defaultUnits: Units = "meters"; * datum: 'MLLW', // optional, defaults to MLLW if available * }) */ -export function getExtremesPrediction(options: NearestOptions & ExtremesOptions) { +export function getExtremesPrediction(options: NearestOptions & StationExtremesOptions) { return nearestStation(options).getExtremesPrediction(options); } /** * Get timeline prediction using the nearest station to the given position. */ -export function getTimelinePrediction(options: NearestOptions & TimelineOptions) { +export function getTimelinePrediction(options: NearestOptions & StationTimelineOptions) { return nearestStation(options).getTimelinePrediction(options); } /** * Get water level at a specific time using the nearest station to the given position. */ -export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions) { +export function getWaterLevelAtTime(options: NearestOptions & StationWaterLevelOptions) { return nearestStation(options).getWaterLevelAtTime(options); } @@ -63,7 +53,7 @@ export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions) export function nearestStation(options: NearestOptions) { const data = nearest(options); if (!data) throw new Error(`No stations found with options: ${JSON.stringify(options)}`); - return useStation(...data); + return useStation(...data, findStation); } /** @@ -71,13 +61,13 @@ export function nearestStation(options: NearestOptions) { * @param limit Maximum number of stations to return (default: 10) */ export function stationsNear(options: NearOptions) { - return near(options).map(([station, distance]) => useStation(station, distance)); + return near(options).map(([station, distance]) => useStation(station, distance, findStation)); } /** * Find a specific station by its ID or source ID. */ -export function findStation(query: string) { +export function findStation(query: string): StationPredictor { const searches = [(s: Station) => s.id === query, (s: Station) => s.source.id === query]; let found: Station | undefined = undefined; @@ -89,96 +79,5 @@ export function findStation(query: string) { if (!found) throw new Error(`Station not found: ${query}`); - return useStation(found); -} - -export function useStation(station: Station, distance?: number) { - // If subordinate station, use the reference station for datums and constituents - let reference = station; - if (station.type === "subordinate" && station.offsets?.reference) { - reference = findStation(station.offsets?.reference); - } - const { datums, harmonic_constituents } = reference; - - // Use MLLW as the default datum if available - const defaultDatum = "MLLW" in datums ? "MLLW" : undefined; - - function getPredictor({ datum = defaultDatum }: PredictionOptions = {}) { - let offset = 0; - - if (datum) { - const datumOffset = datums?.[datum]; - const mslOffset = datums?.["MSL"]; - - if (typeof datumOffset !== "number") { - throw new Error( - `Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums).join(", ")}`, - ); - } - - if (typeof mslOffset !== "number") { - throw new Error( - `Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`, - ); - } - - offset = mslOffset - datumOffset; - } - - return tidePredictor(harmonic_constituents, { offset }); - } - - return { - ...station, - distance, - datums, - harmonic_constituents, - defaultDatum, - getExtremesPrediction({ - datum = defaultDatum, - units = defaultUnits, - ...options - }: ExtremesOptions) { - const extremes = getPredictor({ datum }) - .getExtremesPrediction({ ...options, offsets: station.offsets }) - .map((e) => toPreferredUnits(e, units)); - - return { datum, units, station, distance, extremes }; - }, - - getTimelinePrediction({ - datum = defaultDatum, - units = defaultUnits, - ...options - }: TimelineOptions) { - if (station.type === "subordinate") { - throw new Error(`Timeline predictions are not supported for subordinate stations.`); - } - const timeline = getPredictor({ datum }) - .getTimelinePrediction(options) - .map((e) => toPreferredUnits(e, units)); - - return { datum, units, station, distance, timeline }; - }, - - getWaterLevelAtTime({ time, datum = defaultDatum, units = defaultUnits }: WaterLevelOptions) { - if (station.type === "subordinate") { - throw new Error(`Water level predictions are not supported for subordinate stations.`); - } - - const prediction = toPreferredUnits( - getPredictor({ datum }).getWaterLevelAtTime({ time }), - units, - ); - - return { datum, units, station, distance, ...prediction }; - }, - }; -} - -function toPreferredUnits(prediction: T, units: Units): T { - let { level } = prediction; - if (units === "feet") level *= feetPerMeter; - else if (units !== "meters") throw new Error(`Unsupported units: ${units}`); - return { ...prediction, level }; + return useStation(found, undefined, findStation); } diff --git a/packages/neaps/test/index.test.ts b/packages/neaps/test/index.test.ts index b446e79..c7dc124 100644 --- a/packages/neaps/test/index.test.ts +++ b/packages/neaps/test/index.test.ts @@ -3,7 +3,6 @@ import { getExtremesPrediction, nearestStation, findStation, - useStation, getTimelinePrediction, getWaterLevelAtTime, stationsNear, @@ -357,7 +356,7 @@ describe("datum", () => { ); if (!station) expect.fail("No station without MSL datum found"); expect(() => { - useStation(station).getExtremesPrediction({ + findStation(station.id).getExtremesPrediction({ start: new Date("2025-12-17T00:00:00Z"), end: new Date("2025-12-18T00:00:00Z"), datum: Object.keys(station.datums)[0], @@ -371,7 +370,7 @@ describe("datum", () => { (s) => s.type === "reference" && Object.entries(s.datums).length === 0, ); if (!station) expect.fail("No station without datums found"); - const extremes = useStation(station).getExtremesPrediction({ + const extremes = findStation(station.id).getExtremesPrediction({ start: new Date("2025-12-17T00:00:00Z"), end: new Date("2025-12-18T00:00:00Z"), }); diff --git a/packages/tide-predictor/src/index.ts b/packages/tide-predictor/src/index.ts index 944c961..9cb1625 100644 --- a/packages/tide-predictor/src/index.ts +++ b/packages/tide-predictor/src/index.ts @@ -75,3 +75,4 @@ tidePredictionFactory.constituents = constituents; export default tidePredictionFactory; export type { HarmonicConstituent, TimelinePoint, Extreme }; +export * from "./station.js"; diff --git a/packages/tide-predictor/src/station.ts b/packages/tide-predictor/src/station.ts new file mode 100644 index 0000000..341b638 --- /dev/null +++ b/packages/tide-predictor/src/station.ts @@ -0,0 +1,180 @@ +import tidePredictor, { + type TimeSpan, + type ExtremesInput, + type Extreme, + type TimelinePoint, +} from "./index.js"; +import type { HarmonicConstituent, ExtremeOffsets } from "./harmonics/index.js"; + +export type Units = "meters" | "feet"; + +export type Station = { + id: string; + name: string; + continent: string; + country: string; + region?: string; + timezone: string; + disclaimers: string; + latitude: number; + longitude: number; + + // Data source information + source: { + name: string; + id: string; + url: string; + }; + + datums: Record; + type: "reference" | "subordinate"; + harmonic_constituents: HarmonicConstituent[]; + offsets?: { reference?: string } & ExtremeOffsets; +}; + +export type StationPredictionOptions = { + /** Datum to return predictions in. Defaults to 'MLLW' if available for the nearest station. */ + datum?: string; + + /** Units for returned water levels. Defaults to 'meters'. */ + units?: Units; +}; + +export type StationExtremesOptions = ExtremesInput & StationPredictionOptions; +export type StationTimelineOptions = TimeSpan & StationPredictionOptions; +export type StationWaterLevelOptions = { time: Date } & StationPredictionOptions; + +export type StationPrediction = { + datum: string | undefined; + units: Units; + station: Station; + distance?: number; +}; + +export type StationExtremesPrediction = StationPrediction & { + extremes: Extreme[]; +}; + +export type StationTimelinePrediction = StationPrediction & { + timeline: TimelinePoint[]; +}; + +export type StationWaterLevelPrediction = StationPrediction & TimelinePoint; + +export type StationPredictor = Station & { + distance?: number; + defaultDatum?: string; + harmonic_constituents: HarmonicConstituent[]; + getExtremesPrediction: (options: StationExtremesOptions) => StationExtremesPrediction; + getTimelinePrediction: (options: StationTimelineOptions) => StationTimelinePrediction; + getWaterLevelAtTime: (options: StationWaterLevelOptions) => StationWaterLevelPrediction; +}; + +const feetPerMeter = 3.2808399; +const defaultUnits: Units = "meters"; + +export function useStation( + station: Station, + distance?: number, + findStation?: (query: string) => StationPredictor, +): StationPredictor { + // If subordinate station, use the reference station for datums and constituents + let reference = station; + if (station.type === "subordinate" && station.offsets?.reference) { + if (!findStation) + throw new Error( + "findStation function must be provided to resolve subordinate station references.", + ); + reference = findStation(station.offsets?.reference); + } + + const { datums, harmonic_constituents } = reference; + + // Use MLLW as the default datum if available + const defaultDatum = "MLLW" in datums ? "MLLW" : undefined; + + function getPredictor({ datum = defaultDatum }: StationPredictionOptions = {}) { + let offset = 0; + + if (datum) { + const datumOffset = datums?.[datum]; + const mslOffset = datums?.["MSL"]; + + if (typeof datumOffset !== "number") { + throw new Error( + `Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums).join(", ")}`, + ); + } + + if (typeof mslOffset !== "number") { + throw new Error( + `Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`, + ); + } + + offset = mslOffset - datumOffset; + } + + return tidePredictor(harmonic_constituents, { offset }); + } + + return { + ...station, + distance, + datums, + harmonic_constituents, + defaultDatum, + getExtremesPrediction({ + datum = defaultDatum, + units = defaultUnits, + ...options + }: StationExtremesOptions) { + const offsets = "offsets" in station ? station.offsets : undefined; + + const extremes = getPredictor({ datum }) + .getExtremesPrediction({ ...options, offsets }) + .map((e) => toPreferredUnits(e, units)); + + return { datum, units, station, distance, extremes }; + }, + + getTimelinePrediction({ + datum = defaultDatum, + units = defaultUnits, + ...options + }: StationTimelineOptions) { + if (station.type === "subordinate") { + throw new Error(`Timeline predictions are not supported for subordinate stations.`); + } + const timeline = getPredictor({ datum }) + .getTimelinePrediction(options) + .map((e) => toPreferredUnits(e, units)); + + return { datum, units, station, distance, timeline }; + }, + + getWaterLevelAtTime({ + time, + datum = defaultDatum, + units = defaultUnits, + }: StationWaterLevelOptions) { + if (station.type === "subordinate") { + throw new Error(`Water level predictions are not supported for subordinate stations.`); + } + + const prediction = toPreferredUnits( + getPredictor({ datum }).getWaterLevelAtTime({ time }), + units, + ); + + return { datum, units, station, distance, ...prediction }; + }, + }; +} + +function toPreferredUnits(prediction: T, units: Units): T { + let { level } = prediction; + if (units === "feet") level *= feetPerMeter; + else if (units !== "meters") throw new Error(`Unsupported units: ${units}`); + return { ...prediction, level }; +}