Skip to content
Open
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
7 changes: 6 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ jobs:
mv packages/tcd/dist/harmonics-metric.tcd "packages/tcd/dist/neaps-${RELEASE_VERSION##*.}-metric.tcd"
mv packages/tcd/dist/harmonics-imperial.tcd "packages/tcd/dist/neaps-${RELEASE_VERSION##*.}-imperial.tcd"

- name: Build PMTiles
run: npm run build -w tiles
env:
VERSION: ${{ env.RELEASE_VERSION }}

- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
tag: v${{ env.RELEASE_VERSION }}
generateReleaseNotes: true
artifacts: "packages/tcd/dist/*.tcd"
artifacts: "packages/tcd/dist/*.tcd,packages/tiles/dist/*.pmtiles,packages/tiles/dist/*.mbtiles"
90 changes: 90 additions & 0 deletions packages/tiles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Neaps Tide Database - Vector Tiles (PMTiles)

This package generates a [PMTiles](https://docs.protomaps.com/pmtiles/) vector tileset of the Neaps Tide Database for use with [MapLibre GL](https://maplibre.org) and other renderers that read Mapbox Vector Tiles.

> [!WARNING]
> This data is **NOT FOR NAVIGATION**. See the per-station `disclaimers` and `license` properties.

## Usage

Download the latest `neaps.pmtiles` from [releases](https://github.com/openwatersio/tide-database/releases) (the stable URL `https://github.com/openwatersio/tide-database/releases/latest/download/neaps.pmtiles` always points at the newest build) and host it on any static file server or CDN that supports HTTP range requests.

```js
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";

maplibregl.addProtocol("pmtiles", new Protocol().tile);

map.addSource("tides", {
type: "vector",
url: "pmtiles://https://example.com/neaps.pmtiles",
});

map.addLayer({
id: "tide-stations",
type: "circle",
source: "tides",
"source-layer": "stations",
paint: {
"circle-color": [
"match",
["get", "type"],
"reference",
"#1d4ed8",
"#60a5fa",
],
},
});

map.on("click", "tide-stations", ({ features }) => {
const station = features[0].properties;
// Nested objects are encoded as JSON strings (see "Encoding" below)
const constituents = JSON.parse(station.harmonic_constituents);
const datums = JSON.parse(station.datums);
// ... predict tides with @neaps/tide-predictor
});
```

## Tileset structure

A single source-layer named `stations` contains every station as a point feature from zoom 0 through 10 (renderers overzoom beyond that). No stations are ever dropped, but properties vary by zoom to keep tiles small:

- **z0–7 (lean)**: `id`, `name`, `type` — enough to draw and label dots.
- **z8–10 (full)**: everything below. To read full station data, query a tile at z8+.

## Properties

| Property | Type | Notes |
| ----------------------- | ----------- | ---------------------------------------------- |
| `id` | string | `<source>/<source_id>`, e.g. `noaa/9414290` |
| `name` | string | |
| `type` | string | `reference` or `subordinate` |
| `country` | string | Full country name |
| `continent` | string | |
| `region` | string | Optional |
| `timezone` | string | IANA timezone |
| `chart_datum` | string | Key into `datums`, e.g. `MLLW`, `LAT` |
| `disclaimers` | string | Optional |
| `datums` | JSON string | `{ "MLLW": 1.01, "MSL": 2.532, ... }` |
| `harmonic_constituents` | JSON string | `[{ "name": "M2", "amplitude", "phase" },...]` |
| `offsets` | JSON string | Subordinate stations only |
| `source` | JSON string | `{ name, id, published_harmonics, url }` |
| `license` | JSON string | `{ type, commercial_use, url }` |
| `epoch` | JSON string | Optional; `{ start, end }` dates |

Vector tile properties only support scalar values, so nested objects are encoded as JSON strings — call `JSON.parse()` on them. Subordinate stations include the `datums` and `harmonic_constituents` of their reference station, so each feature is self-sufficient for prediction.

### Feature ids

Each feature's numeric id is a stable 53-bit [FNV-1a](https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function) hash of the string station `id` (see `features.ts`), usable with MapLibre's [`setFeatureState`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#setfeaturestate). Ids are consistent across releases.

## Licensing

Station data licensing varies by source — check each feature's `license` property (e.g. public domain for NOAA, CC BY 4.0 for TICON-4). Attribution is embedded in the tileset metadata.

## Contributing

Building requires Docker (uses the [`ghcr.io/openwatersio/tippecanoe`](https://github.com/openwatersio/tippecanoe) image):

- Build with `npm run build` — generates NDJSON via `build.ts`, runs tippecanoe for the lean (z0–7) and full (z8–10) variants, and merges them with `tile-join` into `dist/neaps.pmtiles`.
- Test with `npm test`.
47 changes: 47 additions & 0 deletions packages/tiles/build
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/bash

set -e
cd "$(dirname "$0")"

VERSION="${VERSION:-$(node -p "require('../../package.json').version")}"

# Remove any existing files to ensure a clean build
rm -rf dist
mkdir -p dist/tmp

# Generate newline-delimited GeoJSON for each variant
node build.ts

# Lean tiles (z0-7): id/name/type only. Drop flags ensure no station is ever
# dropped from a tile, regardless of density.
docker compose run --rm tippecanoe \
--output=/dist/tmp/stations-lean.pmtiles \
--layer=stations \
--minimum-zoom=0 --maximum-zoom=7 \
--drop-rate=1 --no-feature-limit --no-tile-size-limit \
--force \
/dist/stations-lean.ndjson

# Full tiles (z8-10): all station data, nested objects as JSON strings
docker compose run --rm tippecanoe \
--output=/dist/tmp/stations-full.pmtiles \
--layer=stations \
--minimum-zoom=8 --maximum-zoom=10 \
--drop-rate=1 --no-feature-limit --no-tile-size-limit \
--force \
/dist/stations-full.ndjson

# Merge into a single tileset with one "stations" layer spanning z0-10.
# tile-join picks the format from the output extension.
for ext in pmtiles mbtiles; do
docker compose run --rm tile-join \
--output=/dist/neaps.${ext} \
--no-tile-size-limit \
--force \
--name="Neaps Tide Stations" \
--description="Tide stations from @neaps/tide-database v${VERSION}. NOT FOR NAVIGATION." \
--attribution="<a href=\"https://tidesandcurrents.noaa.gov\">NOAA</a>; <a href=\"https://www.seanoe.org/data/00980/109129/\">TICON-4</a>" \
/dist/tmp/stations-lean.pmtiles /dist/tmp/stations-full.pmtiles
done

rm -rf dist/tmp
50 changes: 50 additions & 0 deletions packages/tiles/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Generates newline-delimited GeoJSON for the vector tile build. Two variants
* are produced: a lean one (id/name/type) for low-zoom tiles and a full one
* (all station data) for high-zoom tiles. The `build` script feeds these to
* tippecanoe and merges the results into a single PMTiles file.
*/

import { writeFile, mkdir } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { stations } from "@neaps/tide-database";
import { featureId, toFeature, type Variant } from "./features.ts";

const outDir = join(dirname(fileURLToPath(import.meta.url)), "dist");

// Web Mercator latitude limit — tippecanoe silently clips beyond it
const MAX_LATITUDE = 85.0511;

const sorted = [...stations].sort((a, b) => a.id.localeCompare(b.id));

const ids = new Map<number, string>();
for (const station of sorted) {
if (
Math.abs(station.latitude) > MAX_LATITUDE ||
Math.abs(station.longitude) > 180
) {
throw new Error(`Station ${station.id} is outside Web Mercator bounds`);
}

const id = featureId(station.id);
const existing = ids.get(id);
if (existing) {
throw new Error(
`Feature id collision between ${existing} and ${station.id}`,
);
}
ids.set(id, station.id);
}

await mkdir(outDir, { recursive: true });

for (const variant of ["lean", "full"] as Variant[]) {
const ndjson = sorted
.map((station) => JSON.stringify(toFeature(station, variant)))
.join("\n");
await writeFile(join(outDir, `stations-${variant}.ndjson`), ndjson + "\n");
}

console.log(`Wrote ${sorted.length} stations`);
12 changes: 12 additions & 0 deletions packages/tiles/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
tippecanoe:
image: ghcr.io/openwatersio/tippecanoe:2.79.0
volumes:
- ./dist:/dist
entrypoint: ["tippecanoe"]

tile-join:
image: ghcr.io/openwatersio/tippecanoe:2.79.0
volumes:
- ./dist:/dist
entrypoint: ["tile-join"]
67 changes: 67 additions & 0 deletions packages/tiles/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Station } from "@neaps/tide-database";

export type Variant = "lean" | "full";

/**
* 64-bit FNV-1a hash of the station id, truncated to 53 bits so it fits in a
* JavaScript safe integer. MVT feature ids must be unsigned integers, and a
* stable hash keeps ids consistent across builds as stations come and go.
*/
export function featureId(id: string): number {
let hash = 0xcbf29ce484222325n;
for (let i = 0; i < id.length; i++) {
hash ^= BigInt(id.charCodeAt(i));
hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
}
return Number(hash & 0x1fffffffffffffn);
}

type Properties = Record<string, string | number | boolean>;

/**
* Vector tile properties only support scalar values, so nested objects
* (datums, harmonic_constituents, offsets, source, license, epoch) are
* encoded as JSON strings for clients to JSON.parse.
*/
function properties(station: Station, variant: Variant): Properties {
const lean: Properties = {
id: station.id,
name: station.name,
type: station.type,
};

if (variant === "lean") return lean;

return {
...lean,
country: station.country,
continent: station.continent,
timezone: station.timezone,
chart_datum: station.chart_datum,
source: JSON.stringify(station.source),
license: JSON.stringify(station.license),
...(station.region && { region: station.region }),
...(station.disclaimers && { disclaimers: station.disclaimers }),
...(station.datums &&
Object.keys(station.datums).length > 0 && {
datums: JSON.stringify(station.datums),
}),
...(station.harmonic_constituents?.length > 0 && {
harmonic_constituents: JSON.stringify(station.harmonic_constituents),
}),
...(station.offsets && { offsets: JSON.stringify(station.offsets) }),
...(station.epoch && { epoch: JSON.stringify(station.epoch) }),
};
}

export function toFeature(station: Station, variant: Variant) {
return {
type: "Feature",
id: featureId(station.id),
geometry: {
type: "Point",
coordinates: [station.longitude, station.latitude],
},
properties: properties(station, variant),
};
}
16 changes: 16 additions & 0 deletions packages/tiles/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "tiles",
"private": true,
"type": "module",
"scripts": {
"build": "./build",
"pretest": "npm run build",
"test": "vitest"
},
"devDependencies": {
"@mapbox/vector-tile": "^2.0.3",
"@neaps/tide-database": "file:../..",
"pbf": "^4.0.1",
"pmtiles": "^4.3.0"
}
}
56 changes: 56 additions & 0 deletions packages/tiles/test/pmtiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { openSync, readSync } from "fs";
import { PMTiles, type Source, type RangeResponse } from "pmtiles";
import { VectorTile } from "@mapbox/vector-tile";
import Protobuf from "pbf";

/** Byte-range source for reading a local .pmtiles file. */
class FileSource implements Source {
private fd: number;

constructor(private path: string) {
this.fd = openSync(path, "r");
}

getKey(): string {
return this.path;
}

async getBytes(offset: number, length: number): Promise<RangeResponse> {
const buffer = Buffer.alloc(length);
readSync(this.fd, buffer, 0, length, offset);
return {
Comment on lines +1 to +21
data: buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + length,
) as ArrayBuffer,
};
}
}

export function openPMTiles(path: string): PMTiles {
return new PMTiles(new FileSource(path));
}

/** Web Mercator tile coordinates containing the given location. */
export function lonLatToTile(lon: number, lat: number, zoom: number) {
const n = 2 ** zoom;
const latRad = (lat * Math.PI) / 180;
return {
x: Math.floor(((lon + 180) / 360) * n),
y: Math.floor(
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
n,
),
};
}

export function decodeTile(data: ArrayBuffer): VectorTile {
return new VectorTile(new Protobuf(new Uint8Array(data)));
}

/** All features in a tile's layer, decoded to GeoJSON-ish objects. */
export function getFeatures(tile: VectorTile, layerName = "stations") {
const layer = tile.layers[layerName];
if (!layer) return [];
return Array.from({ length: layer.length }, (_, i) => layer.feature(i));
}
Loading
Loading