diff --git a/README.md b/README.md index c0d25049c..9daed33c4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,35 @@ console.log("Total stations:", stations.length); console.log(stations[0]); ``` +#### Searching for stations + +You can search for stations by proximity using the `near` and `nearest` functions: + +```typescript +import { near, nearest } from "@neaps/tide-database"; + +// Find all stations within 10 km of a lat/lon. Returns an array of [station, distanceinKm] tuples. +const nearbyStations = near({ + lon: -122, + lat: 37, + maxDistance: 10, + maxResults: 50, +}); +console.log("Nearby stations:", nearbyStations.length); + +// Find the nearest station to a lat/lon +const [nearestStation, distance] = nearest({ longitude: -75.5, latitude: 22 }); +console.log("Nearest station:", nearestStation.name, "is", distance, "km away"); +``` + +Both functions take the following parameters: + +- `latitude` or `lat`: Latitude in decimal degrees. +- `longitude`, `lon`, or `lng`: Longitude in decimal degrees. +- `filter`: A function that takes a station and returns `true` to include it in results, or `false` to exclude it. +- `maxDistance`: Maximum distance in kilometers to search for stations (default: `50` km). +- `maxResults`: Maximum number of results to return (default: `10`). + ## Data Format Each tide station is defined in a single JSON file in the [`data/`](./data) directory that includes basic station information, like location and name, and harmonics or subordinate station offsets. The format is defined by the schema in [../schemas/station.schema.json](schemas/station.schema.json), which includes more detailed descriptions of each field. All data is validated against this schema automatically on each change. diff --git a/examples/search.ts b/examples/search.ts new file mode 100644 index 000000000..7bfb2cef1 --- /dev/null +++ b/examples/search.ts @@ -0,0 +1,16 @@ +import { near, nearest } from "@neaps/tide-database"; +console.log( + "all stations within 10 km of 37.7749, -122.4194:", + near({ + lat: 37.7749, + lon: -122.4194, + maxDistance: 10, + maxResults: Infinity, + }).map(([s, distance]) => `${s.name} (${distance.toFixed(2)} km)`), +); + +const [station, distance] = nearest({ lon: -75.5, lat: 22 }) || []; +console.log( + "Nearest station to [-75.5, 22]:", + `${station!.name} (${distance} km)`, +); diff --git a/package.json b/package.json index 77bff1a92..55bb75317 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@neaps/tide-predictor": "^0.2.1", + "@types/geokdbush": "^1.1.5", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^25.0.3", "ajv": "^8.17.1", @@ -49,10 +50,15 @@ "sort-object-keys": "^2.0.1", "tsdown": "^0.19.0-beta.3", "typescript": "^5.9.3", + "unplugin-macros": "^0.18.6", "vitest": "^4.0.15" }, "files": [ "dist", "schemas" - ] + ], + "dependencies": { + "geokdbush": "^2.0.1", + "kdbush": "^4.0.2" + } } diff --git a/src/constituents.ts b/src/constituents.ts new file mode 100644 index 000000000..262428d81 --- /dev/null +++ b/src/constituents.ts @@ -0,0 +1,4 @@ +import _constituents from "./constituents.json" with { type: "json" }; +import type { Constituent } from "./types.js"; + +export const constituents: Constituent[] = _constituents; diff --git a/src/index.ts b/src/index.ts index 314e9a13e..f2b501c93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,75 +1,4 @@ -import _constituents from "./constituents.json" with { type: "json" }; - -export interface HarmonicConstituent { - name: string; - description?: string; - amplitude: number; - phase: number; - speed?: number; -} - -export interface Constituent { - name: string; - description: string | null; - speed: number; -} - -export interface StationData { - // Basic station information - name: string; - continent: string; - country: string; - region?: string; - timezone: string; - disclaimers: string; - type: "reference" | "subordinate"; - latitude: number; - longitude: number; - - // Data source information - source: { - name: string; - id: string; - published_harmonics: boolean; - url: string; - }; - - // License information - license: { - type: string; - commercial_use: boolean; - url: string; - notes?: string; - }; - - // Harmonic constituents (empty array for subordinate stations) - harmonic_constituents: HarmonicConstituent[]; - - // Subordinate station offsets (empty object for reference stations) - offsets?: { - reference: string; - height: { high: number; low: number; type: "ratio" | "fixed" }; - time: { high: number; low: number }; - }; - - datums: Record; -} - -export interface Station extends StationData { - id: string; -} - -export const constituents: Constituent[] = _constituents; - -const modules = import.meta.glob("./**/*.json", { - eager: true, - import: "default", - base: "../data", -}); - -export const stations: Station[] = Object.entries(modules).map( - ([path, data]) => { - const id = path.replace(/^\.\//, "").replace(/\.json$/, ""); - return { id, ...data }; - }, -); +export * from "./constituents.js"; +export * from "./stations.js"; +export * from "./search.js"; +export type * from "./types.js"; diff --git a/src/search-index.ts b/src/search-index.ts new file mode 100644 index 000000000..f8f7d681f --- /dev/null +++ b/src/search-index.ts @@ -0,0 +1,32 @@ +import KDBush from "kdbush"; + +/** + * Create a search index for stations and return it as a base64 string, which can be + * inlinted at build time by using the `macro` import type: + * + * import { createIndex } from "./search-index.js" with { type: "macro" }; + */ +export async function createIndex() { + const { stations } = await import("./stations.js"); + + const index = new KDBush(stations.length); + + for (const { longitude, latitude } of stations) { + index.add(longitude, latitude); + } + index.finish(); + + // @ts-ignore: Buffer is available at build time + return Buffer.from(index.data).toString("base64"); +} + +export function loadIndex(data: string): KDBush { + return KDBush.from(base64ToArrayBuffer(data)); +} + +function base64ToArrayBuffer(b64: string): ArrayBuffer { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 000000000..19998995e --- /dev/null +++ b/src/search.ts @@ -0,0 +1,70 @@ +import { around, distance } from "geokdbush"; +import { stations } from "./stations.js"; +import { createIndex } from "./search-index.js" with { type: "macro" }; +import { loadIndex } from "./search-index.js"; +import type { Station } from "./types.js"; + +export type Position = Latitude & Longitude; +type Latitude = { latitude: number } | { lat: number }; +type Longitude = { longitude: number } | { lon: number } | { lng: number }; + +export type NearestOptions = Position & { + maxDistance?: number; + filter?: (station: Station) => boolean; +}; + +export type NearOptions = NearestOptions & { + maxResults?: number; +}; + +/** + * A tuple of a station and its distance from a given point, in kilometers. + */ +export type StationWithDistance = [Station, number]; + +// Load the index, which gets inlined at build time +const index = loadIndex(await createIndex()); + +/** + * Find stations near a given position. + */ +export function near({ + maxDistance = Infinity, + maxResults = 10, + filter, + ...position +}: NearOptions): StationWithDistance[] { + const point = positionToPoint(position); + + const ids: number[] = around( + index, + ...point, + maxResults, + maxDistance, + filter ? (id: number) => filter(stations[id]!) : undefined, + ); + return ids.map((id) => { + const station = stations[id]!; + + return [station, distance(...point, ...positionToPoint(station))] as const; + }); +} + +/** + * Find the single nearest station to a given position. + */ +export function nearest(options: NearestOptions): StationWithDistance | null { + const results = near({ ...options, maxResults: 1 }); + return results[0] ?? null; +} + +export function positionToPoint(options: Position): [number, number] { + const longitude = + "longitude" in options + ? options.longitude + : "lon" in options + ? options.lon + : options.lng; + const latitude = "latitude" in options ? options.latitude : options.lat; + return [longitude, latitude]; +} diff --git a/src/stations.ts b/src/stations.ts new file mode 100644 index 000000000..ad6d5f8df --- /dev/null +++ b/src/stations.ts @@ -0,0 +1,14 @@ +import type { Station, StationData } from "./types.js"; + +const modules = import.meta.glob("./**/*.json", { + eager: true, + import: "default", + base: "../data", +}); + +export const stations: Station[] = Object.entries(modules).map( + ([path, data]) => { + const id = path.replace(/^\.\//, "").replace(/\.json$/, ""); + return { id, ...data }; + }, +); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..54441b0b6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,58 @@ +export interface HarmonicConstituent { + name: string; + description?: string; + amplitude: number; + phase: number; + speed?: number; +} + +export interface Constituent { + name: string; + description: string | null; + speed: number; +} + +export interface StationData { + // Basic station information + name: string; + continent: string; + country: string; + region?: string; + timezone: string; + disclaimers: string; + type: "reference" | "subordinate"; + latitude: number; + longitude: number; + + // Data source information + source: { + name: string; + id: string; + published_harmonics: boolean; + url: string; + }; + + // License information + license: { + type: string; + commercial_use: boolean; + url: string; + notes?: string; + }; + + // Harmonic constituents (empty array for subordinate stations) + harmonic_constituents: HarmonicConstituent[]; + + // Subordinate station offsets (empty object for reference stations) + offsets?: { + reference: string; + height: { high: number; low: number; type: "ratio" | "fixed" }; + time: { high: number; low: number }; + }; + + datums: Record; +} + +export interface Station extends StationData { + id: string; +} diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 000000000..57044cf01 --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect } from "vitest"; +import { near, nearest } from "../src/index.js"; + +describe("near", () => { + [ + { lat: 44, lon: -67 }, + { lat: 44, lng: -67 }, + { latitude: 44, longitude: -67 }, + ].forEach((coords) => { + test(Object.keys(coords).join("/"), () => { + const stations = near(coords); + expect(stations.length).toBeGreaterThan(0); + + const [station, distance] = stations[0]!; + expect(station.source.id).toBe("8411801"); + expect(distance).toBeCloseTo(70, 0); + }); + }); + + test("defaults to maxResults=10, maxDistance=Infinity", () => { + expect(near({ lat: 0, lon: 0 }).length).toBe(10); + }); + + test("can set maxResults", () => { + const stations = near({ lon: -67, lat: 44, maxResults: 5 }); + expect(stations.length).toBe(5); + }); + + test("can set maxDistance", () => { + const stations = near({ lon: -67.5, lat: 44.5, maxDistance: 10 }); + expect(stations.length).toBe(1); + }); + + test("can filter results", () => { + const stations = near({ + lon: -67, + lat: 44, + filter: (station) => station.type === "reference", + }); + expect(stations.length).toBe(10); + stations.forEach(([station]) => { + expect(station.type).toBe("reference"); + }); + }); +}); + +describe("nearest", () => { + test("returns the single nearest station", () => { + const [station, distance] = nearest({ lon: -75, lat: 23 }) || []; + expect(station).toBeDefined(); + expect(station!.source.id).toBe("TEC4633"); + expect(distance).toBeCloseTo(11, 0); + }); + + test("returns nearest with filter", () => { + const [station] = + nearest({ + lon: -75, + lat: 23, + filter: (s) => s.type === "reference", + }) || []; + expect(station).toBeDefined(); + expect(station!.source.id).toBe("9710441"); + }); + + test("returns null if no stations found", () => { + expect(nearest({ lon: 0, lat: 0, maxDistance: 1 })).toBe(null); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 498738f71..e0978b4c6 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,6 +7,6 @@ "types": ["node", "vite/client"], "allowImportingTsExtensions": true }, - "include": ["vite.config.js", "test/**/*", "tools/**/*"], + "include": ["vite.config.js", "test/**/*", "tools/**/*", "examples/**/*"], "exclude": ["src/**/*.ts"] } diff --git a/tsdown.config.js b/tsdown.config.js index 5f71df832..b7341804f 100644 --- a/tsdown.config.js +++ b/tsdown.config.js @@ -1,4 +1,5 @@ import { defineConfig } from "tsdown"; +import macros from "unplugin-macros/rolldown"; export default defineConfig({ entry: ["./src/index.ts"], @@ -8,4 +9,5 @@ export default defineConfig({ declarationMap: true, target: "es2020", platform: "neutral", + plugins: [macros()], });