Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
276 lines (262 loc) · 11 KB
/
Copy pathindex.js
File metadata and controls
276 lines (262 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import {
applyState,
BANDS_MIN_ZOOM,
readDepth,
style,
SCHEMA,
} 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:
// endpoint config, manifest fetch, UI controls, click-to-inspect.
//
// VITE_TILES_BASE is the full bathymetry endpoint base — it includes the
// /seascape route prefix in prod (e.g. https://tiles.openwaters.io/seascape);
// the dev default is same-origin (the Vite dev server runs the tile Worker
// in-process via the Cloudflare plugin). VITE_BBOX sets the initial view.
// ?tiles=<base> overrides it at runtime so this one viewer (deployed or local)
// can inspect any build preview, e.g.
// ?tiles=https://data.openwaters.io/bathymetry/build/<sha>.
// #W,S,E,N in the URL sets the initial view (e.g. #174.72,-36.45,174.86,-36.28).
// It doesn't parse as MapLibre's #zoom/lat/lng hash, so hash:true ignores it,
// falls back to `bounds`, and rewrites the hash on first move.
const hashBbox = location.hash.slice(1).split(",").map(Number);
const BBOX =
hashBbox.length === 4 && hashBbox.every(Number.isFinite)
? hashBbox
: import.meta.env.VITE_BBOX
? import.meta.env.VITE_BBOX.split(",").map(Number)
: [-180, -85, 180, 85];
const tilesBase = (
new URLSearchParams(location.search).get("tiles") ||
import.meta.env.VITE_TILES_BASE ||
""
).replace(/\/$/, "");
const MAX_ZOOM = 13; // deepest zoom readDepth fetches (the Worker overzooms past it)
// ─── 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 }),
bounds: BBOX,
hash: true,
dragRotate: false,
pitchWithRotate: false,
dragRotate: false,
attributionControl: { compact: true },
});
window.map = map; // exposed for debugging / verification
// Schema assert (docs/schema.md): a tileset whose contract moved past this
// package's SCHEMA can decode plausibly but wrongly (a wrong depth renders
// exactly like a right one), so a mismatch is fatal — replace the map, don't
// warn over it. TileJSON without a schema field (pre-schema release) passes.
fetch(`${tilesBase}/vector.json`)
.then((r) => (r.ok ? r.json() : null))
.then((tj) => {
if (tj?.schema === undefined || tj.schema === SCHEMA) return;
map.remove();
const el = document.getElementById("map");
const msg = document.createElement("div");
msg.style.cssText =
"display:flex;height:100%;align-items:center;justify-content:center;text-align:center;padding:2em;font:16px/1.5 sans-serif";
msg.textContent =
`These tiles use schema version ${tj.schema}, but this viewer was built ` +
`for schema version ${SCHEMA}. Rendering could silently show wrong depths, ` +
`so the chart is disabled. Update the viewer to match the tiles.`;
el.replaceChildren(msg);
})
.catch(() => {}); // unreachable TileJSON already surfaces as a map load error
map.addControl(new maplibregl.NavigationControl());
map.addControl({
onAdd: () => document.getElementById("controls"),
onRemove: () => {},
});
// No setTerrain(): enabling 3D terrain drapes the DEM layers (depth-shading, hillshade)
// over MapLibre's terrain mesh, which resamples the land+water DEM coarsely below native
// zoom. Near coasts the large positive land values bleed across the 0 m transparency
// cutoff, so narrow/shallow water (bays, sounds, shoals) renders transparent at z<8 —
// leaving only the deep open water, which then looks like coarse GEBCO. color-relief reads
// the tiles at native resolution instead. Click-depth reads the DEM tile directly
// (readDepth), so terrain isn't needed for it. Re-add behind a toggle for 3D seafloor.
// ─── Layer toggles ────────────────────────────────────────────────────────
const toggles = {
"toggle-depth": ["depth-shading", "depth-areas"],
"toggle-hillshade": ["depth-hillshade"],
"toggle-contours": ["contour-lines", "contour-labels"],
"toggle-soundings": ["soundings"],
"toggle-osm": ["osm-base"],
"toggle-land": ["land-hillshade"],
"toggle-sources": [
"source-fill",
"source-highlight",
"source-outline",
"source-labels",
],
};
map.on("load", () => {
// Mapterhorn (mapterhorn.com): open global terrain DEM, far finer on land
// than the bathymetry mosaic. Ocean is flat 0 there, so a hillshade shades
// land only — attribution rides in via its TileJSON. Demo-only; the style
// package stays bathymetry.
map.addSource("mapterhorn", {
type: "raster-dem",
url: "https://tiles.mapterhorn.com/tilejson.json",
tileSize: 512,
encoding: "terrarium", // MapLibre doesn't read encoding from TileJSON
});
map.addLayer(
{
id: "land-hillshade",
type: "hillshade",
source: "mapterhorn",
paint: {
"hillshade-exaggeration": 0.3,
"hillshade-shadow-color": "#8a7455",
"hillshade-highlight-color": "#fffdf2",
"hillshade-accent-color": "#b3a184",
},
},
"contour-lines", // under the chart linework, over the land wash
);
// Mariner settings — unit (m/ft/fm), safety depth (metres, 0 = off; drives the
// hazard tint and black-sounding emphasis, S-52 style), and shading mode: relief
// (raster ramp, continuous) vs bands (vector ENC depth areas — crisp isobath
// edges, safety snapped to a charted level). The style carries the settings as
// literals; applyState re-derives every dependent property from the full current
// values, so the controls just forward them. shading gates the depare fill's own
// filter (it hides the depth bands but keeps drying/unknown-depth water), so it
// rides through applyState too — the raster only needs its zoom range adjusted:
// bands data floors at BANDS_MIN_ZOOM, so relief keeps the zooms below, and
// never both at once (the 0.85 opacities would compound).
const apply = () => {
const shading =
document.getElementById("shading-select")?.value === "bands"
? "bands"
: "relief";
applyState(map, {
unit: document.getElementById("unit-select")?.value || "m",
safety: parseFloat(document.getElementById("safety-depth")?.value) || 0,
shading,
});
if (map.getLayer("depth-shading"))
map.setLayerZoomRange(
"depth-shading",
0,
shading === "bands" ? BANDS_MIN_ZOOM : 24,
);
};
apply();
document.getElementById("safety-depth")?.addEventListener("input", apply);
document.getElementById("unit-select")?.addEventListener("change", apply);
document.getElementById("shading-select")?.addEventListener("change", apply);
// Layer toggles: the checkboxes are the source of truth — sync once on load
// (the style's own defaults may differ, e.g. hillshade ships hidden) and on
// every change.
for (const [inputId, layerIds] of Object.entries(toggles)) {
const input = document.getElementById(inputId);
if (!input) continue;
const sync = () => {
const vis = input.checked ? "visible" : "none";
layerIds.forEach((id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", vis);
});
};
sync();
input.addEventListener("change", sync);
}
});
// ─── Debug: bbox readout/zoom + tile grid ─────────────────────────────────
// One field for both directions: it tracks the viewport bbox (W,S,E,N), and
// editing it + Enter fits the map to the typed bbox (the resulting moveend
// then rewrites it with the actual fitted bounds).
const bboxInput = document.getElementById("bbox-input");
const showBbox = () =>
(bboxInput.value = map
.getBounds()
.toArray()
.flat()
.map((n) => n.toFixed(4))
.join(","));
map.on("moveend", showBbox);
showBbox();
bboxInput.addEventListener("change", () => {
const b = bboxInput.value.split(",").map(Number);
if (b.length === 4 && b.every(Number.isFinite)) map.fitBounds(b);
});
// MapLibre's native debug overlay: tile borders + z/x/y labels per source
document
.getElementById("toggle-tiles")
.addEventListener(
"change",
(e) => (map.showTileBoundaries = e.target.checked),
);
// ─── Click to inspect ─────────────────────────────────────────────────────
// Which sources cover a clicked point, deepest footprint first (lex-first id on
// a tie) — the build's merge rule, so [0] names the source the depth came from
// and the rest are the overlapped alternates. Deduped by id: a footprint split
// across tile boundaries hits once per fragment.
// undefined → coverage layer hidden (skip the line); [] → no footprint = GEBCO.
function sourcesAt(point) {
if (map.getLayoutProperty("source-fill", "visibility") === "none")
return undefined;
const hits = map.queryRenderedFeatures(point, { layers: ["source-fill"] });
const byId = new Map(hits.map((f) => [f.properties.source_id, f.properties]));
return [...byId.values()].sort(
(a, b) =>
b.source_maxzoom - a.source_maxzoom ||
(a.source_id < b.source_id ? -1 : 1),
);
}
map.on("click", async (e) => {
const ele = await readDepth(
tilesBase,
e.lngLat,
Math.min(map.getZoom(), MAX_ZOOM),
);
const srcs = sourcesAt(e.point);
if (map.getLayer("source-highlight"))
map.setFilter("source-highlight", [
"==",
["get", "source_id"],
srcs?.[0]?.source_id ?? "__none__",
]);
const lines = [];
if (ele != null) {
// Floored toward shallower, like the soundings (soundings_run.py): a reported
// depth must never overstate the water, so 16.6 m reads 16, not 17.
const depth = -ele;
// Non-negative values are category codes (0 unknown water, 1 drying, 2 land);
// overzoom fractions between codes round to the nearest one.
lines.push(
`<strong>${
ele < 0
? `${Math.floor(depth)}m (${Math.floor(depth * 3.28084)}ft)`
: ["unknown depth", "drying", "land"][Math.min(Math.round(ele), 2)]
}</strong>`,
);
}
if (srcs !== undefined) {
lines.push(
`<small>source: ${srcs[0]?.source_name ?? "GEBCO (global)"}</small>`,
);
for (const s of srcs.slice(1))
lines.push(`<small>also covered by: ${s.source_name}</small>`);
}
if (!lines.length) return;
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(lines.join("<br>"))
.addTo(map);
});
map.on(
"mouseenter",
"contour-lines",
() => (map.getCanvas().style.cursor = "pointer"),
);
map.on(
"mouseleave",
"contour-lines",
() => (map.getCanvas().style.cursor = ""),
);