mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
db49edf363
commit
feba5af0b9
8 files changed
+270
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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]]),
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user