Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions examples/search.ts
Original file line number Diff line number Diff line change
@@ -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)`,
);
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
4 changes: 4 additions & 0 deletions src/constituents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import _constituents from "./constituents.json" with { type: "json" };
import type { Constituent } from "./types.js";

export const constituents: Constituent[] = _constituents;
79 changes: 4 additions & 75 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

export interface Station extends StationData {
id: string;
}

export const constituents: Constituent[] = _constituents;

const modules = import.meta.glob<StationData>("./**/*.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";
32 changes: 32 additions & 0 deletions src/search-index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
70 changes: 70 additions & 0 deletions src/search.ts
Original file line number Diff line number Diff line change
@@ -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];
}
14 changes: 14 additions & 0 deletions src/stations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Station, StationData } from "./types.js";

const modules = import.meta.glob<StationData>("./**/*.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 };
},
);
58 changes: 58 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

export interface Station extends StationData {
id: string;
}
Loading