diff --git a/index.js b/index.js index 1c8a43d5..776c0c45 100644 --- a/index.js +++ b/index.js @@ -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: @@ -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, diff --git a/package-lock.json b/package-lock.json index bf8acba5..778ae253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ ], "devDependencies": { "@openwaters/seascape": "*", + "maplibre-contour": "github:openwatersio/maplibre-contour#36f8207", "maplibre-gl": "^5.21.1", "pmtiles": "^4.4.0", "vite": "^8.0.2" @@ -1041,6 +1042,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-contour": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/openwatersio/maplibre-contour.git#36f82072a4428a77decc68b5fa2fc09bf3615f24", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "marching-squares": "^1.0.0" + } + }, "node_modules/maplibre-gl": { "version": "5.21.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", @@ -1076,6 +1086,16 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, + "node_modules/marching-squares": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/marching-squares/-/marching-squares-1.0.0.tgz", + "integrity": "sha512-uJQBBYw76MEFFR90erke9oLZYYM5Nv8yp870p8jpzqxit6WeIQ7qh7sXEUdygMlcdTJ4hw6T41qB5oFjJAldHQ==", + "dev": true, + "license": "AGPL-3.0", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1371,8 +1391,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/typescript": { "version": "5.9.3", diff --git a/package.json b/package.json index f06f2114..75a4b672 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/style/README.md b/style/README.md index 981f87c4..e47a8011 100644 --- a/style/README.md +++ b/style/README.md @@ -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 diff --git a/style/index.test.ts b/style/index.test.ts index 38adaac7..ef2cfffb 100644 --- a/style/index.test.ts +++ b/style/index.test.ts @@ -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, @@ -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 = {}; + const dem = { + contourProtocolUrl: (o: object) => { + opts = o as Record; + 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); diff --git a/style/index.ts b/style/index.ts index 1771915d..dcfc325e 100644 --- a/style/index.ts +++ b/style/index.ts @@ -202,12 +202,70 @@ 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, @@ -215,10 +273,13 @@ export function layers( }: { 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. // @@ -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 = [ @@ -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: { @@ -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, @@ -425,11 +499,15 @@ 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, }: { @@ -437,6 +515,7 @@ export function style({ flavor?: Flavor; glyphs?: string; osm?: boolean; + clientContours?: SourceSpecification; unit?: Unit; safety?: number; }): StyleSpecification { @@ -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", + }), + ], }; }