Skip to content
Draft
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
24 changes: 22 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { applyState, readDepth, style } from "@openwaters/seascape";
import mlcontour from "maplibre-contour";
import {
applyState,
clientContourSource,
readDepth,
style,
} from "@openwaters/seascape";

// The style itself (sources, layers, depth ramp, unit/safety expressions) lives
// in the @openwaters/seascape package (style/) — this file is the demo app:
Expand All @@ -17,12 +23,26 @@ const tilesBase = (
).replace(/\/$/, "");
const MAX_ZOOM = 13; // deepest zoom readDepth fetches (the Worker overzooms past it)

// ?contours=client — A/B the embedded contour tiles against isolines generated
// in the browser from the DEM (openwatersio/maplibre-contour fork).
let clientContours;
if (new URLSearchParams(location.search).get("contours") === "client") {
const dem = new mlcontour.DemSource({
url: `${tilesBase}/{z}/{x}/{y}.webp`,
encoding: "terrarium",
maxzoom: MAX_ZOOM,
worker: true,
});
dem.setupMaplibre(maplibregl);
clientContours = clientContourSource(dem);
}

// ─── Create map ───────────────────────────────────────────────────────────
// The style is self-contained: zooms/bounds/attribution come from the
// endpoint's TileJSON, so there's nothing to fetch before creating the map.
const map = new maplibregl.Map({
container: "map",
style: style({ tilesBase }),
style: style({ tilesBase, clientContours }),
bounds: BBOX,
hash: true,
dragRotate: false,
Expand Down
23 changes: 21 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"devDependencies": {
"@openwaters/seascape": "*",
"maplibre-contour": "github:openwatersio/maplibre-contour#36f8207",
"maplibre-gl": "^5.21.1",
"pmtiles": "^4.4.0",
"vite": "^8.0.2"
Expand Down
30 changes: 30 additions & 0 deletions style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ map.on("click", async (e) => {
});
```

## Client-side contours

Instead of the embedded contour tiles, the contour layers can read isolines
generated in the browser from the DEM, via the
[openwatersio/maplibre-contour](https://github.com/openwatersio/maplibre-contour)
fork (it adds fixed `lineLevels` — stock maplibre-contour only supports uniform
intervals, which can't express the INT isobath ladder):

```js
import mlcontour from "maplibre-contour"; // github:openwatersio/maplibre-contour
import { clientContourSource, style } from "@openwaters/seascape";

const dem = new mlcontour.DemSource({
url: `${tilesBase}/{z}/{x}/{y}.webp`,
encoding: "terrarium",
maxzoom: 13,
worker: true,
});
dem.setupMaplibre(maplibregl);
style({ tilesBase, clientContours: clientContourSource(dem) });
```

Soundings, drying, and coverage still come from the embedded vector source —
only the contour lines/labels switch. Trade-offs vs embedded: no Chaikin
smoothing and no fathom-curve geometry set (ft/fm labels unit-convert the
metric `INT_ISOBATHS_M` levels); in exchange, contours render at any zoom and
level changes need no pipeline rebuild. JS consumers only — the hosted
`/style.json` can't register the DEM protocol, so it always serves embedded
contours.

## Flavors

A flavor is a plain object of colors/fonts (`day` is the only built-in so
Expand Down
40 changes: 40 additions & 0 deletions style/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { expect, test } from "vitest";
import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec";
import {
applyState,
clientContourSource,
day,
INT_ISOBATHS_M,
state,
depthRelief,
sources,
Expand Down Expand Up @@ -91,6 +93,44 @@ test("layers reference only the caller's source names", () => {
).toEqual(["bathy", "bathy-dem"]);
});

test("client contour mode swaps only the contour layers", () => {
const byId = Object.fromEntries(
layers(day, { contours: "client" }).map((l) => [
l.id,
l as { source?: string; filter?: unknown; minzoom?: number },
]),
);
expect(byId["contour-lines"].source).toBe("seascape-contours");
expect(byId["contour-labels"].source).toBe("seascape-contours");
expect(byId["contour-lines"].filter).toBeUndefined(); // no sys filter on client isolines
expect(byId["contour-lines"].minzoom).toBe(6); // presentation floor applies in both modes
expect(byId["soundings"].source).toBe("seascape-vector"); // embedded carries the rest
expect(byId["drying-areas"].source).toBe("seascape-vector");
});

test("clientContourSource wires a DemSource into a validating style", () => {
let opts: Record<string, unknown> = {};
const dem = {
contourProtocolUrl: (o: object) => {
opts = o as Record<string, unknown>;
return "dem-contour://tiles/{z}/{x}/{y}";
},
};
const src = clientContourSource(dem) as {
type: string;
tiles: string[];
};
expect(src.tiles).toEqual(["dem-contour://tiles/{z}/{x}/{y}"]);
expect(opts.multiplier).toBe(-1); // positive-down depths
expect(opts.lineLevels).toEqual({ 0: INT_ISOBATHS_M }); // fixed INT levels
const s = style({
tilesBase: "https://t.example",
clientContours: clientContourSource(dem),
});
expect(validateStyleMin(s)).toEqual([]);
expect(Object.keys(s.sources)).toContain("seascape-contours");
});

test("contour lines floor at z6 — depth shading carries lower zooms", () => {
const lines = layers().find((l) => l.id === "contour-lines");
expect((lines as { minzoom?: number }).minzoom).toBe(6);
Expand Down
104 changes: 97 additions & 7 deletions style/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,23 +202,84 @@ export function sources({
};
}

// ─── Client-side contours (maplibre-contour) ─────────────────────────────────
// Alternative to the embedded contour tiles: generate isolines in the browser
// from the Terrarium DEM with the openwatersio/maplibre-contour fork (the fork
// adds fixed `lineLevels` — stock only does uniform intervals, which can't
// express the INT ladder). Fidelity trade vs embedded: no Chaikin smoothing,
// no fathom-curve geometry set (ft/fm labels are unit-converted from the
// metric levels), soundings/drying/coverage still come from the embedded
// vector source. In exchange: contours at any zoom and instant level changes
// with no pipeline rebuild.

// Standard INT isobaths (IHO S-4 B-411), metres positive-down. Keep in sync
// with CONTOUR_LEVELS in pipelines/config.py (the embedded tiles' levels).
export const INT_ISOBATHS_M = [
2, 5, 10, 20, 30, 50, 100, 200, 300, 500, 1000, 2000, 3000, 4000, 5000, 6000,
8000, 10000,
];

// The subset of maplibre-contour's DemSource this needs (structural — the
// consumer constructs it and calls setupMaplibre; the lib stays out of this
// package's dependencies).
export interface ClientDemSource {
contourProtocolUrl(options: object): string;
}

// Vector source spec for client-generated contours. The consumer owns the
// DemSource:
// const dem = new mlcontour.DemSource({ url: `${tilesBase}/{z}/{x}/{y}.webp`,
// encoding: "terrarium", maxzoom, worker: true });
// dem.setupMaplibre(maplibregl);
// style({ tilesBase, clientContours: clientContourSource(dem) });
export function clientContourSource(
demSource: ClientDemSource,
{ levels = INT_ISOBATHS_M, maxzoom = 15 }: {
levels?: number[];
maxzoom?: number;
} = {},
): SourceSpecification {
return {
type: "vector",
tiles: [
demSource.contourProtocolUrl({
multiplier: -1, // DEM is negative below datum; flip to positive depth
contourLayer: "contours",
elevationKey: "ele",
levelKey: "level",
lineLevels: { 0: levels }, // fixed INT levels at every zoom
overzoom: 1,
}),
],
maxzoom,
};
}

// ─── Layers ──────────────────────────────────────────────────────────────────
export function layers(
flavor: Flavor = day,
{
dem = "seascape-dem",
vector = "seascape-vector",
// "client": contour lines/labels read client-generated isolines (see
// clientContourSource) from `clientVector`; everything else (soundings,
// drying, coverage) stays on the embedded vector source.
contours = "embedded",
clientVector = "seascape-contours",
// Baked into the initial depth-shading ramp; keep in sync with the state
// defaults (runtime changes go through depthRelief() + setPaintProperty).
unit = state.unit.default,
safety = state.safety.default,
}: {
dem?: string;
vector?: string;
contours?: "embedded" | "client";
clientVector?: string;
unit?: Unit;
safety?: number;
} = {},
): LayerSpecification[] {
const client = contours === "client";
// One global-state variable — `unit` — drives every sounding/contour label
// and which isobaths show.
//
Expand Down Expand Up @@ -257,6 +318,18 @@ export function layers(
["concat", ["to-string", ["get", "depth_fm"]], "fm"],
["concat", ["to-string", ["get", "depth_abs_m"]], "m"],
];
// Client isolines carry only `ele` (metres positive-down, no fathom-curve
// set), so ft/fm labels unit-convert the metric levels instead of switching
// to fathom-curve geometries.
const clientLabelText: ExpressionSpecification = [
"case",
["==", UNIT, "ft"],
["concat", ["to-string", ["round", ["*", ["get", "ele"], 3.28084]]], "ft"],
["==", UNIT, "fm"],
["concat", ["to-string", ["round", ["/", ["get", "ele"], 1.8288]]], "fm"],
["concat", ["to-string", ["get", "ele"]], "m"],
];
const contourSource = client ? clientVector : vector;

// Shared label styling so soundings and contour labels read as one chart.
const labelSize: ExpressionSpecification = [
Expand Down Expand Up @@ -307,9 +380,10 @@ export function layers(
{
id: "contour-lines",
type: "line",
source: vector,
source: contourSource,
"source-layer": "contours",
filter: contourLineFilter,
// Client isolines are one metric set — no sys filter to apply.
...(client ? {} : { filter: contourLineFilter }),
// Presentation floor, not a data limit: below z6 isobaths read as clutter over depth shading.
minzoom: 6,
paint: {
Expand All @@ -321,13 +395,13 @@ export function layers(
{
id: "contour-labels",
type: "symbol",
source: vector,
source: contourSource,
"source-layer": "contours",
filter: contourLineFilter,
...(client ? {} : { filter: contourLineFilter }),
minzoom: 8,
layout: {
"symbol-placement": "line",
"text-field": contourLabelText,
"text-field": client ? clientLabelText : contourLabelText,
"text-size": labelSize,
"text-font": flavor.font,
"text-letter-spacing": 0.1,
Expand Down Expand Up @@ -425,18 +499,23 @@ export function layers(
// layers-only over your own basemap) + the bathymetry sources and layers.
// `unit`/`safety` bake mariner defaults into both the depth ramp and the
// style's global-state defaults, so labels and tint always agree.
// `clientContours` (a clientContourSource() spec) switches the contour layers
// to client-generated isolines — a JS-consumer feature; the hosted /style.json
// can't register the DEM protocol, so it always serves embedded contours.
export function style({
tilesBase,
flavor = day,
glyphs = DEFAULT_GLYPHS,
osm = true,
clientContours,
unit = state.unit.default,
safety = state.safety.default,
}: {
tilesBase: string;
flavor?: Flavor;
glyphs?: string;
osm?: boolean;
clientContours?: SourceSpecification;
unit?: Unit;
safety?: number;
}): StyleSpecification {
Expand All @@ -459,8 +538,19 @@ export function style({
name: "Open Waters Seascape",
glyphs,
state: { unit: { default: unit }, safety: { default: safety } },
sources: { ...osmSource, ...sources({ tilesBase }) },
layers: [...osmBase, ...layers(flavor, { unit, safety })],
sources: {
...osmSource,
...sources({ tilesBase }),
...(clientContours ? { "seascape-contours": clientContours } : {}),
},
layers: [
...osmBase,
...layers(flavor, {
unit,
safety,
contours: clientContours ? "client" : "embedded",
}),
],
};
}

Expand Down
Loading