Add OpenSeaMap / OpenSnowMap overlay layers (#10)

Two independently toggleable open-data raster overlays — OpenSeaMap
sea marks (buoys, lighthouses, ports) and OpenSnowMap ski pistes —
drawn as Cesium.ImageryLayers directly on the Cesium globe surface.
Same "small array of descriptors" shape as MAP_STACKS in
mapStackController.js.

Visible on the osm/bing-aerial/bing-labels map stacks and invisible
(not broken — the layer's stats say so in plain text) on the default
photoreal stack, since Google Photorealistic 3D Tiles render with the
globe hidden.

OpenRailwayMap and OpenInfraMap, also requested in #10, are
deliberately NOT included in this pass: OpenRailwayMap's public tile
server enforces a Referer allowlist scoped to its own site (confirmed
by direct request — every other Referer, including none, 403s), and
OpenInfraMap serves MapLibre GL vector tiles, not raster XYZ — that
would need a genuine vector-tile rendering pipeline, not a
Cesium.ImageryLayer drop-in. Worth its own dedicated pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Tom-NeverwinterandClaude Sonnet 5 committed 2026-09-12 17:07:33 -05:00
1 parent db49edf363
commit feba5af0b9
8 files changed
+270 -2

No files matched your search

+12
View File
@@ -66,6 +66,18 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'openseamap',
html:
'Sea marks overlay: ' +
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a>',
},
{
key: 'opensnowmap',
html:
'Ski pistes overlay: Map data © OpenStreetMap contributors, SRTM | Map style © ' +
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">OpenSnowMap.org</a> (CC BY-SA)',
},
{
key: 'overpass',
html:
+9
View File
@@ -223,6 +223,14 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
// Two independently toggleable open-data raster overlays (#10) sharing one
// owner — same "one master row, several booleans" shape as `cctv`/`radio`
// above, not a new mechanism. Both default ON: switching the row on with
// nothing yet chosen would show nothing and read as broken.
osmOverlays: Object.freeze([
booleanOption('seamap', 's', true),
booleanOption('snowmap', 'n', true),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -286,6 +294,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights' }),
Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only' }),
Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only' }),
Object.freeze({ id: 'osm-overlays', token: 'j', disposition: 'enabled+options', optionOwner: 'osmOverlays' }),
Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio' }),
Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only' }),
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
+2 -2
View File
@@ -155,8 +155,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 16);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
assert.equal(REGISTERED_LAYER_IDS.length, 17);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
+191
View File
@@ -0,0 +1,191 @@
import * as Cesium from 'cesium';
/**
* @file Open-data raster overlays drawn on the Cesium GLOBE surface —
* OpenSeaMap sea marks and OpenSnowMap ski pistes (#10). Two independently
* toggleable overlays sharing one config-driven module, same "small array of
* descriptors" shape as `MAP_STACKS` in mapStackController.js.
*
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately NOT
* included in this pass:
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) enforces
* a Referer allowlist scoped to openrailwaymap.org itself — verified by
* direct request: a Referer of `https://www.openrailwaymap.org/` returns
* the tile, every other Referer (including none) returns 403, regardless
* of User-Agent. A browser tab on this app sends ITS OWN origin as
* Referer, so the tiles would 403 in practice, and embedding them here
* would run against their stated tile-usage policy either way
* (CONTRIBUTING.md: "Don't add scraping of sources whose terms forbid it").
* - OpenInfraMap serves MapLibre GL vector tiles (MVT) from its own
* infrastructure, not simple raster XYZ tiles — adding it means a genuine
* vector-tile rendering pipeline, not a `Cesium.ImageryLayer` drop-in.
* Worth its own dedicated pass; out of scope here.
*
* Like every non-photoreal-stack raster source, these overlays draw on the
* Cesium globe (`viewer.scene.globe`), not on the Google Photorealistic 3D
* Tiles mesh (see `mapStackController.js`: the photoreal stack renders with
* `globe.show = false`, and a hidden globe requests and draws no imagery).
* They're visible on the `osm`/`bing-aerial`/`bing-labels` map stacks and
* invisible — not broken, just nothing to draw on — on the default
* `photoreal` stack. `getStats().error` surfaces that in plain language
* whenever an enabled overlay is currently showing nothing for this reason.
* @module data/osmOverlays
*/
export const OSM_OVERLAYS_LAYER_ID = 'osm-overlays';
/** @type {Array<{key:string,label:string,icon:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
export const OSM_OVERLAYS = Object.freeze([
Object.freeze({
key: 'seamap',
label: 'Sea Marks',
icon: '⚓',
// Confirmed live 2026-08-25: https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
credit: '© OpenSeaMap contributors',
maximumLevel: 18,
}),
Object.freeze({
key: 'snowmap',
label: 'Ski Pistes',
icon: '⛷',
// Confirmed live 2026-08-25: https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
credit: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenSnowMap.org (CC-BY-SA)',
maximumLevel: 18,
}),
]);
const DEFAULT_PARAMS = Object.freeze(
Object.fromEntries(OSM_OVERLAYS.map((overlay) => [overlay.key, true])),
);
/** Reason an enabled, chosen overlay is currently showing nothing, or null. */
export function osmOverlaysHiddenReason(enabled, params, globeShown) {
if (!enabled) return null;
const anyChosen = OSM_OVERLAYS.some((overlay) => Boolean(params?.[overlay.key]));
if (!anyChosen) return null;
if (globeShown) return null;
return 'Overlays draw on the globe surface — switch the map stack to OSM or Bing (not Google 3D) to see them';
}
/**
* Sanitize a candidate params object against the two known overlay keys.
* Unknown keys are ignored; a non-boolean value for a known key leaves that
* key's current value untouched rather than coercing it (mirrors the
* `undefined` = "not requested" convention `satellites.js`/`radio.js` use).
* @param {object} current — the module's live `{seamap, snowmap}` state
* @param {object} candidate
* @returns {{next: object, changed: boolean}}
*/
export function applyOsmOverlayParams(current, candidate = {}) {
const next = { ...current };
let changed = false;
for (const overlay of OSM_OVERLAYS) {
if (!Object.hasOwn(candidate, overlay.key)) continue;
const value = candidate[overlay.key];
if (typeof value !== 'boolean') continue;
if (next[overlay.key] !== value) changed = true;
next[overlay.key] = value;
}
return { next, changed };
}
const state = {
viewer: null,
enabled: false,
params: { ...DEFAULT_PARAMS },
imageryLayers: new Map(),
};
function syncImageryLayerVisibility() {
for (const overlay of OSM_OVERLAYS) {
const layer = state.imageryLayers.get(overlay.key);
if (layer) layer.show = state.enabled && Boolean(state.params[overlay.key]);
}
}
const osmOverlaysLayer = {
id: OSM_OVERLAYS_LAYER_ID,
name: 'Open Map Overlays',
icon: '🗺️',
source: 'OpenSeaMap / OpenSnowMap',
updateInterval: 0,
init(viewer) {
state.viewer = viewer;
for (const overlay of OSM_OVERLAYS) {
const provider = new Cesium.UrlTemplateImageryProvider({
url: overlay.urlTemplate,
credit: overlay.credit,
maximumLevel: overlay.maximumLevel,
});
const layer = new Cesium.ImageryLayer(provider);
layer.show = false;
// Appended (no index) so it draws on top of the base map-stack imagery
// layer mapStackController.js keeps at index 0.
viewer.imageryLayers.add(layer);
state.imageryLayers.set(overlay.key, layer);
}
},
enable() {
state.enabled = true;
syncImageryLayerVisibility();
},
disable() {
state.enabled = false;
syncImageryLayerVisibility();
},
update() {
return true;
},
setParams(params = {}) {
const { next, changed } = applyOsmOverlayParams(state.params, params);
if (changed) {
state.params = next;
syncImageryLayerVisibility();
}
return true;
},
getParams() {
return { ...state.params };
},
getRowControls() {
if (!state.enabled) return { chips: [], legend: [] };
const globeShown = state.viewer?.scene?.globe?.show !== false;
return {
chips: OSM_OVERLAYS.map((overlay) => {
const active = Boolean(state.params[overlay.key]);
return {
id: overlay.key,
label: `${overlay.icon} ${overlay.label}`,
active,
title: globeShown
? overlay.credit
: `${overlay.credit} — needs the OSM or Bing map stack to render, not Google 3D`,
params: { [overlay.key]: !active },
};
}),
legend: [],
};
},
destroy(viewer) {
this.disable();
for (const layer of state.imageryLayers.values()) {
if (viewer) viewer.imageryLayers.remove(layer, true);
}
state.imageryLayers.clear();
state.viewer = null;
state.params = { ...DEFAULT_PARAMS };
},
getStats() {
const globeShown = state.viewer?.scene?.globe?.show !== false;
return {
count: state.enabled
? OSM_OVERLAYS.filter((overlay) => state.params[overlay.key]).length
: 0,
error: osmOverlaysHiddenReason(state.enabled, state.params, globeShown),
};
},
};
export default osmOverlaysLayer;
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OSM_OVERLAYS,
applyOsmOverlayParams,
osmOverlaysHiddenReason,
} from './osmOverlays.js';
test('OSM_OVERLAYS: exactly the two shipped overlays, each with a tile template and credit', () => {
assert.deepEqual(OSM_OVERLAYS.map((o) => o.key), ['seamap', 'snowmap']);
for (const overlay of OSM_OVERLAYS) {
assert.match(overlay.urlTemplate, /\{z\}.*\{x\}.*\{y\}/);
assert.ok(overlay.credit);
}
});
test('osmOverlaysHiddenReason: null when disabled, nothing chosen, or the globe is shown', () => {
assert.equal(osmOverlaysHiddenReason(false, { seamap: true, snowmap: true }, false), null);
assert.equal(osmOverlaysHiddenReason(true, { seamap: false, snowmap: false }, false), null);
assert.equal(osmOverlaysHiddenReason(true, { seamap: true, snowmap: false }, true), null);
});
test('osmOverlaysHiddenReason: explains itself when enabled + chosen but the photoreal globe is hidden', () => {
const reason = osmOverlaysHiddenReason(true, { seamap: true, snowmap: false }, false);
assert.ok(reason && /globe/i.test(reason));
});
test('applyOsmOverlayParams: flips only known keys with boolean values, reports whether anything changed', () => {
const current = { seamap: true, snowmap: true };
const a = applyOsmOverlayParams(current, { seamap: false });
assert.deepEqual(a.next, { seamap: false, snowmap: true });
assert.equal(a.changed, true);
const b = applyOsmOverlayParams(current, { seamap: true });
assert.deepEqual(b.next, current);
assert.equal(b.changed, false);
const c = applyOsmOverlayParams(current, { unknownKey: false, snowmap: 'nope' });
assert.deepEqual(c.next, current);
assert.equal(c.changed, false);
});