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',