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:
Said Turksever committed 2026-11-02 15:55:40 +00:00
commit ce1ad3b95a
2330 files changed
+632956 -84310

No files matched your search

+201 -143
View File
@@ -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 };
},
};
}