mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge main into prereq-10-osm-overlays and port Open Map Overlays
Main moved on since this PR opened; the overlay layer is ported to the current contracts: - Layer-state token 6 (j belongs to transit; new single-character tokens are digits) with a reservation-ledger row, and the option owner renamed to the layer id so the compact share codec can encode it. - Constructed per catalog by createOsmOverlaysLayer() in constructCatalog.js; src/standalone/data.js no longer registers layers. - Drapes through maps/imageryHost.js, so the overlays show on Google 3D (tileset imagery) as well as the globe stacks, and follow map-stack switches. Imagery layers are created on enable and destroyed on disable/destroy. - Translated chip labels and layer name in every locale, LAYER_NAME_KEYS, Infrastructure panel group, voice manifest entry. - Attribution: OpenSeaMap tiles CC BY-SA 2.0, OpenSnowMap credit as required, OSM ODbL credit for both; bulk-download note in DATA_SOURCES. - CURRENT-STATE row, changelog fragment, share-codec absent defaults, updated token-ledger fixtures and voice schema pins.
This commit is contained in:
commit
ce1ad3b95a
2330 files changed
+632956
-84310
No files matched your search
+201
-143
@@ -1,57 +1,52 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
|
||||
* pistes (#10). Two independently toggleable overlays sharing one
|
||||
* config-driven layer, the same "small array of descriptors" shape as the
|
||||
* map stacks.
|
||||
*
|
||||
* 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.
|
||||
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
|
||||
* included:
|
||||
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
|
||||
* serves requests whose Referer is openrailwaymap.org; every other Referer,
|
||||
* including none, gets 403. A tab on this app sends its own origin, so the
|
||||
* tiles would fail, and embedding them would run against their tile-usage
|
||||
* policy either way.
|
||||
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
|
||||
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
|
||||
*
|
||||
* 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.
|
||||
* The overlays drape on whatever surface the active map stack offers (see
|
||||
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
|
||||
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
|
||||
* neither leaves them hidden, and the row says so.
|
||||
* @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}>} */
|
||||
/** @type {ReadonlyArray<{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',
|
||||
label: msg('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',
|
||||
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
|
||||
credit:
|
||||
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
Object.freeze({
|
||||
key: 'snowmap',
|
||||
label: 'Ski Pistes',
|
||||
label: msg('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)',
|
||||
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
|
||||
credit:
|
||||
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
]);
|
||||
@@ -60,21 +55,11 @@ 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
|
||||
* Sanitize a candidate params object against the known overlay keys. Unknown
|
||||
* keys are ignored; a non-boolean value for a known key leaves that key
|
||||
* untouched rather than coercing it.
|
||||
* @param {object} current the live `{seamap, snowmap}` state
|
||||
* @param {object} candidate
|
||||
* @returns {{next: object, changed: boolean}}
|
||||
*/
|
||||
@@ -82,7 +67,7 @@ export function applyOsmOverlayParams(current, candidate = {}) {
|
||||
const next = { ...current };
|
||||
let changed = false;
|
||||
for (const overlay of OSM_OVERLAYS) {
|
||||
if (!Object.hasOwn(candidate, overlay.key)) continue;
|
||||
if (!Object.hasOwn(candidate ?? {}, overlay.key)) continue;
|
||||
const value = candidate[overlay.key];
|
||||
if (typeof value !== 'boolean') continue;
|
||||
if (next[overlay.key] !== value) changed = true;
|
||||
@@ -91,101 +76,174 @@ export function applyOsmOverlayParams(current, candidate = {}) {
|
||||
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]);
|
||||
}
|
||||
/** Why an enabled overlay with something chosen is showing nothing, or null. */
|
||||
export function osmOverlaysHiddenReason(enabled, params, hostKind) {
|
||||
if (!enabled || hostKind !== 'none') return null;
|
||||
const anyChosen = OSM_OVERLAYS.some((overlay) =>
|
||||
Boolean(params?.[overlay.key]),
|
||||
);
|
||||
return anyChosen ? NO_IMAGERY_HOST : null;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Create one Open Map Overlays layer. Each application catalog gets its own
|
||||
* instance; the Cesium factories are injectable so tests run without WebGL.
|
||||
*/
|
||||
export function createOsmOverlaysLayer({
|
||||
host = resolveImageryHost,
|
||||
createProvider = (overlay) =>
|
||||
new Cesium.UrlTemplateImageryProvider({
|
||||
url: overlay.urlTemplate,
|
||||
credit: overlay.credit,
|
||||
maximumLevel: overlay.maximumLevel,
|
||||
}),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
} = {}) {
|
||||
let viewer = null;
|
||||
let mapStack = null;
|
||||
let unsubscribeMap = null;
|
||||
let enabled = false;
|
||||
let params = { ...DEFAULT_PARAMS };
|
||||
let hostInfo = { collection: null, kind: 'none' };
|
||||
/** Imagery layers this instance owns, by overlay key: `{ layer, collection }`. */
|
||||
const owned = new Map();
|
||||
|
||||
export default osmOverlaysLayer;
|
||||
const usable = (collection) => collection && !collection.isDestroyed?.();
|
||||
|
||||
function detach(entry, destroy) {
|
||||
const { collection, layer } = entry;
|
||||
if (usable(collection) && collection.contains(layer))
|
||||
collection.remove(layer, destroy);
|
||||
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
|
||||
entry.collection = null;
|
||||
}
|
||||
|
||||
function resolveHost() {
|
||||
return (
|
||||
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
|
||||
collection: null,
|
||||
kind: 'none',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Create, move or drop owned imagery layers so they match the wanted set. */
|
||||
function sync() {
|
||||
if (!viewer) return;
|
||||
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
|
||||
for (const overlay of OSM_OVERLAYS) {
|
||||
const wanted = enabled && Boolean(params[overlay.key]);
|
||||
let entry = owned.get(overlay.key);
|
||||
if (!wanted) {
|
||||
if (entry) {
|
||||
detach(entry, true);
|
||||
owned.delete(overlay.key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry) {
|
||||
entry = {
|
||||
layer: createImageryLayer(createProvider(overlay)),
|
||||
collection: null,
|
||||
};
|
||||
owned.set(overlay.key, entry);
|
||||
}
|
||||
if (entry.collection === hostInfo.collection) continue;
|
||||
detach(entry, false);
|
||||
if (usable(hostInfo.collection)) {
|
||||
// Appended, so it draws above the map stack's base imagery.
|
||||
hostInfo.collection.add(entry.layer);
|
||||
entry.collection = hostInfo.collection;
|
||||
}
|
||||
}
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
return {
|
||||
id: OSM_OVERLAYS_LAYER_ID,
|
||||
name: 'Open Map Overlays',
|
||||
icon: '🗺️',
|
||||
source: 'OpenSeaMap / OpenSnowMap',
|
||||
updateInterval: 0,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
},
|
||||
/** Follow map-stack switches so the drapes move with the surface. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = controller || null;
|
||||
const off = controller?.subscribe?.(() => {
|
||||
if (enabled) sync();
|
||||
});
|
||||
unsubscribeMap = typeof off === 'function' ? off : null;
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
sync();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
sync();
|
||||
},
|
||||
update() {
|
||||
return true;
|
||||
},
|
||||
setParams(next = {}) {
|
||||
const result = applyOsmOverlayParams(params, next);
|
||||
if (result.changed) {
|
||||
params = result.next;
|
||||
sync();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getParams() {
|
||||
return { ...params };
|
||||
},
|
||||
getRowControls() {
|
||||
if (!enabled) return { chips: [], legend: [] };
|
||||
return {
|
||||
chips: OSM_OVERLAYS.map((overlay) => {
|
||||
const active = Boolean(params[overlay.key]);
|
||||
return {
|
||||
id: overlay.key,
|
||||
label: `${overlay.icon} ${t(overlay.label)}`,
|
||||
active,
|
||||
title: overlay.credit,
|
||||
params: { [overlay.key]: !active },
|
||||
};
|
||||
}),
|
||||
legend: [],
|
||||
};
|
||||
},
|
||||
getStats() {
|
||||
// Without a controller subscription the stats poll doubles as the
|
||||
// map-stack watch.
|
||||
if (enabled && !unsubscribeMap) {
|
||||
const next = resolveHost();
|
||||
if (next.collection !== hostInfo.collection) sync();
|
||||
}
|
||||
return {
|
||||
count: enabled
|
||||
? OSM_OVERLAYS.filter((overlay) => params[overlay.key]).length
|
||||
: 0,
|
||||
error: osmOverlaysHiddenReason(enabled, params, hostInfo.kind),
|
||||
};
|
||||
},
|
||||
/** Owned layer count and host kind, for tests and live gates. */
|
||||
diagnostics() {
|
||||
return { host: hostInfo.kind, ownedCount: owned.size };
|
||||
},
|
||||
destroy() {
|
||||
enabled = false;
|
||||
for (const entry of owned.values()) detach(entry, true);
|
||||
owned.clear();
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = null;
|
||||
viewer = null;
|
||||
hostInfo = { collection: null, kind: 'none' };
|
||||
params = { ...DEFAULT_PARAMS };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user