From feba5af0b98026c924decbc21737622125810bb5 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:05:56 -0500 Subject: [PATCH 1/4] Add OpenSeaMap / OpenSnowMap overlay layers (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DATA_SOURCES.md | 3 + src/data/dataCredits.js | 12 +++ src/data/layerState.js | 9 ++ src/data/layerState.test.mjs | 4 +- src/data/osmOverlays.js | 191 ++++++++++++++++++++++++++++++++++ src/data/osmOverlays.test.mjs | 41 ++++++++ src/standalone/data.js | 2 + src/voice/gevActions.js | 10 ++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/data/osmOverlays.js create mode 100644 src/data/osmOverlays.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index faa351dca..7f5cdbdad 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -37,10 +37,13 @@ How to read this: | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | +| **OpenSeaMap** (`tiles.openseamap.org`) | Sea marks raster overlay (buoys, lighthouses, ports) — one of two Open Map Overlays (#10) | Map data ODbL 1.0 via OpenStreetMap | "© OpenSeaMap contributors" | +| **OpenSnowMap** (`opensnowmap.org`) | Ski piste raster overlay — one of two Open Map Overlays (#10) | Map data ODbL 1.0 (OpenStreetMap) + SRTM; map style CC BY-SA | "Map data: © OpenStreetMap contributors, SRTM \| Map style: © OpenSnowMap.org (CC-BY-SA)" | ### Notes on the live sources - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). +- **Open Map Overlays (#10).** Two independently toggleable keyless raster overlays — OpenSeaMap sea marks and OpenSnowMap ski pistes — drawn as `Cesium.ImageryLayer`s directly on the Cesium globe surface. Like every globe-surface source, they render on the `osm`/`bing-aerial`/`bing-labels` map stacks and are invisible on the default `photoreal` stack (Google Photorealistic 3D Tiles render with the globe hidden; see `mapStackController.js`) — the layer's stats surface that as plain text instead of looking broken. OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's public tile server enforces a Referer allowlist restricted to its own site (confirmed by direct request — any other Referer, including none, 403s), and OpenInfraMap serves MapLibre vector tiles rather than raster XYZ tiles, which would need a genuine vector-tile rendering pipeline, not an imagery-layer drop-in. - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index b35da9e00..5cd3d1b66 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -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: ' + + '© OpenSeaMap contributors', + }, + { + key: 'opensnowmap', + html: + 'Ski pistes overlay: Map data © OpenStreetMap contributors, SRTM | Map style © ' + + 'OpenSnowMap.org (CC BY-SA)', + }, { key: 'overpass', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index eb68c14e7..c209634f6 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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' }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e75e..2cb4ed2bf 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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]]), diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js new file mode 100644 index 000000000..7183813fe --- /dev/null +++ b/src/data/osmOverlays.js @@ -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; diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs new file mode 100644 index 000000000..d4dcbe691 --- /dev/null +++ b/src/data/osmOverlays.test.mjs @@ -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); +}); diff --git a/src/standalone/data.js b/src/standalone/data.js index 1c3e20366..a7359c584 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -10,6 +10,7 @@ import radioLayer from '../data/radio.js'; import bikeshareLayer from '../data/bikeshare.js'; import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; import militaryInstallationsLayer from '../data/militaryInstallations.js'; +import osmOverlaysLayer from '../data/osmOverlays.js'; import militaryAwarenessLayer from '../data/militaryAwareness.js'; import localDataLayers from '../data/localLayers.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; @@ -44,6 +45,7 @@ export function createStandaloneData({ dataManager.register(bikeshareLayer); dataManager.register(aisLiveVesselsLayer); dataManager.register(militaryInstallationsLayer); + dataManager.register(osmOverlaysLayer); dataManager.register(militaryAwarenessLayer); militaryAwarenessLayer.attachDataManager(dataManager); for (const layer of localDataLayers) { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..3ab9d149e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -176,6 +176,16 @@ const LAYER_ALIASES = new Map([ ['firms', 'local-firms'], ['fires', 'local-firms'], ['active fires', 'local-firms'], + ['map overlays', 'osm-overlays'], + ['open map overlays', 'osm-overlays'], + ['sea marks', 'osm-overlays'], + ['seamarks', 'osm-overlays'], + ['nautical charts', 'osm-overlays'], + ['openseamap', 'osm-overlays'], + ['ski pistes', 'osm-overlays'], + ['ski slopes', 'osm-overlays'], + ['snow map', 'osm-overlays'], + ['opensnowmap', 'osm-overlays'], ]); const CITY_ALIASES = new Map([ From c489e6579623ef3992afaebd5ffc89c62ae83fa6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 2 Nov 2026 16:07:01 +0000 Subject: [PATCH 2/4] Keep one OpenStreetMap copyright link in the data credits The generic OpenStreetMap credit owns the link; the overlay credits name OSM in plain text, as dataCredits.test.mjs requires. --- src/data/dataCredits.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a0cc8b41c..b2b8f9786 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -155,14 +155,14 @@ export const DATA_CREDITS = [ html: 'Sea marks overlay: ' + '© OpenSeaMap contributors (CC BY-SA 2.0) · ' + - 'Data © OpenStreetMap contributors (ODbL)', + 'Data © OpenStreetMap contributors (ODbL)', }, { key: 'opensnowmap', html: 'Ski pistes overlay: ' + '© www.opensnowmap.org (CC BY-SA) · ' + - 'Data © OpenStreetMap contributors (ODbL)', + 'Data © OpenStreetMap contributors (ODbL)', }, { key: 'nasa-gibs', From fc7ff1055c50bde4c7d3f083d8f5ba2ffe024101 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 2 Nov 2026 16:20:08 +0000 Subject: [PATCH 3/4] Split Open Map Overlays into Sea Marks and Ski Pistes layers Each overlay is now its own Infrastructure row instead of a chip on one shared row: sea-marks (token 6) and ski-pistes (token 7), both enabled-only, built by createOsmOverlayLayers() from one descriptor list. Drops the option group and its share-codec defaults, gives each layer its own name, voice entry and CURRENT-STATE row, and shifts the token-ledger fixtures to the next free digit 8. --- DATA_SOURCES.md | 6 +- changelog.d/114-open-map-overlays.md | 7 +- docs/CURRENT-STATE.md | 3 +- src/app/constructCatalog.js | 4 +- src/app/constructCatalog.test.mjs | 4 +- src/app/sourceComposition.test.mjs | 2 +- src/data/layerState.js | 16 +- src/data/layerState.test.mjs | 26 +-- src/data/layerStateTokenLedger.test.mjs | 59 +++---- src/data/layerStateTokenReservations.json | 3 +- src/data/osmOverlays.js | 189 ++++++++-------------- src/data/osmOverlays.test.mjs | 119 +++++--------- src/firstRunExperience.test.mjs | 6 +- src/i18n/locales/ar.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/it.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/uk.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-Hant.json | 3 +- src/share/viewCodec.js | 1 - src/ui/layerNames.js | 3 +- src/ui/layerPanel.js | 3 +- src/voice/actionSchemas.test.mjs | 2 +- src/voice/layerManifest.js | 23 ++- 29 files changed, 194 insertions(+), 315 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1e8e4b0fd..f2f40d356 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,8 +98,8 @@ How to read this: | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | -| **OpenSeaMap** (`tiles.openseamap.org`) | Sea marks raster overlay (buoys, lighthouses, ports), one of two Open Map Overlays (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | -| **OpenSnowMap** (`www.opensnowmap.org`) | Ski piste raster overlay, one of two Open Map Overlays (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" | +| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | +| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | ### Local receiver input (not fetched from a service) @@ -120,7 +120,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). -- **Open Map Overlays (#10).** Two independently toggleable keyless raster overlays, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline. +- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline. - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. diff --git a/changelog.d/114-open-map-overlays.md b/changelog.d/114-open-map-overlays.md index a91ca1205..cac0ce6db 100644 --- a/changelog.d/114-open-map-overlays.md +++ b/changelog.d/114-open-map-overlays.md @@ -1,4 +1,3 @@ -- Add Open Map Overlays (Infrastructure ▸ Open Map Overlays): OpenSeaMap sea - marks and OpenSnowMap ski pistes as two toggleable raster overlays. They - drape on the globe stacks and on Google 3D, and share links keep the row and - both chips (Tom-Neverwinter, #114). +- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster + overlay layers under Infrastructure. They drape on the globe stacks and on + Google 3D, and share links keep each one (Tom-Neverwinter, #114). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2d76d8f06..6850beb3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3979,7 +3979,8 @@ its criteria cannot be silently ignored. | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | | S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | -| Open Map Overlays 🗺️ (`osm-overlays`) | OpenSeaMap sea marks + OpenSnowMap ski pistes raster tiles (keyless; CC BY-SA tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | +| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | +| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 390a2165d..aa9499c28 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -29,7 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationCables } from './layers/submarineCables.js'; -import { createOsmOverlaysLayer } from '../data/osmOverlays.js'; +import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; import { localGeoJsonServices } from './localGeojsonServices.js'; @@ -170,7 +170,7 @@ export function createApplicationCatalog({ clock: weatherClock, }), createCyclonesLayer({ feed: sources.cyclones }), - createOsmOverlaysLayer(), + ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), createApplicationFirms({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 5f6975c03..a9147f652 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 34); + assert.equal(first.layers.length, 35); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 34); + assert.equal(catalog.layers.length, 35); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 0969b8f7e..e3db7d40b 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 34); + assert.equal(actual.layers.length, 35); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/layerState.js b/src/data/layerState.js index 18c61b3a0..5853cf53d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -430,14 +430,6 @@ const OPTION_GROUPS = Object.freeze({ // MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test). boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }), ]), - // Two independently toggleable open-data raster overlays (#10) sharing one - // owner, the same "one master row, several booleans" shape as `cctv`/`radio`. - // Both default on: switching the row on with nothing chosen would show - // nothing and read as broken. - 'osm-overlays': Object.freeze([ - booleanOption('seamap', 's', true), - booleanOption('snowmap', 'n', true), - ]), radio: Object.freeze([ Object.freeze({ key: 'filter', @@ -646,12 +638,6 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'i', disposition: 'enabled-only', }), - Object.freeze({ - id: 'osm-overlays', - token: '6', - disposition: 'enabled+options', - optionOwner: 'osm-overlays', - }), Object.freeze({ id: 'radio', token: 'r', @@ -680,6 +666,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled+options', optionOwner: 'satellites', }), + Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }), + Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }), Object.freeze({ id: 'street-level', token: '0', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 18851284c..f91f90f3e 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,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, 33); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 33); + assert.equal(REGISTERED_LAYER_IDS.length, 34); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,18 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '7'); + assert.equal(nextLayerStateToken(), '8'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '7', - bravo: '8', + alpha: '8', + bravo: '9', }), - '9', + '00', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, - ...Object.fromEntries([...'789'].map((digit) => [`prior-${digit}`, digit])), + ...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); assert.equal( @@ -305,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '7', - next: '8', + future: '8', + next: '9', }), true, ); @@ -316,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 7/, + /next free token 8/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -339,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' }, ), - /next free token 7/, + /next free token 9/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 17a7e0684..a95762364 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 33); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -137,7 +137,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'gnss-integrity': '3', 'air-quality': '4', 's2-ghostmaps': '5', - 'osm-overlays': '6', + 'sea-marks': '6', + 'ski-pistes': '7', }, ); assert.throws( @@ -163,9 +164,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '7']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '8']]) .future, - '7', + '8', ); assert.throws( () => @@ -183,14 +184,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '7']] }, + { rows: [...reservationRows, ['retired-layer', '8']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '8'); + assert.equal(nextLayerStateToken(published), '9'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '8', + newcomer: '9', }), true, ); @@ -198,15 +199,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '7', + competing: '8', }), - /next free token 8/, + /next free token 9/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '7', + newcomer: '8', }), /changed or removed: retired-layer/, ); @@ -214,8 +215,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '7']] }, (cwd) => { +test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -236,8 +237,8 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '7']], - [{ id: 'pr-b', token: '7' }], + [...reservationRows, ['pr-b', '8']], + [{ id: 'pr-b', token: '8' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -245,10 +246,10 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '7'], ['pr-b', '7']], + [...reservationRows, ['pr-a', '8'], ['pr-b', '8']], [ - { id: 'pr-a', token: '7' }, - { id: 'pr-b', token: '7' }, + { id: 'pr-a', token: '8' }, + { id: 'pr-b', token: '8' }, ], ); const staleRebase = runCheck(); @@ -256,16 +257,16 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '8'); - const validBaselineRows = [...reservationRows, ['pr-a', '7']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '7' }]); + assert.equal(nextLayerStateToken(published), '9'); + const validBaselineRows = [...reservationRows, ['pr-a', '8']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 8/); + assert.match(helper.stdout, /pr-b: 9/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -273,20 +274,20 @@ test('PR B manually replaces provisional 7 with 8 after PR A publishes 7', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '7'], ['pr-b', '8']], + [...reservationRows, ['pr-a', '8'], ['pr-b', '9']], [ - { id: 'pr-a', token: '7' }, - { id: 'pr-b', token: '8' }, + { id: 'pr-a', token: '8' }, + { id: 'pr-b', token: '9' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /34 published, 1 new/); + assert.match(corrected.stdout, /35 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -338,7 +339,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'789'].map((digit) => [ + const priorDigits = [...'89'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -375,7 +376,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 77); + assert.equal(params.get('l').length, 79); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -393,7 +394,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 34); + assert.equal(Object.keys(published).length, 35); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 3d040b43f..cfb7cea93 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -17,12 +17,13 @@ ["military", "m"], ["military-awareness", "g"], ["military-installations", "i"], - ["osm-overlays", "6"], ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], ["s2-ghostmaps", "5"], ["satellites", "s"], + ["sea-marks", "6"], + ["ski-pistes", "7"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], ["traffic", "t"], diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index f68672d92..dc3de45fc 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -1,12 +1,10 @@ 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: 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. + * pistes (#10). Each overlay is its own layer with its own row under + * Infrastructure; both are built from the descriptors below. * * OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not * included: @@ -25,14 +23,13 @@ import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; * @module data/osmOverlays */ -export const OSM_OVERLAYS_LAYER_ID = 'osm-overlays'; - -/** @type {ReadonlyArray<{key:string,label:string,icon:string,urlTemplate:string,credit:string,maximumLevel:number}>} */ +/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */ export const OSM_OVERLAYS = Object.freeze([ Object.freeze({ - key: 'seamap', - label: msg('Sea Marks'), + id: 'sea-marks', + name: 'Sea Marks', icon: '⚓', + source: 'OpenSeaMap', urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', // Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL). credit: @@ -40,9 +37,10 @@ export const OSM_OVERLAYS = Object.freeze([ maximumLevel: 18, }), Object.freeze({ - key: 'snowmap', - label: msg('Ski Pistes'), + id: 'ski-pistes', + name: 'Ski Pistes', icon: '⛷', + source: 'OpenSnowMap', urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png', // OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear. credit: @@ -51,71 +49,49 @@ export const OSM_OVERLAYS = Object.freeze([ }), ]); -const DEFAULT_PARAMS = Object.freeze( - Object.fromEntries(OSM_OVERLAYS.map((overlay) => [overlay.key, true])), -); - -/** - * 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}} - */ -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 }; -} - -/** 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; +/** Why an enabled overlay is showing nothing, or null. */ +export function osmOverlayHiddenReason(enabled, hostKind) { + return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null; } /** - * Create one Open Map Overlays layer. Each application catalog gets its own - * instance; the Cesium factories are injectable so tests run without WebGL. + * Create the layer for one overlay descriptor. Each application catalog gets + * its own instances; 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), -} = {}) { +export function createOsmOverlayLayer( + overlay, + { + host = resolveImageryHost, + createProvider = (descriptor) => + new Cesium.UrlTemplateImageryProvider({ + url: descriptor.urlTemplate, + credit: descriptor.credit, + maximumLevel: descriptor.maximumLevel, + }), + createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), + } = {}, +) { + if (!OSM_OVERLAYS.includes(overlay)) + throw new TypeError(`Unknown open map overlay: ${overlay?.id}`); 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(); + /** The imagery layer this instance owns while enabled, and where it sits. */ + let owned = null; const usable = (collection) => collection && !collection.isDestroyed?.(); - function detach(entry, destroy) { - const { collection, layer } = entry; + function detach(destroy) { + if (!owned) return; + const { collection, layer } = owned; if (usable(collection) && collection.contains(layer)) collection.remove(layer, destroy); else if (destroy && !layer.isDestroyed?.()) layer.destroy?.(); - entry.collection = null; + owned.collection = null; + if (destroy) owned = null; } function resolveHost() { @@ -127,48 +103,39 @@ export function createOsmOverlaysLayer({ ); } - /** Create, move or drop owned imagery layers so they match the wanted set. */ + /** Create, move or drop the owned imagery layer to match the state. */ 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); + if (!enabled) { + detach(true); + } else { + owned ||= { + layer: createImageryLayer(createProvider(overlay)), + collection: null, + }; + if (owned.collection !== hostInfo.collection) { + detach(false); + if (usable(hostInfo.collection)) { + // Appended, so it draws above the map stack's base imagery. + hostInfo.collection.add(owned.layer); + owned.collection = hostInfo.collection; } - 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', + id: overlay.id, + name: overlay.name, + icon: overlay.icon, + source: overlay.source, updateInterval: 0, init(nextViewer) { viewer = nextViewer; }, - /** Follow map-stack switches so the drapes move with the surface. */ + /** Follow map-stack switches so the drape moves with the surface. */ attachMapStackController(controller) { unsubscribeMap?.(); unsubscribeMap = null; @@ -189,33 +156,6 @@ export function createOsmOverlaysLayer({ 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. @@ -224,26 +164,27 @@ export function createOsmOverlaysLayer({ if (next.collection !== hostInfo.collection) sync(); } return { - count: enabled - ? OSM_OVERLAYS.filter((overlay) => params[overlay.key]).length - : 0, - error: osmOverlaysHiddenReason(enabled, params, hostInfo.kind), + count: enabled ? 1 : 0, + error: osmOverlayHiddenReason(enabled, hostInfo.kind), }; }, /** Owned layer count and host kind, for tests and live gates. */ diagnostics() { - return { host: hostInfo.kind, ownedCount: owned.size }; + return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 }; }, destroy() { enabled = false; - for (const entry of owned.values()) detach(entry, true); - owned.clear(); + detach(true); unsubscribeMap?.(); unsubscribeMap = null; mapStack = null; viewer = null; hostInfo = { collection: null, kind: 'none' }; - params = { ...DEFAULT_PARAMS }; }, }; } + +/** One layer per overlay, in panel order. */ +export function createOsmOverlayLayers(options) { + return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options)); +} diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index 0f17c78e3..ee2792ff4 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -2,9 +2,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { OSM_OVERLAYS, - applyOsmOverlayParams, - createOsmOverlaysLayer, - osmOverlaysHiddenReason, + createOsmOverlayLayer, + createOsmOverlayLayers, + osmOverlayHiddenReason, } from './osmOverlays.js'; import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; @@ -49,15 +49,15 @@ function fakeMapStack() { }; } -function setup() { +function setup(overlay = OSM_OVERLAYS[0]) { const globe = fakeCollection(); const viewer = { imageryLayers: globe, scene: { globe: { show: true }, requestRender() {} }, }; const created = []; - const layer = createOsmOverlaysLayer({ - createProvider: (overlay) => ({ overlay }), + const layer = createOsmOverlayLayer(overlay, { + createProvider: (descriptor) => ({ descriptor }), createImageryLayer: (provider) => { const imagery = { provider, destroyed: false }; created.push(imagery); @@ -70,10 +70,10 @@ function setup() { return { layer, viewer, globe, created, mapStack }; } -test('OSM_OVERLAYS: exactly the two shipped overlays, each with a tile template and credit', () => { +test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => { assert.deepEqual( - OSM_OVERLAYS.map((o) => o.key), - ['seamap', 'snowmap'], + OSM_OVERLAYS.map((o) => o.id), + ['sea-marks', 'ski-pistes'], ); for (const overlay of OSM_OVERLAYS) { assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/); @@ -83,77 +83,64 @@ test('OSM_OVERLAYS: exactly the two shipped overlays, each with a tile template assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/); }); -test('osmOverlaysHiddenReason: only when enabled, something chosen and no imagery host', () => { - const both = { seamap: true, snowmap: true }; - assert.equal(osmOverlaysHiddenReason(false, both, 'none'), null); - assert.equal( - osmOverlaysHiddenReason(true, { seamap: false, snowmap: false }, 'none'), - null, +test('createOsmOverlayLayers builds one independent layer per overlay', () => { + const layers = createOsmOverlayLayers(); + assert.deepEqual( + layers.map((layer) => [layer.id, layer.name]), + [ + ['sea-marks', 'Sea Marks'], + ['ski-pistes', 'Ski Pistes'], + ], ); - assert.equal(osmOverlaysHiddenReason(true, both, 'globe'), null); - assert.equal(osmOverlaysHiddenReason(true, both, 'tileset'), null); - assert.equal(osmOverlaysHiddenReason(true, both, 'none'), NO_IMAGERY_HOST); + assert.notEqual(createOsmOverlayLayers()[0], layers[0]); + assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/); }); -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); - assert.equal(applyOsmOverlayParams(current, null).changed, false); +test('osmOverlayHiddenReason: only when enabled with no imagery host', () => { + assert.equal(osmOverlayHiddenReason(false, 'none'), null); + assert.equal(osmOverlayHiddenReason(true, 'globe'), null); + assert.equal(osmOverlayHiddenReason(true, 'tileset'), null); + assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST); }); -test('layer owns no imagery until enabled and destroys what it created on disable', () => { - const { layer, globe, created } = setup(); +test('a layer owns no imagery until enabled and destroys it on disable', () => { + const { layer, globe, created } = setup(OSM_OVERLAYS[1]); assert.equal(created.length, 0); - assert.equal(globe.layers.length, 0); layer.enable(); - assert.equal(globe.layers.length, 2); - assert.deepEqual(layer.getStats(), { count: 2, error: null }); - - layer.setParams({ snowmap: false }); assert.equal(globe.layers.length, 1); - assert.equal(globe.layers[0].provider.overlay.key, 'seamap'); - assert.equal(created[1].destroyed, true); + assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes'); + assert.deepEqual(layer.getStats(), { count: 1, error: null }); layer.disable(); assert.equal(globe.layers.length, 0); - assert.ok(created.every((imagery) => imagery.destroyed)); + assert.equal(created[0].destroyed, true); assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 }); assert.deepEqual(layer.getStats(), { count: 0, error: null }); + + layer.enable(); + assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer'); }); -test('overlays follow a map-stack switch from the globe onto the Google 3D tileset and back', () => { +test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => { const { layer, viewer, globe, mapStack } = setup(); layer.enable(); - const before = [...globe.layers]; + const [imagery] = globe.layers; const tileset = { imageryLayers: fakeCollection() }; viewer.scene.globe.show = false; mapStack.tileset = tileset; mapStack.emit(); assert.equal(globe.layers.length, 0); - assert.deepEqual(tileset.imageryLayers.layers, before); - assert.ok(globe.removed.every(({ destroy }) => destroy === false)); - assert.equal(layer.getStats().error, null); + assert.deepEqual(tileset.imageryLayers.layers, [imagery]); + assert.equal(globe.removed[0].destroy, false); assert.equal(layer.diagnostics().host, 'tileset'); + assert.equal(layer.getStats().error, null); viewer.scene.globe.show = true; mapStack.tileset = null; mapStack.emit(); - assert.deepEqual(globe.layers, before); + assert.deepEqual(globe.layers, [imagery]); assert.equal(tileset.imageryLayers.layers.length, 0); }); @@ -166,39 +153,15 @@ test('with no surface to drape on the row explains itself, and recovers when one viewer.scene.globe.show = true; mapStack.emit(); - assert.equal(globe.layers.length, 2); + assert.equal(globe.layers.length, 1); assert.equal(layer.getStats().error, null); }); -test('destroy removes owned imagery, unsubscribes from the map stack and resets params', () => { +test('destroy removes owned imagery and unsubscribes from the map stack', () => { const { layer, globe, created, mapStack } = setup(); layer.enable(); - layer.setParams({ seamap: false }); layer.destroy(); assert.equal(globe.layers.length, 0); - assert.ok(created.every((imagery) => imagery.destroyed)); + assert.equal(created[0].destroyed, true); assert.equal(mapStack.subscribers.size, 0); - assert.deepEqual(layer.getParams(), { seamap: true, snowmap: true }); -}); - -test('each catalog gets its own instance', () => { - const a = createOsmOverlaysLayer(); - const b = createOsmOverlaysLayer(); - a.setParams({ seamap: false }); - assert.deepEqual(b.getParams(), { seamap: true, snowmap: true }); -}); - -test('chips translate their labels and toggle one overlay each', () => { - const { layer } = setup(); - assert.deepEqual(layer.getRowControls(), { chips: [], legend: [] }); - layer.enable(); - const { chips } = layer.getRowControls(); - assert.deepEqual( - chips.map((chip) => [chip.id, chip.active, chip.params]), - [ - ['seamap', true, { seamap: false }], - ['snowmap', true, { snowmap: false }], - ], - ); - assert.match(chips[0].label, /Sea Marks/); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 1cf009cb2..463936d2a 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1030,11 +1030,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. // The layer alias hint leaves out aliases the layer id already spells. - // Open Map Overlays extends the generated layer enums and alias hint. - assert.equal(block.length, 30975, 'serialized tool schema length drifted'); + // Sea Marks and Ski Pistes extend the generated layer enums and alias hint. + assert.equal(block.length, 31008, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'deeb6c52e706bfa6dcd330fee0df3228581d2bd0836c2e433e371da2c6b5f60c', + 'da1674545ab9380fb86d387f6e8513aa271e0afe539b69b6a6d09add6c710b0c', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 9049211fb..581124405 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3220,6 +3220,5 @@ "Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة", "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", "Sea Marks": "علامات بحرية", - "Ski Pistes": "مسارات التزلج", - "Open Map Overlays": "طبقات خرائط مفتوحة" + "Ski Pistes": "مسارات التزلج" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 787cc8519..fff5e431e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2940,6 +2940,5 @@ "CONFIRMED": "BESTÄTIGT", "PAD / RTLS": "STARTPLATZ / RTLS", "Sea Marks": "Seezeichen", - "Ski Pistes": "Skipisten", - "Open Map Overlays": "Offene Kartenebenen" + "Ski Pistes": "Skipisten" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 982877abd..da4342616 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3010,6 +3010,5 @@ "Left panels": "Paneles izquierdos", "Right panels": "Paneles derechos", "Sea Marks": "Señales marítimas", - "Ski Pistes": "Pistas de esquí", - "Open Map Overlays": "Capas de mapas abiertos" + "Ski Pistes": "Pistas de esquí" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bbd185fb2..7c06121d6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3010,6 +3010,5 @@ "CONFIRMED": "CONFIRMÉE", "PAD / RTLS": "PAS DE TIR / RTLS", "Sea Marks": "Balisage maritime", - "Ski Pistes": "Pistes de ski", - "Open Map Overlays": "Calques de cartes ouvertes" + "Ski Pistes": "Pistes de ski" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 2faef76f9..d52a122c7 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3010,6 +3010,5 @@ "CONFIRMED": "CONFERMATA", "PAD / RTLS": "SITO DI LANCIO / RTLS", "Sea Marks": "Segnalamenti marittimi", - "Ski Pistes": "Piste da sci", - "Open Map Overlays": "Livelli di mappe aperte" + "Ski Pistes": "Piste da sci" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7e36f468b..3287ed209 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2870,6 +2870,5 @@ "CONFIRMED": "確認済み", "PAD / RTLS": "射場 / RTLS", "Sea Marks": "航路標識", - "Ski Pistes": "スキーゲレンデ", - "Open Map Overlays": "オープンマップのオーバーレイ" + "Ski Pistes": "スキーゲレンデ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index b2b027260..58104f44d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3010,6 +3010,5 @@ "CONFIRMED": "CONFIRMADA", "PAD / RTLS": "PLATAFORMA / RTLS", "Sea Marks": "Sinais marítimos", - "Ski Pistes": "Pistas de esqui", - "Open Map Overlays": "Camadas de mapas abertos" + "Ski Pistes": "Pistas de esqui" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 412604a35..22228fbca 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3080,6 +3080,5 @@ "Left panels": "Левые панели", "Right panels": "Правые панели", "Sea Marks": "Морские знаки", - "Ski Pistes": "Горнолыжные трассы", - "Open Map Overlays": "Открытые картографические слои" + "Ski Pistes": "Горнолыжные трассы" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a6f158030..6157f9e58 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3080,6 +3080,5 @@ "CONFIRMED": "ПІДТВЕРДЖЕНО", "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", "Sea Marks": "Морські знаки", - "Ski Pistes": "Гірськолижні траси", - "Open Map Overlays": "Відкриті картографічні шари" + "Ski Pistes": "Гірськолижні траси" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 491dd959d..d09052d7e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,6 +2870,5 @@ "Left panels": "左侧面板", "Right panels": "右侧面板", "Sea Marks": "航标", - "Ski Pistes": "滑雪道", - "Open Map Overlays": "开放地图叠加层" + "Ski Pistes": "滑雪道" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 4c47108bb..3bd582fd3 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2870,6 +2870,5 @@ "Left panels": "左側面板", "Right panels": "右側面板", "Sea Marks": "航標", - "Ski Pistes": "滑雪道", - "Open Map Overlays": "開放地圖疊加層" + "Ski Pistes": "滑雪道" } diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 76aa3da76..b72f79d34 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -296,7 +296,6 @@ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ sinceDays: 0, }), radio: Object.freeze({ filter: 'all', volume: 0.8 }), - 'osm-overlays': Object.freeze({ seamap: true, snowmap: true }), }); /** The frozen meaning of an omitted option; unlisted options are always written. */ diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 0ba80f174..58b651ae7 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -43,7 +43,8 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'air-quality': msg('Air quality'), directions: msg('Directions'), 'alpr-cameras': msg('ALPR Cameras'), - 'osm-overlays': msg('Open Map Overlays'), + 'sea-marks': msg('Sea Marks'), + 'ski-pistes': msg('Ski Pistes'), }); /** The layer panel's own English names, where they differ from the module's. */ diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 0a9ec4a11..48dd52a2c 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -64,7 +64,8 @@ const PANEL_GROUPS = [ 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', - 'osm-overlays', + 'sea-marks', + 'ski-pistes', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 6bf53d616..7e754f291 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -48,7 +48,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - 'b59bf989c98cb5935b628dd9c586f303f3e1e7febb8aac83f479fd60ec023bbc', + 'c13f59886cce3780e7b27462df19659242fdac98928289dce74cf61a1f74054a', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 2df081435..3e11d898a 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -72,21 +72,16 @@ const layers = [ noQuery: 'published third-party intel placemarks; not analyst records', }, { - id: 'osm-overlays', - aliases: [ - 'open map overlays', - 'map overlays', - 'sea marks', - 'seamarks', - 'nautical charts', - 'openseamap', - 'ski pistes', - 'ski slopes', - 'snow map', - 'opensnowmap', - ], + id: 'sea-marks', + aliases: ['seamarks', 'nautical charts', 'buoys', 'openseamap'], context: false, - noQuery: 'raster tile overlays; no countable records', + noQuery: 'raster tile overlay; no countable records', + }, + { + id: 'ski-pistes', + aliases: ['ski slopes', 'ski runs', 'snow map', 'opensnowmap'], + context: false, + noQuery: 'raster tile overlay; no countable records', }, { id: 'flights', From 315c561a7bab64e9f816f3370caa7e7011904874 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 14:49:54 +0000 Subject: [PATCH 4/4] Restore main's catalog strings and re-pin the voice schema after the main merges The two Update-branch merges resolved the locale conflicts by keeping this branch's side, which dropped nine strings main had added (Utilities panel, drawing colours, cockpit exit). Rebuild each catalog as main's copy plus Sea Marks and Ski Pistes, and re-pin the voice tool schema length and digests on top of main's Utilities target. --- src/firstRunExperience.test.mjs | 7 ++++--- src/i18n/locales/ar.json | 9 +++++++++ src/i18n/locales/de.json | 9 +++++++++ src/i18n/locales/es.json | 3 +++ src/i18n/locales/fr.json | 9 +++++++++ src/i18n/locales/it.json | 9 +++++++++ src/i18n/locales/ja.json | 9 +++++++++ src/i18n/locales/pt.json | 9 +++++++++ src/i18n/locales/ru.json | 3 +++ src/i18n/locales/uk.json | 9 +++++++++ src/i18n/locales/zh-CN.json | 3 +++ src/i18n/locales/zh-Hant.json | 3 +++ src/voice/actionSchemas.test.mjs | 2 +- 13 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 463936d2a..2b65334c3 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1030,11 +1030,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. // The layer alias hint leaves out aliases the layer id already spells. - // Sea Marks and Ski Pistes extend the generated layer enums and alias hint. - assert.equal(block.length, 31008, 'serialized tool schema length drifted'); + // The Utilities panel adds one set_panel_open target; Sea Marks and Ski + // Pistes extend the generated layer enums and alias hint. + assert.equal(block.length, 31026, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'da1674545ab9380fb86d387f6e8513aa271e0afe539b69b6a6d09add6c710b0c', + '5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f2c434aa9..002d73ab5 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3214,6 +3214,15 @@ "Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل", "Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة", "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", + "Name of {name}": "اسم {name}", + "Change the colour of {name}": "تغيير لون {name}", + "Colour of {name}": "لون {name}", + "Colour of the next shape": "لون الشكل التالي", + "Drawn shapes": "الأشكال المرسومة", + "Audio": "الصوت", + "UTILITIES": "الأدوات", + "Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم", + "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", "Sea Marks": "علامات بحرية", "Ski Pistes": "مسارات التزلج" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 83b6c77a3..95641870a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2934,6 +2934,15 @@ "Right panels": "Rechte Panels", "CONFIRMED": "BESTÄTIGT", "PAD / RTLS": "STARTPLATZ / RTLS", + "Name of {name}": "Name von {name}", + "Change the colour of {name}": "Farbe von {name} ändern", + "Colour of {name}": "Farbe von {name}", + "Colour of the next shape": "Farbe der nächsten Form", + "Drawn shapes": "Gezeichnete Formen", + "Audio": "Audio", + "UTILITIES": "WERKZEUGE", + "Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen", + "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien", "Sea Marks": "Seezeichen", "Ski Pistes": "Skipisten" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b35124d33..412a9c26a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3010,6 +3010,9 @@ "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", "Left panels": "Paneles izquierdos", "Right panels": "Paneles derechos", + "UTILITIES": "UTILIDADES", + "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", + "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos", "Sea Marks": "Señales marítimas", "Ski Pistes": "Pistas de esquí" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1d587c67d..48386ed15 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3004,6 +3004,15 @@ "Right panels": "Panneaux de droite", "CONFIRMED": "CONFIRMÉE", "PAD / RTLS": "PAS DE TIR / RTLS", + "Name of {name}": "Nom de {name}", + "Change the colour of {name}": "Changer la couleur de {name}", + "Colour of {name}": "Couleur de {name}", + "Colour of the next shape": "Couleur de la prochaine forme", + "Drawn shapes": "Formes dessinées", + "Audio": "Audio", + "UTILITIES": "OUTILS", + "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", + "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers", "Sea Marks": "Balisage maritime", "Ski Pistes": "Pistes de ski" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4af7be819..437f6586d 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3004,6 +3004,15 @@ "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", "PAD / RTLS": "SITO DI LANCIO / RTLS", + "Name of {name}": "Nome di {name}", + "Change the colour of {name}": "Cambia il colore di {name}", + "Colour of {name}": "Colore di {name}", + "Colour of the next shape": "Colore della prossima forma", + "Drawn shapes": "Forme disegnate", + "Audio": "Audio", + "UTILITIES": "STRUMENTI", + "Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno", + "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file", "Sea Marks": "Segnalamenti marittimi", "Ski Pistes": "Piste da sci" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1ae2cbd8f..3a70a77ad 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2864,6 +2864,15 @@ "Right panels": "右パネル", "CONFIRMED": "確認済み", "PAD / RTLS": "射場 / RTLS", + "Name of {name}": "{name}の名前", + "Change the colour of {name}": "{name}の色を変更", + "Colour of {name}": "{name}の色", + "Colour of the next shape": "次の図形の色", + "Drawn shapes": "描いた図形", + "Audio": "オーディオ", + "UTILITIES": "ユーティリティ", + "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", + "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Sea Marks": "航路標識", "Ski Pistes": "スキーゲレンデ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 778239c86..6c7117bd4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3004,6 +3004,15 @@ "Right panels": "Painéis à direita", "CONFIRMED": "CONFIRMADA", "PAD / RTLS": "PLATAFORMA / RTLS", + "Name of {name}": "Nome de {name}", + "Change the colour of {name}": "Mudar a cor de {name}", + "Colour of {name}": "Cor de {name}", + "Colour of the next shape": "Cor da próxima forma", + "Drawn shapes": "Formas desenhadas", + "Audio": "Áudio", + "UTILITIES": "UTILITÁRIOS", + "Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho", + "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos", "Sea Marks": "Sinais marítimos", "Ski Pistes": "Pistas de esqui" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c8ae233c9..0557bb53c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3080,6 +3080,9 @@ "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", "Left panels": "Левые панели", "Right panels": "Правые панели", + "UTILITIES": "ИНСТРУМЕНТЫ", + "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", + "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Sea Marks": "Морские знаки", "Ski Pistes": "Горнолыжные трассы" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 53db119d7..2be54c891 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3074,6 +3074,15 @@ "Right panels": "Праві панелі", "CONFIRMED": "ПІДТВЕРДЖЕНО", "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", + "Name of {name}": "Назва: {name}", + "Change the colour of {name}": "Змінити колір: {name}", + "Colour of {name}": "Колір: {name}", + "Colour of the next shape": "Колір наступної фігури", + "Drawn shapes": "Намальовані фігури", + "Audio": "Аудіо", + "UTILITIES": "ІНСТРУМЕНТИ", + "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", + "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Sea Marks": "Морські знаки", "Ski Pistes": "Гірськолижні траси" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 20677c4f5..e2595e778 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,6 +2870,9 @@ "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", "Left panels": "左侧面板", "Right panels": "右侧面板", + "UTILITIES": "工具", + "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", + "Directions, drawing and your own files": "路线导航、绘制和你自己的文件", "Sea Marks": "航标", "Ski Pistes": "滑雪道" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index fa07c7d5d..f2550b955 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2870,6 +2870,9 @@ "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Left panels": "左側面板", "Right panels": "右側面板", + "UTILITIES": "工具", + "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", + "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", "Sea Marks": "航標", "Ski Pistes": "滑雪道" } diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index ff0bb6a8a..0cc178013 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -49,7 +49,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - 'c13f59886cce3780e7b27462df19659242fdac98928289dce74cf61a1f74054a', + '7f113854cdfe3114b9edd69f49995502276c718ce8878320161a68bafb63ebe2', ); });