From b09db551495e2c9e51c8ff30c903f9e1a4c851c3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:53:13 +0700 Subject: [PATCH 001/174] fix(server): declare the method contract on the three read-only query proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/opensky, /api/adsblol/mil and /api/route answered any verb with the same body while ignoring the request entirely. Measured against a running server before the change: POST /api/opensky returned 200 with 833,759 bytes of live state vectors, POST /api/adsblol/mil returned 47,372 bytes. Their siblings do not: /api/geocode, /api/weather and /api/transit answer 405, and /api/overpass is deliberately POST-only. These three are read-only query proxies, so they now answer 405 to anything but GET, using the wording and shape /api/geocode already uses. The refusal comes first, before rate limiting and before any upstream call, which the tests assert by stubbing fetch to throw. Scope, stated plainly: this is a consistency and intent fix. No rate limiter was being bypassed — /api/route already limits every request regardless of verb, and the two aircraft routes have no limiter at all, only 9s and 12s server-side caches that bound upstream cost for any method. One existing fixture in src/search/geospatial.test.mjs built a request without a method, which a real Node request always has; it now sets GET. Verified on a running server after the change: GET still 200 on all three, POST 405 on all three, /api/overpass and the other proxies unchanged. Suite goes from 5090 to 5092 tests, 0 failures. --- docs/CURRENT-STATE.md | 1 + server/providers/aircraft/adsb-lol.js | 7 +++++++ server/providers/aircraft/opensky.js | 8 ++++++++ server/providers/places/routes.js | 7 +++++++ src/search/geospatial.test.mjs | 6 +++++- src/tooling/liveProviders.test.mjs | 23 +++++++++++++++++++++++ src/tooling/placeProviders.test.mjs | 19 +++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..15e159ac8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..80140bb56 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -68,6 +68,13 @@ export function adsbLolProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/adsblol/mil', async (req, res) => { + // Read-only snapshot endpoint; match the method contract its sibling + // query proxies already declare instead of serving every verb alike. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const now = Date.now(); if (_cache && now - _cacheAt < CACHE_MS) { diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..aa0dad268 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -350,6 +350,14 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { export function openSkyProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/opensky', async (req, res) => { + // Read-only snapshot endpoint: state the method contract the way + // /api/geocode and the other query proxies state it, rather than + // answering any verb with the same body and ignoring the request body. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const requestedMode = normalizeOpenSkyAuthMode( process.env.OPENSKY_AUTH_MODE, diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 42e24ad32..8e837fcd4 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -227,6 +227,13 @@ export function installRouteMiddleware( // only shape that asks the upstream for them. A response WITHOUT steps is // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { + // The documented shape above is a GET with query parameters; say so, + // the way the other query proxies do, instead of answering any verb. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } const fail = (msg) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: msg })); diff --git a/src/search/geospatial.test.mjs b/src/search/geospatial.test.mjs index be846bb36..dbedbe53f 100644 --- a/src/search/geospatial.test.mjs +++ b/src/search/geospatial.test.mjs @@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo const request = async (query) => { let payload; await handler( - { url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } }, + { + url: `/?${query}`, + method: 'GET', + socket: { remoteAddress: '127.0.0.1' }, + }, { writeHead() {}, end(body) { diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..c25c445d4 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -381,3 +381,26 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => { + // Both routes answered every verb with the full snapshot, unlike the query + // proxies beside them, so nothing said whether a body was read or ignored. + // A stubbed fetch that throws proves the refusal happens before the handler + // reaches upstream, rather than after paying for the work and discarding it. + t.mock.method(globalThis, 'fetch', async (url) => { + throw new Error(`upstream must not be reached: ${url}`); + }); + const cases = [ + [install(providers.openSkyProxy()), '/api/opensky'], + [install(providers.adsbLolProxy()), '/api/adsblol/mil'], + ]; + for (const [request, route] of cases) { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request(route, '/', method); + assert.equal(response.statusCode, 405, `${method} ${route}`); + assert.deepEqual(JSON.parse(response.body), { + error: 'Method Not Allowed', + }); + } + } +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 78d311369..968c5a9fa 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -482,3 +482,22 @@ test('the upstream host is pinned: a redirect is refused, not followed', async ( await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01'); assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); }); + +test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => { + // The documented shape is a GET with query parameters, but every verb was + // answered alike. Refuse first: a stubbed fetch that throws shows the + // rejection costs neither an upstream call nor a rate-limit slot. + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('upstream must not be reached'); + }); + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + method, + ); + assert.equal(response.statusCode, 405, method); + assert.deepEqual(response.body, { error: 'Method Not Allowed' }); + } +}); From fb6b4e34f0aab85c715146739152d753d5a3efa8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 13:16:17 +0700 Subject: [PATCH 002/174] docs: changelog entry for the read-only proxy method contract (#794) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..7885f0131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- The read-only query proxies state their method contract. `/api/opensky`, + `/api/adsblol/mil` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a From 2e5dfce1af9b07664005bd1bd77e5bcb4ccf0ba4 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Tue, 29 Sep 2026 10:56:20 +0700 Subject: [PATCH 003/174] fix(server): declare Allow: GET on the new 405 responses RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts, and /api/radio/stations already does this in-tree. Thanks to kvnloo for catching it on review. The same gap on the pre-existing handlers is tracked in #809 and is deliberately left alone here. --- docs/CURRENT-STATE.md | 2 +- server/providers/aircraft/adsb-lol.js | 5 ++++- server/providers/aircraft/opensky.js | 5 ++++- server/providers/places/routes.js | 2 +- src/tooling/liveProviders.test.mjs | 3 +++ src/tooling/placeProviders.test.mjs | 2 ++ 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 15e159ac8..6e3039920 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,7 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did, declaring `Allow: GET` with it as `/api/radio/stations` does. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 80140bb56..1e5565832 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -71,7 +71,10 @@ export function adsbLolProxy() { // Read-only snapshot endpoint; match the method contract its sibling // query proxies already declare instead of serving every verb alike. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index aa0dad268..679548300 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -354,7 +354,10 @@ export function openSkyProxy() { // /api/geocode and the other query proxies state it, rather than // answering any verb with the same body and ignoring the request body. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 8e837fcd4..2c67f1f5c 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -230,7 +230,7 @@ export function installRouteMiddleware( // The documented shape above is a GET with query parameters; say so, // the way the other query proxies do, instead of answering any verb. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index c25c445d4..856a528a2 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -398,6 +398,9 @@ test('the aircraft snapshot routes decline a non-GET before any upstream work', for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { const response = await request(route, '/', method); assert.equal(response.statusCode, 405, `${method} ${route}`); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it does + // accept, which is what `/api/radio/stations` already does in-tree. + assert.equal(response.headers.allow, 'GET', `${method} ${route}`); assert.deepEqual(JSON.parse(response.body), { error: 'Method Not Allowed', }); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 968c5a9fa..afc323efc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -498,6 +498,8 @@ test('the routing proxy declines a non-GET before rate limiting or upstream', as method, ); assert.equal(response.statusCode, 405, method); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts. + assert.equal(response.headers.allow, 'GET', method); assert.deepEqual(response.body, { error: 'Method Not Allowed' }); } }); From daed0be99a03d8f416c59617f77c7cea455616eb Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:17:20 +0700 Subject: [PATCH 004/174] feat(server): compress the cached OpenSky snapshot for clients that accept gzip The ~860 KB snapshot is held for an adaptive 9-300 s TTL and was served as identity bytes to every request; no response in server/ set Content-Encoding or Vary. Gzip it once per refresh beside the cached body, off the event loop, and return the body and the header that names it together so the two can never disagree. Live against an anonymous snapshot: 874,002 B to 258,012 B, 3.39x, decompressing byte-identical to the uncompressed response. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 1 + server/providers/aircraft/opensky.js | 91 ++++++++++++---- server/providers/common/compress.js | 94 +++++++++++++++++ src/tooling/liveProviders.test.mjs | 113 +++++++++++++++++++- src/tooling/responseCompression.test.mjs | 129 +++++++++++++++++++++++ 6 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 server/providers/common/compress.js create mode 100644 src/tooling/responseCompression.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..a03d2bd5d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky` negotiates response encoding. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh, so a client that offers gzip receives roughly 860 KB as 250 KB and one that does not receives the same bytes uncompressed; both answers carry `Vary: Accept-Encoding`. Bodies the proxy does not cache — auth failures, cooldown notices — are never sent under a cached response's encoding. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..363966ba9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -35,6 +35,7 @@ "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", + "server/providers/common/compress.js", "server/providers/common/http.js", "server/providers/common/query.js", "server/providers/aircraft/opensky.js", diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..a37315b9d 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,4 +1,5 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; +import { encodedBody, gzipBody } from '../common/compress.js'; import { coalesceProxyRequest, readResponseJsonCapped, @@ -15,6 +16,12 @@ let _openskyTokenExpiry = 0; let _openskyTokenPromise = null; /** @type {string|null} Cached upstream response body (JSON text). */ let _openskyCacheBody = null; +/** + * @type {Buffer|null} Gzip of `_openskyCacheBody`, written in the same step as + * the body it encodes so the two can never describe different snapshots. Null + * whenever the cached body is too small to be worth a frame. + */ +let _openskyCacheGzip = null; /** @type {number} HTTP status of the cached response. */ let _openskyCacheStatus = 0; /** @type {number} Epoch-ms when the response was cached. */ @@ -271,8 +278,13 @@ async function fetchAdsbLolPointFallback(req) { ADSBLOL_POINT_MAX_RESPONSE_BYTES, ); const normalized = normalizeAdsbLolPointResponse(payload); + const body = JSON.stringify(normalized); const record = { - body: JSON.stringify(normalized), + body, + // Compressed with the body it belongs to, so a cache entry served + // many times pays for the frame once. Entries are bounded by + // ADSBLOL_POINT_CACHE_MAX, so the extra residency is bounded too. + gzip: await gzipBody(body), cachedAt: Date.now(), count: normalized.states.length, }; @@ -301,6 +313,7 @@ async function fetchAdsbLolPointFallback(req) { async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { const fallback = await fetchAdsbLolPointFallback(req); if (!fallback) return false; + const encoded = encodedBody(req, fallback.body, fallback.gzip); res.writeHead(200, { ...buildOpenSkyHeaders({ cacheStatus: fallback.cacheStatus, @@ -311,8 +324,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { 'X-Flight-Source': 'adsb.lol', 'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`, 'X-Flight-Count': String(fallback.count), + ...encoded.headers, }); - res.end(fallback.body); + res.end(encoded.body); return true; } @@ -380,9 +394,13 @@ export function openSkyProxy() { reason: 'cached', }; const isStale = now - _openskyCacheTime >= _openskyTtlMs; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: isStale ? 'STALE' : 'HIT', requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', @@ -396,8 +414,9 @@ export function openSkyProxy() { ? (_openskyCooldownUntil - now) / 1000 : undefined, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } // Cooling down with nothing cached (cold start into a rate limit): @@ -507,7 +526,12 @@ export function openSkyProxy() { ) { // Keep the last global snapshot available as a fail-soft cache, // but do not label or render it as a fresh live result. + // The frame is built before either slot is written so the two are + // always published together: a request arriving mid-refresh reads the + // previous body with the previous body's frame, never a mismatch. + const frame = await gzipBody(body); _openskyCacheBody = body; + _openskyCacheGzip = frame; _openskyCacheStatus = upstream.status; _openskyCacheTime = now; _openskyCacheSourceEpochMs = sourceEpochMs; @@ -532,9 +556,13 @@ export function openSkyProxy() { // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { - res.writeHead( - 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode, usedMode, @@ -542,8 +570,9 @@ export function openSkyProxy() { staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } } @@ -616,7 +645,10 @@ export function openSkyProxy() { // Only cache successful responses — error responses (401/403/429/5xx) // should not be served from cache on subsequent requests if (upstream.ok) { + // Published as a pair, for the reason given at the other cache fill. + const frame = await gzipBody(body); _openskyCacheBody = body; + _openskyCacheGzip = frame; _openskyCacheStatus = upstream.status; _openskyCacheTime = now; _openskyCacheSourceEpochMs = sourceEpochMs; @@ -635,16 +667,26 @@ export function openSkyProxy() { _openskyCooldownUntil = 0; } - res.writeHead( - upstream.status, - buildOpenSkyHeaders({ + // Only the branch above caches, so the gzip slot belongs to this body + // only when this body is the one it just stored. An error response + // (401/403/429/5xx) is not cached and must not borrow the previous + // snapshot's frame — identity comparison, not `upstream.ok`, because it + // stays correct if the caching condition above ever changes. + const encoded = encodedBody( + req, + body, + _openskyCacheBody === body ? _openskyCacheGzip : null, + ); + res.writeHead(upstream.status, { + ...buildOpenSkyHeaders({ cacheStatus: 'MISS', requestedMode, usedMode, reason, }), - ); - res.end(body); + ...encoded.headers, + }); + res.end(encoded.body); } catch (e) { console.error('[OpenSky Proxy]', e.message); if (_openskyCacheBody) { @@ -655,17 +697,22 @@ export function openSkyProxy() { usedMode: 'unknown', reason: 'cached_stale', }; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, usedMode: cachedMeta.usedMode || 'unknown', reason: cachedMeta.reason || 'cached_stale', }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } const requestedMode = normalizeOpenSkyAuthMode( diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js new file mode 100644 index 000000000..7d93b3594 --- /dev/null +++ b/server/providers/common/compress.js @@ -0,0 +1,94 @@ +import { gzip } from 'node:zlib'; +import { promisify } from 'node:util'; + +const gzipAsync = promisify(gzip); + +/** + * Response compression for proxies that already hold a whole body in memory. + * + * Deliberately not a middleware. Two response paths in this tree must never be + * wrapped: `cctv/media.js` streams a piped body and answers byte-range requests + * with 206, and `wind/gfs.js` also serves ranges — a wrapper that buffered or + * re-encoded those would break playback and range semantics. Seven providers + * additionally forward an upstream `content-type` verbatim, including already + * compressed binary formats that gzip would only make larger. + * + * So compression is opt-in at the call site, for bodies the proxy holds and + * knows to be text. The failure mode of forgetting a site is a response that is + * merely uncompressed, never a corrupt one — but only as long as the encoded + * body and its `Content-Encoding` are chosen together, which is why + * `encodedBody` returns both rather than exposing them separately. + */ + +/** Below this, a gzip frame costs more than the bytes it saves. */ +const MIN_COMPRESSED_BYTES = 1024; + +/** + * Whether the client offered gzip in `Accept-Encoding`. + * + * Anything this cannot read confidently — a missing header, a malformed + * quality value, the `x-gzip` alias — reads as "no", because the fallback is an + * identity response every client can decode. + * + * @param {import('node:http').IncomingMessage} request + * @returns {boolean} + */ +export function acceptsGzip(request) { + const header = request?.headers?.['accept-encoding']; + if (typeof header !== 'string') return false; + return header.split(',').some((part) => { + const [token, ...parameters] = part.split(';'); + if (token.trim().toLowerCase() !== 'gzip') return false; + // `q=0` is the one way a client can name an encoding and still refuse it + // (RFC 9110 section 12.4.2). + const quality = parameters + .map((parameter) => parameter.trim().toLowerCase()) + .find((parameter) => parameter.startsWith('q=')); + return quality === undefined || Number.parseFloat(quality.slice(2)) > 0; + }); +} + +/** + * Gzip a body once, for callers that cache it and serve it many times. + * + * OpenSky's snapshot is ~860 KB and is held for the cache TTL, so this is paid + * per refresh rather than per request. It runs off the event loop on purpose: + * on that snapshot the synchronous call stalls every other in-flight request by + * 11 ms, while this stalls them by under 1 ms and costs the caller — which is + * already waiting on an upstream fetch — about 5 ms more of its own latency. + * + * Returns null when the body is too small to be worth a frame, so callers can + * store the result directly and treat null as "serve identity". + * + * @param {string|Buffer} body - the exact bytes the identity response sends. + * @returns {Promise} + */ +export async function gzipBody(body) { + if (body == null) return null; + const raw = Buffer.isBuffer(body) ? body : Buffer.from(String(body)); + if (raw.byteLength < MIN_COMPRESSED_BYTES) return null; + const compressed = await gzipAsync(raw); + return compressed.byteLength < raw.byteLength ? compressed : null; +} + +/** + * Pick the body to send and the headers that describe it, together. + * + * `Vary: Accept-Encoding` is set on both branches, not only the compressed one: + * a cache that stored an identity response without it would later hand those + * bytes to a client that asked for gzip, and vice versa. + * + * @param {import('node:http').IncomingMessage} request + * @param {string|Buffer} identity - the uncompressed body. + * @param {Buffer|null} compressed - `gzipBody(identity)`, or null. + * @returns {{body: string|Buffer, headers: Record}} + */ +export function encodedBody(request, identity, compressed) { + if (compressed && acceptsGzip(request)) { + return { + body: compressed, + headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' }, + }; + } + return { body: identity, headers: { Vary: 'Accept-Encoding' } }; +} diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..94bbb6a27 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { gunzipSync } from 'node:zlib'; import { WebSocketServer } from 'ws'; import { setTimeout as delay } from 'node:timers/promises'; import * as providers from '../../server/providers/live.js'; @@ -15,7 +16,7 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { assert.ok(routes.has(route), `registered route: ${route}`); const response = { statusCode: 200, @@ -32,7 +33,7 @@ function install(plugin, preview = false) { this.body = body; }, }; - await routes.get(route)({ url, method }, response); + await routes.get(route)({ url, method, headers }, response); return response; }; } @@ -381,3 +382,111 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => { + environment(t, { + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, + }); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + + let upstreamStatus = 200; + let snapshotAgeMs = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/lat/')) + return Response.json({ + now: now / 1000, + ac: Array.from({ length: 200 }, (_, index) => ({ + hex: `abc${index}`, + lat: 30, + lon: -97, + alt_baro: 10000, + })), + }); + assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`); + if (upstreamStatus !== 200) + return new Response('{"error":"upstream"}', { status: upstreamStatus }); + const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000); + // A healthy credit budget, so the proxy keeps its 9 s base TTL and the + // clock advances below are unambiguously past it. + return Response.json( + { + time: sourceSeconds, + states: Array.from({ length: 200 }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + sourceSeconds, + -97.7, + 30.1, + 10000, + ]), + }, + { headers: { 'x-rate-limit-remaining': '4000' } }, + ); + }); + + // A module instance of its own: the snapshot cache, its frame and the + // adaptive TTL are module state, and this test reasons about all three. + const isolated = await import( + `../../server/providers/aircraft/opensky.js?compression=${now}` + ); + const states = install(isolated.openSkyProxy()); + const compressed = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip, deflate, br', + }); + assert.equal(compressed.statusCode, 200); + assert.equal(compressed.headers['content-encoding'], 'gzip'); + assert.equal(compressed.headers.vary, 'Accept-Encoding'); + assert.ok(Buffer.isBuffer(compressed.body)); + + // Same snapshot from cache, this time to a client that offered nothing: the + // identity bytes, and the compressed frame above must decode to exactly them. + const identity = await states('/api/opensky'); + assert.equal(identity.headers['x-opensky-cache'], 'HIT'); + assert.equal(identity.headers['content-encoding'], undefined); + assert.equal(identity.headers.vary, 'Accept-Encoding'); + assert.equal(gunzipSync(compressed.body).toString(), identity.body); + + // A client that names gzip and refuses it with q=0 is a non-gzip client. + const refused = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip;q=0', + }); + assert.equal(refused.headers['content-encoding'], undefined); + assert.equal(refused.body, identity.body); + + // An upstream rejection is not cached, so it has no frame of its own. It must + // not be sent under the previous snapshot's Content-Encoding. + upstreamStatus = 403; + now += 60000; // past the 9 s base TTL this fresh instance starts with + const rejected = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(rejected.statusCode, 403); + assert.equal(rejected.headers['content-encoding'], undefined); + assert.ok(JSON.parse(rejected.body).error, 'a readable error body'); + + // The regional fallback is a fifth body this route can answer with. It is + // cached the same way and so it negotiates the same way: a snapshot too old + // to serve hands the request to adsb.lol, compressed. + upstreamStatus = 200; + snapshotAgeMs = 3600000; + now += 60000; + const regional = await states('/api/opensky', '?lat=30&lon=-97', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(regional.statusCode, 200); + assert.equal(regional.headers['x-flight-source'], 'adsb.lol'); + assert.equal(regional.headers['content-encoding'], 'gzip'); + assert.equal(regional.headers.vary, 'Accept-Encoding'); + assert.equal( + JSON.parse(gunzipSync(regional.body).toString()).states.length, + 200, + ); +}); diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs new file mode 100644 index 000000000..92fee57a3 --- /dev/null +++ b/src/tooling/responseCompression.test.mjs @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { gunzipSync } from 'node:zlib'; +import { + acceptsGzip, + encodedBody, + gzipBody, +} from '../../server/providers/common/compress.js'; + +/** A request shaped like the one Node hands a middleware. */ +function request(acceptEncoding) { + return { + url: '/api/opensky', + method: 'GET', + headers: + acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding }, + }; +} + +/** Repetitive JSON, the shape of an actual snapshot body. */ +function snapshotBody(states = 200) { + return JSON.stringify({ + time: 1700000000, + states: Array.from({ length: states }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + 1700000000, + 30.1, + -97.7, + 10000, + ]), + }); +} + +/** + * Deterministic high-entropy bytes. Random data would do, but a seeded sequence + * keeps the "gzip made it bigger" assertion from depending on luck. + */ +function incompressibleBytes(length) { + const bytes = Buffer.alloc(length); + let state = 123456789; + for (let index = 0; index < length; index += 1) { + state = (state * 1103515245 + 12345) % 2147483648; + bytes[index] = (state >>> 16) & 0xff; + } + return bytes; +} + +test('accept-encoding is read the way clients actually write it', () => { + assert.equal(acceptsGzip(request('gzip')), true); + assert.equal(acceptsGzip(request('gzip, deflate, br')), true); + assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true); + assert.equal(acceptsGzip(request('GZIP')), true); + assert.equal(acceptsGzip(request(' gzip ')), true); + + assert.equal(acceptsGzip(request('br, deflate')), false); + assert.equal(acceptsGzip(request('identity')), false); + assert.equal(acceptsGzip(request('')), false); + assert.equal(acceptsGzip(request(undefined)), false); + assert.equal(acceptsGzip({}), false); + assert.equal(acceptsGzip(undefined), false); + + // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2), + // and an unreadable quality falls back to the encoding every client accepts. + assert.equal(acceptsGzip(request('gzip;q=0')), false); + assert.equal(acceptsGzip(request('gzip;q=0.000')), false); + assert.equal(acceptsGzip(request('gzip;q=bogus')), false); + assert.equal(acceptsGzip(request('gzip;q=0.001')), true); + + // `gzip` is not matched as a substring of a neighbouring token. + assert.equal(acceptsGzip(request('x-gzip')), false); + assert.equal(acceptsGzip(request('gzipped')), false); +}); + +test('a body is only compressed when compression pays for itself', async () => { + assert.equal(await gzipBody(null), null); + assert.equal(await gzipBody(undefined), null); + assert.equal(await gzipBody(''), null); + assert.equal( + await gzipBody('{"states":[]}'), + null, + 'below the frame threshold', + ); + assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short'); + + const identity = snapshotBody(); + assert.ok(identity.length > 1024, 'fixture is worth compressing'); + const compressed = await gzipBody(identity); + assert.ok(Buffer.isBuffer(compressed)); + assert.ok(compressed.byteLength < Buffer.byteLength(identity)); + assert.equal(gunzipSync(compressed).toString(), identity); + + // Buffers are accepted as-is, and data that gzip cannot shrink is refused + // rather than served as a larger response. + assert.ok((await gzipBody(Buffer.from(identity))) !== null); + assert.equal(await gzipBody(incompressibleBytes(4096)), null); +}); + +test('the encoded body and the header that names it are chosen together', async () => { + const identity = snapshotBody(); + const compressed = await gzipBody(identity); + + const negotiated = encodedBody(request('gzip'), identity, compressed); + assert.equal(negotiated.body, compressed); + assert.equal(negotiated.headers['Content-Encoding'], 'gzip'); + assert.equal(negotiated.headers.Vary, 'Accept-Encoding'); + assert.equal(gunzipSync(negotiated.body).toString(), identity); + + // Offered but not wanted: identity bytes, and no Content-Encoding to + // contradict them. + const refused = encodedBody(request('br'), identity, compressed); + assert.equal(refused.body, identity); + assert.equal(refused.headers['Content-Encoding'], undefined); + assert.equal(refused.headers.Vary, 'Accept-Encoding'); + + // Wanted but not available: same answer. + const unavailable = encodedBody(request('gzip'), identity, null); + assert.equal(unavailable.body, identity); + assert.equal(unavailable.headers['Content-Encoding'], undefined); + + // Vary is on both branches: without it a shared cache could hand a stored + // response to a client whose Accept-Encoding it was not selected for. + assert.equal(unavailable.headers.Vary, 'Accept-Encoding'); + assert.equal( + encodedBody(request(undefined), identity, compressed).headers.Vary, + 'Accept-Encoding', + ); +}); From 47b13ab18e4cafc961f049fd361cb0ed29fe51b1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:18:09 +0700 Subject: [PATCH 005/174] docs(changelog): note OpenSky snapshot compression (#795) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..aa36408a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- The live flight snapshot is compressed on the wire. `/api/opensky` gzips its + cached global snapshot and its regional adsb.lol fallback once per upstream + refresh and serves that frame to clients offering gzip — roughly 860 KB + becomes roughly 258 KB — while a client that offers nothing receives the same + bytes uncompressed. Both answers carry `Vary: Accept-Encoding`, and a response + the proxy does not cache, such as an auth failure, is never sent under a + cached snapshot's encoding (daikaginza, #795). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a From e7dd71e9bcdd836e046aba84ba98d5889d482025 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Tue, 29 Sep 2026 11:13:36 +0700 Subject: [PATCH 006/174] fix(server): read the whole q value, not a numeric prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number.parseFloat accepts any valid numeric prefix, so `gzip;q=1oops` read as 1 and the out-of-range `gzip;q=2` was accepted — both contradicting the comment two lines above, which promises that a quality this cannot read confidently fails closed. Matching the qvalue grammar of RFC 9110 section 12.4.2 makes the code do what it already claimed to do. Thanks to kvnloo for catching it on review. --- server/providers/common/compress.js | 21 +++++++++++++++------ src/tooling/responseCompression.test.mjs | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js index 7d93b3594..ebf736eda 100644 --- a/server/providers/common/compress.js +++ b/server/providers/common/compress.js @@ -23,12 +23,20 @@ const gzipAsync = promisify(gzip); /** Below this, a gzip frame costs more than the bytes it saves. */ const MIN_COMPRESSED_BYTES = 1024; +/** + * The `qvalue` grammar of RFC 9110 section 12.4.2: zero to three decimals + * below one, or one with only zeros after it. Matching the whole token rather + * than parsing a numeric prefix is what makes `q=1oops` and the out-of-range + * `q=2` unreadable instead of quietly rounding to something acceptable. + */ +const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/; + /** * Whether the client offered gzip in `Accept-Encoding`. * - * Anything this cannot read confidently — a missing header, a malformed - * quality value, the `x-gzip` alias — reads as "no", because the fallback is an - * identity response every client can decode. + * Anything this cannot read confidently — a missing header, a malformed or + * out-of-range quality value, the `x-gzip` alias — reads as "no", because the + * fallback is an identity response every client can decode. * * @param {import('node:http').IncomingMessage} request * @returns {boolean} @@ -39,12 +47,13 @@ export function acceptsGzip(request) { return header.split(',').some((part) => { const [token, ...parameters] = part.split(';'); if (token.trim().toLowerCase() !== 'gzip') return false; - // `q=0` is the one way a client can name an encoding and still refuse it - // (RFC 9110 section 12.4.2). const quality = parameters .map((parameter) => parameter.trim().toLowerCase()) .find((parameter) => parameter.startsWith('q=')); - return quality === undefined || Number.parseFloat(quality.slice(2)) > 0; + if (quality === undefined) return true; + // `q=0` is the one way a client can name an encoding and still refuse it. + const value = quality.slice(2); + return QVALUE.test(value) && Number(value) > 0; }); } diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs index 92fee57a3..c5c70dae7 100644 --- a/src/tooling/responseCompression.test.mjs +++ b/src/tooling/responseCompression.test.mjs @@ -61,12 +61,23 @@ test('accept-encoding is read the way clients actually write it', () => { assert.equal(acceptsGzip({}), false); assert.equal(acceptsGzip(undefined), false); - // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2), - // and an unreadable quality falls back to the encoding every client accepts. + // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2). assert.equal(acceptsGzip(request('gzip;q=0')), false); assert.equal(acceptsGzip(request('gzip;q=0.000')), false); - assert.equal(acceptsGzip(request('gzip;q=bogus')), false); assert.equal(acceptsGzip(request('gzip;q=0.001')), true); + assert.equal(acceptsGzip(request('gzip;q=1')), true); + assert.equal(acceptsGzip(request('gzip;q=1.000')), true); + + // A quality that is not a whole `qvalue` is unreadable, not a number with + // trailing noise: the grammar allows three decimals and a ceiling of 1, so + // these must not round into acceptance. + assert.equal(acceptsGzip(request('gzip;q=bogus')), false); + assert.equal(acceptsGzip(request('gzip;q=1oops')), false); + assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false); + assert.equal(acceptsGzip(request('gzip;q=2')), false); + assert.equal(acceptsGzip(request('gzip;q=1.5')), false); + assert.equal(acceptsGzip(request('gzip;q=0.0001')), false); + assert.equal(acceptsGzip(request('gzip;q=')), false); // `gzip` is not matched as a substring of a neighbouring token. assert.equal(acceptsGzip(request('x-gzip')), false); From 28f3243090fe2aa766d9df2f096e095811f04ac0 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:20:16 -0500 Subject: [PATCH 007/174] feat(cyclones): publish explicit selection to shared context --- scripts/package-boundaries.json | 2 + src/app/constructCatalog.js | 4 +- src/app/layers/cyclones.js | 11 +++++ src/layers/cyclones/index.js | 52 +++++++++++++++++++++ src/layers/cyclones/index.test.mjs | 72 +++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/app/layers/cyclones.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..93bdcf7e1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1340,6 +1340,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", @@ -2075,6 +2076,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7ed03d0d2..0437e1749 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,6 +1,6 @@ import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; -import { createCyclonesLayer } from '../layers/cyclones/index.js'; +import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; @@ -181,7 +181,7 @@ export function createApplicationCatalog({ id: 'weather-lightning', clock: weatherClock, }), - createCyclonesLayer({ feed: sources.cyclones }), + createApplicationCyclones({ feed: sources.cyclones }), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), createApplicationFirms({ diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js new file mode 100644 index 000000000..d671a11eb --- /dev/null +++ b/src/app/layers/cyclones.js @@ -0,0 +1,11 @@ +import { createCyclonesLayer } from '../../layers/cyclones/index.js'; +import * as context from '../../data/contextStore.js'; + +/** Wire cyclone selection into the application shared-context owner. */ +export function createApplicationCyclones(options) { + const browserContext = typeof window !== 'undefined' ? { context } : {}; + return createCyclonesLayer({ + ...browserContext, + ...options, + }); +} diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 666614176..2738bebe0 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -37,6 +37,7 @@ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, + context = null, cesium = Cesium, createRendering = createCycloneRendering, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -52,6 +53,8 @@ export function createCyclonesLayer({ selectedId = null, selectionIntent = 'auto', navigationGeneration = 0, + selectedContextStormId = null, + selectedContextEntity = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -62,6 +65,46 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; + + function clearSelectedContext() { + context?.removeEntityContextsForLayer?.('weather-cyclones'); + selectedContextStormId = null; + selectedContextEntity = null; + } + + function publishSelectedContext({ announce = false } = {}) { + const storm = selected(); + if (!storm || !context) { + if (!storm) clearSelectedContext(); + return null; + } + selectedContextEntity ||= { show: true }; + const record = context.registerEntityContext?.(selectedContextEntity, { + id: `cyclone:${storm.id}`, + layerId: 'weather-cyclones', + layerName: 'Cyclone advisories', + source: 'NOAA NHC / CPHC', + label: `Cyclone · ${storm.name}`, + latitude: storm.position.latitude, + longitude: storm.position.longitude, + properties: { + classification: storm.classification, + basin: storm.basin, + advisoryNumber: storm.advisoryNumber, + issuedAt: storm.issuedAt, + positionAt: storm.positionAt, + windKt: storm.windKt, + pressureHpa: storm.pressureHpa, + movement: storm.movement, + advisoryUrl: storm.advisoryUrl, + }, + }); + if (!record) return null; + selectedContextStormId = storm.id; + if (announce) context.selectEntityContext?.(selectedContextEntity); + return record; + } + function select(id) { if (selectedId !== id) ++navigationGeneration; selectedId = id; @@ -206,6 +249,7 @@ export function createCyclonesLayer({ selectedId = null; selectionIntent = 'auto'; ++navigationGeneration; + clearSelectedContext(); rendering?.clear(); }, async update(_viewer, { signal } = {}) { @@ -229,6 +273,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; + clearSelectedContext(); error = next.reason || 'Cyclone advisories unavailable'; return true; } @@ -251,6 +296,10 @@ export function createCyclonesLayer({ : snapshot.storms[0]?.id || null, ); else rendering.setSelection(selectedId); + if (selectedContextStormId) { + if (selectedId === selectedContextStormId) publishSelectedContext(); + else clearSelectedContext(); + } return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; @@ -260,6 +309,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; + clearSelectedContext(); return true; } finally { signal?.removeEventListener('abort', abort); @@ -275,6 +325,7 @@ export function createCyclonesLayer({ if (params.clear === true || params.stormId === null) { selectionIntent = 'cleared'; select(null); + clearSelectedContext(); notify(); } else if ( typeof params.stormId === 'string' && @@ -282,6 +333,7 @@ export function createCyclonesLayer({ ) { selectionIntent = 'user'; select(params.stormId); + publishSelectedContext({ announce: true }); notify(); } const storm = selected(); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 4127871f4..7d796bef7 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -338,7 +338,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick }); function harness( feed = { getSnapshot: async () => snapshot() }, - { reducedMotion = false } = {}, + { reducedMotion = false, context = null } = {}, ) { const applied = [], navigation = [], @@ -368,6 +368,7 @@ function harness( }; const layer = createCyclonesLayer({ feed, + context, createRendering: () => rendering, matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), @@ -401,6 +402,75 @@ function harness( }, }; } +test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { + let next = snapshot([storm(), storm('ep162026')]); + const records = new Map(); + let selectedContext = null; + let selectedEvents = 0; + const context = { + registerEntityContext(entity, metadata) { + entity.__gevContextId = metadata.id; + const record = { ...metadata, entity }; + records.set(metadata.id, record); + return record; + }, + selectEntityContext(entity) { + selectedContext = records.get(entity.__gevContextId) || null; + selectedEvents += 1; + return selectedContext; + }, + removeEntityContextsForLayer(layerId) { + for (const [id, record] of records) { + if (record.layerId === layerId) records.delete(id); + } + if (selectedContext?.layerId === layerId) selectedContext = null; + }, + }; + const h = harness({ getSnapshot: async () => next }, { context }); + h.layer.enable(); + await h.layer.update(); + + assert.equal( + selectedEvents, + 0, + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(records.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + assert.equal(selectedEvents, 1); + assert.equal(selectedContext?.layerId, 'weather-cyclones'); + assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); + assert.equal(selectedContext?.label, 'Cyclone · Another system'); + assert.equal(selectedContext?.latitude, 15.5); + assert.equal(selectedContext?.longitude, -125.8); + assert.equal(selectedContext?.properties.advisoryNumber, '10'); + + await h.layer.update(); + assert.equal( + selectedEvents, + 1, + 'refresh updates the selected record without re-announcing the click', + ); + + next = snapshot([storm()]); + await h.layer.update(); + assert.equal( + selectedContext, + null, + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(records.size, 0); + + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(selectedEvents, 2); + h.layer.setParams({ clear: true }); + assert.equal(selectedContext, null); + assert.equal(records.size, 0); + + h.layer.destroy(); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); From a803be1af273051738e633e778ca04fce1ae0387 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:01:44 -0500 Subject: [PATCH 008/174] fix(cyclones): evict prior shared selection on storm switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression: explicit storm A→B→A keeps one active record. Reviewer finding: quanticsoul4772 (#847). --- changelog.d/847-cyclone-shared-selection.md | 4 ++++ docs/CURRENT-STATE.md | 5 +++++ src/layers/cyclones/index.js | 6 +++++- src/layers/cyclones/index.test.mjs | 20 +++++++++++++++----- 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 changelog.d/847-cyclone-shared-selection.md diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md new file mode 100644 index 000000000..a2f58ca71 --- /dev/null +++ b/changelog.d/847-cyclone-shared-selection.md @@ -0,0 +1,4 @@ +- Explicit cyclone selections publish only the currently selected NHC/CPHC + storm into shared context. Switching storms evicts the previous identity + instead of leaving stale coordinates active (kvnloo, #847; reviewed by + quanticsoul4772). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..8e2662bf4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -218,6 +218,11 @@ not storm size or the full hazard area. Successful empty and unavailable states are distinct. One owned native Cesium data source preserves polygon holes and geographic seams and releases on disable; forecast animation is not implied. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. +Explicit cyclone selections publish one shared `weather-cyclones` record +with official advisory provenance and source position. Switching storms removes +the prior record; automatic row selection never claims global context, and +refreshes do not re-announce selection. Clearing or losing an explicitly selected +storm removes shared context rather than assigning a fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 2738bebe0..ae4e5175a 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -79,8 +79,12 @@ export function createCyclonesLayer({ return null; } selectedContextEntity ||= { show: true }; + const contextId = `cyclone:${storm.id}`; + context.removeEntityContextsForLayer?.('weather-cyclones', { + retainIds: new Set([contextId]), + }); const record = context.registerEntityContext?.(selectedContextEntity, { - id: `cyclone:${storm.id}`, + id: contextId, layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 7d796bef7..d1b7fefd4 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -419,11 +419,13 @@ test('explicit cyclone selection publishes shared context without auto-claiming selectedEvents += 1; return selectedContext; }, - removeEntityContextsForLayer(layerId) { + removeEntityContextsForLayer(layerId, { retainIds } = {}) { for (const [id, record] of records) { - if (record.layerId === layerId) records.delete(id); + if (record.layerId === layerId && !retainIds?.has(id)) + records.delete(id); } - if (selectedContext?.layerId === layerId) selectedContext = null; + if (selectedContext && !records.has(selectedContext.id)) + selectedContext = null; }, }; const h = harness({ getSnapshot: async () => next }, { context }); @@ -446,10 +448,18 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(selectedContext?.longitude, -125.8); assert.equal(selectedContext?.properties.advisoryNumber, '10'); + h.layer.setParams({ stormId: 'ep152026' }); + assert.deepEqual([...records.keys()], ['cyclone:ep152026']); + assert.equal(selectedContext?.id, 'cyclone:ep152026'); + h.layer.setParams({ stormId: 'ep162026' }); + assert.deepEqual([...records.keys()], ['cyclone:ep162026']); + assert.equal(selectedContext?.id, 'cyclone:ep162026'); + assert.equal(selectedEvents, 3, 'each explicit storm switch announces once'); + await h.layer.update(); assert.equal( selectedEvents, - 1, + 3, 'refresh updates the selected record without re-announcing the click', ); @@ -463,7 +473,7 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(records.size, 0); h.layer.setParams({ stormId: 'ep152026' }); - assert.equal(selectedEvents, 2); + assert.equal(selectedEvents, 4); h.layer.setParams({ clear: true }); assert.equal(selectedContext, null); assert.equal(records.size, 0); From 3a7c7b7c6a9c1c2a126bb6c415386f6016a2cdec Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:36:07 -0500 Subject: [PATCH 009/174] fix(cyclones): keep selected names source-owned for i18n --- src/layers/cyclones/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 2529b98e0..66bf13bbd 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -111,7 +111,9 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - label: `Cyclone · ${storm.name}`, + // Layer identity is already carried separately. Keep source names literal + // rather than adding an untranslated user-facing prefix. + label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, properties: { From 0e2ae76f0ab0f75df72010c1c6ae41c3c052b505 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:36:09 -0500 Subject: [PATCH 010/174] test(cyclones): assert source-owned context label --- src/layers/cyclones/index.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 06342464b..f7cbcdc12 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -443,7 +443,7 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(selectedEvents, 1); assert.equal(selectedContext?.layerId, 'weather-cyclones'); assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); - assert.equal(selectedContext?.label, 'Cyclone · Another system'); + assert.equal(selectedContext?.label, 'Another system'); assert.equal(selectedContext?.latitude, 15.5); assert.equal(selectedContext?.longitude, -125.8); assert.equal(selectedContext?.properties.advisoryNumber, '10'); From d3370d6bc4ce5f264fd29eece459052e6565cb1a Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:40 +0700 Subject: [PATCH 011/174] docs(changelog): move the entry into a changelog.d fragment Main now takes pending entries as changelog.d/-.md files (#787), so the entry leaves CHANGELOG.md for changelog.d/794-api-method-guard.md. --- changelog.d/794-api-method-guard.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/794-api-method-guard.md diff --git a/changelog.d/794-api-method-guard.md b/changelog.d/794-api-method-guard.md new file mode 100644 index 000000000..530be060e --- /dev/null +++ b/changelog.d/794-api-method-guard.md @@ -0,0 +1,7 @@ +- The read-only query proxies state their method contract. `/api/flights`, + `/api/military` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). From 69777700674fa5f4dfcc3729a553b96a9a91ffe0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:42 +0700 Subject: [PATCH 012/174] docs(changelog): move the entry into a changelog.d fragment Main now takes pending entries as changelog.d/-.md files (#787), so the entry leaves CHANGELOG.md for changelog.d/795-opensky-compress-cached-body.md. --- changelog.d/795-opensky-compress-cached-body.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/795-opensky-compress-cached-body.md diff --git a/changelog.d/795-opensky-compress-cached-body.md b/changelog.d/795-opensky-compress-cached-body.md new file mode 100644 index 000000000..c0bbadbed --- /dev/null +++ b/changelog.d/795-opensky-compress-cached-body.md @@ -0,0 +1,7 @@ +- The live flight snapshot is compressed on the wire. `/api/flights` gzips its + cached global snapshot and its regional adsb.lol fallback once per upstream + refresh and serves that frame to clients offering gzip — roughly 860 KB + becomes roughly 258 KB — while a client that offers nothing receives the same + bytes uncompressed. Both answers carry `Vary: Accept-Encoding`, and a response + the proxy does not cache, such as an auth failure, is never sent under a + cached snapshot's encoding (daikaginza, #795). From b358b5dba176e744fa53de73074a0b242b768a4f Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:17:39 +0100 Subject: [PATCH 013/174] fix(cyclones): distinguish deliberate clears from feed eviction --- changelog.d/847-cyclone-shared-selection.md | 6 +-- docs/CURRENT-STATE.md | 2 + src/layers/cyclones/index.js | 18 ++++--- src/layers/cyclones/index.test.mjs | 59 +++++++++++++++++++++ 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md index a2f58ca71..24edf2ebb 100644 --- a/changelog.d/847-cyclone-shared-selection.md +++ b/changelog.d/847-cyclone-shared-selection.md @@ -1,4 +1,4 @@ - Explicit cyclone selections publish only the currently selected NHC/CPHC - storm into shared context. Switching storms evicts the previous identity - instead of leaving stale coordinates active (kvnloo, #847; reviewed by - quanticsoul4772). + storm into shared context. Switching storms removes the previous identity + without a false eviction event. Deliberate clears remain distinct from feed + disappearance (kvnloo, #847; reviewed by quanticsoul4772). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42ebf7555..9d6680086 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -394,6 +394,8 @@ with official advisory provenance and source position. Switching storms removes the prior record; automatic row selection never claims global context, and refreshes do not re-announce selection. Clearing or losing an explicitly selected storm removes shared context rather than assigning a fallback. +Deliberate clear/disable reports `deliberate`; feed disappearance reports +`evicted`. Switching storms selects the replacement before pruning the old record. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 66bf13bbd..1b45265c5 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -89,7 +89,9 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; - function clearSelectedContext() { + function clearSelectedContext({ evicted = false } = {}) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); context?.removeEntityContextsForLayer?.('weather-cyclones'); selectedContextStormId = null; selectedContextEntity = null; @@ -98,14 +100,11 @@ export function createCyclonesLayer({ function publishSelectedContext({ announce = false } = {}) { const storm = selected(); if (!storm || !context) { - if (!storm) clearSelectedContext(); + if (!storm) clearSelectedContext({ evicted: true }); return null; } selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; - context.removeEntityContextsForLayer?.('weather-cyclones', { - retainIds: new Set([contextId]), - }); const record = context.registerEntityContext?.(selectedContextEntity, { id: contextId, layerId: 'weather-cyclones', @@ -131,6 +130,9 @@ export function createCyclonesLayer({ if (!record) return null; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); + context.removeEntityContextsForLayer?.('weather-cyclones', { + retainIds: new Set([contextId]), + }); return record; } @@ -302,7 +304,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); error = next.reason || ADVISORIES_UNAVAILABLE; return true; } @@ -327,7 +329,7 @@ export function createCyclonesLayer({ else rendering.setSelection(selectedId); if (selectedContextStormId) { if (selectedId === selectedContextStormId) publishSelectedContext(); - else clearSelectedContext(); + else clearSelectedContext({ evicted: true }); } return true; } catch (cause) { @@ -338,7 +340,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); return true; } finally { signal?.removeEventListener('abort', abort); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f7cbcdc12..f1c7656b1 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,7 @@ import { isPointerFree, } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; +import * as contextStore from '../../data/contextStore.js'; const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -481,6 +482,64 @@ test('explicit cyclone selection publishes shared context without auto-claiming h.layer.destroy(); }); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const host = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: host, + }); + let h; + t.after(() => { + try { + h?.layer.destroy(); + } finally { + if (previousWindow) + Object.defineProperty(globalThis, 'window', previousWindow); + else delete globalThis.window; + } + }); + const events = []; + host.addEventListener('gev:entity-selected', ({ detail }) => + events.push(['selected', detail.id]), + ); + host.addEventListener('gev:entity-selection-cleared', ({ detail }) => + events.push(['cleared', detail.reason]), + ); + let next = snapshot([storm(), storm('ep162026')]); + h = harness({ getSnapshot: async () => next }, { context: contextStore }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.setParams({ stormId: 'ep162026' }); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual( + [...contextStore.getContextStore().entities.keys()], + ['cyclone:ep162026'], + ); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual(events.at(-1), ['cleared', 'evicted']); + assert.equal(contextStore.getContextStore().entities.size, 0); + assert.equal(contextStore.getContextStore().selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().entities.size, 0); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); From 0c4da3cdb27c0eda490954d8581079809cf3ae32 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:47:32 -0800 Subject: [PATCH 014/174] Keep one world clock that every timed layer reads Core told time eight ways: Date.now() in most layers, JulianDate.now() for flights and military, Cesium's clock frozen at page load for the sky, the weather clock for observed imagery. They now read one clock, src/time/worldClock.js, exported as ./time. Live is a mode, not a timestamp: while live the clock is the wall clock, so every layer draws as before. Away from live only the layers that follow the clock move with it. The weather products follow it, as they followed the weather transport; a host opts others in with follow(id). - The weather clock takes its time from the world clock and drives its replay, stepping its frames as before. - Satellites, launches, flights and military (still 30 s and 15 s behind), transit, GNSS, Recent Imagery, street level, the HUD and the celestial ring read worldTimeFor(id). Cadences stay on the wall clock. - Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world time each frame instead of the moment the page loaded. - Weather frames and satellites declare coverage() for a host's timeline. --- package.json | 1 + scripts/package-boundaries.json | 40 ++- src/app/constructCatalog.js | 3 +- src/app/layers/gnss.js | 6 +- src/app/layers/recentImagery.js | 4 + src/app/scene.js | 8 + src/celestialRing.js | 15 +- src/hud.js | 13 +- src/layers/flights/motion.js | 5 +- src/layers/flights/motion.test.mjs | 14 +- src/layers/flights/rendering.js | 3 +- src/layers/flights/state.js | 3 + src/layers/flights/tracking.js | 3 +- src/layers/gnss/index.js | 4 +- src/layers/launches/launchPad.js | 2 +- src/layers/launches/model.js | 2 +- src/layers/launches/orbitRendering.js | 2 +- src/layers/launches/rendering.js | 4 +- src/layers/launches/state.js | 9 + src/layers/military/motion.js | 5 +- src/layers/military/state.js | 3 + src/layers/military/tracking.js | 3 +- src/layers/satellites/catalog.js | 4 +- src/layers/satellites/controls.js | 5 +- src/layers/satellites/ingestion.js | 2 +- src/layers/satellites/ingestion.test.mjs | 1 + src/layers/satellites/lifecycle.js | 26 +- src/layers/satellites/orbits.js | 6 +- src/layers/satellites/policy.js | 2 + src/layers/satellites/records.js | 2 +- src/layers/satellites/rendering.js | 6 +- src/layers/satellites/state.js | 5 + src/layers/satellites/tracking.js | 7 +- src/layers/streetLevel/index.js | 8 +- src/layers/transit/lifecycle.test.mjs | 1 + src/layers/transit/rendering.js | 12 +- src/layers/transit/state.js | 3 + src/layers/weather/clock.js | 127 ++++++++-- src/layers/weather/clock.test.mjs | 176 +++++++++++++ src/time/julian.js | 13 + src/time/viewerClock.js | 46 ++++ src/time/worldClock.js | 308 +++++++++++++++++++++++ src/time/worldClock.test.mjs | 254 +++++++++++++++++++ 43 files changed, 1091 insertions(+), 75 deletions(-) create mode 100644 src/time/julian.js create mode 100644 src/time/viewerClock.js create mode 100644 src/time/worldClock.js create mode 100644 src/time/worldClock.test.mjs diff --git a/package.json b/package.json index 7dda05144..44debccb2 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", + "./time": "./src/time/worldClock.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c8783bde9..b6ba070e2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -145,10 +145,16 @@ "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", "src/ui/styleParameters.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js" ], "external": ["vue", "pinia", "vue-i18n"] }, + "time": { + "exports": ["./time"], + "modules": ["src/time/worldClock.js"], + "external": [] + }, "viewer": { "exports": ["./application/viewer"], "modules": [ @@ -1308,7 +1314,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1363,7 +1371,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1547,7 +1557,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js" ], "external": ["cesium", "satellite.js"] }, @@ -1587,7 +1598,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium"] }, @@ -1904,7 +1916,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/time/worldClock.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -3138,7 +3151,10 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/viewerClock.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": [ "hls.js", @@ -3734,7 +3750,9 @@ "src/layers/wind/inspectionMarker.js", "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": [ "hls.js", @@ -4389,7 +4407,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -4440,7 +4459,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "pbf"] }, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 198f91dff..7ffc08fa3 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,5 +1,6 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; +import { worldClock } from '../time/worldClock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -72,7 +73,7 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = createWeatherClock({ world: worldClock }); const dispose = () => { signal.removeEventListener('abort', dispose); militaryRegistry.dispose(); diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..72071e86a 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,10 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ export function createApplicationGnssIntegrity(options) { - return createGnssIntegrityLayer(options); + return createGnssIntegrityLayer({ + worldNow: worldTimeFor('gnss-integrity'), + ...options, + }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..6a730bc7c 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; +import { worldTimeFor } from '../../time/worldClock.js'; + +const imageryTime = worldTimeFor('recent-imagery'); /** Construct imagery presentation over one explicitly supplied acquisition source. */ export function createApplicationRecentImagery({ source }) { @@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) { thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), + now: () => new Date(imageryTime()), }); } diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..50695f68c 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { worldClock, worldTimeFor } from '../time/worldClock.js'; +import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -58,6 +60,12 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer( + followWorldTime(viewer, worldTimeFor('sky'), { + subscribe: worldClock.subscribe, + requestRender: () => governorRequestRender('world-time'), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/celestialRing.js b/src/celestialRing.js index c031593d4..f470e7d18 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,9 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { worldClock, worldTimeFor } from './time/worldClock.js'; + +// The ring shows the sun and moon at the time the sky is drawn. +const skyTime = worldTimeFor('sky'); /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -445,6 +449,12 @@ export class CelestialRing { if (typeof document !== 'undefined' && document.hidden) return; governorRequestRender('celestial-ephemeris'); }, 60_000); + // A jump in the world time moves the sun and moon at once. + this._skyDate = new Date(); + this._skyTime = new Cesium.JulianDate(); + this._unsubscribeWorldTime = worldClock.subscribe(() => { + this._ephemerisDirty = true; + }); this.setEnabled(this.enabled); } @@ -801,7 +811,8 @@ export class CelestialRing { return; } - const time = Cesium.JulianDate.now(); + this._skyDate.setTime(skyTime()); + const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); const camera = this.viewer.camera; @@ -890,6 +901,8 @@ export class CelestialRing { clearInterval(this._ephemerisTimer); this._ephemerisTimer = null; } + this._unsubscribeWorldTime?.(); + this._unsubscribeWorldTime = null; if (this._removePostRender) this._removePostRender(); this._removePostRender = null; if (this._shown()) { diff --git a/src/hud.js b/src/hud.js index 80ef5e6f8..d74b05878 100644 --- a/src/hud.js +++ b/src/hud.js @@ -18,6 +18,11 @@ import * as Cesium from 'cesium'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; +import { worldTimeFor } from './time/worldClock.js'; + +// The HUD stamps its readouts with the world time; its sun is the sky's. +const hudTime = worldTimeFor('hud'); +const skyTime = worldTimeFor('sky'); import { composeLocalityTag } from './hudLocality.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { @@ -385,11 +390,11 @@ export class IntelHUD { } /** - * Format the current wall-clock time as a UTC Zulu string. + * Format the HUD's world time as a UTC Zulu string. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format. */ _formatUTC() { - const now = new Date(); + const now = new Date(hudTime()); const y = now.getUTCFullYear(); const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0'); @@ -491,7 +496,7 @@ export class IntelHUD { readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp - const now = new Date(); + const now = new Date(hudTime()); const h = String(now.getUTCHours()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0'); @@ -598,7 +603,7 @@ export class IntelHUD { * @returns {number} Estimated sun elevation in degrees (negative = below horizon). */ _estimateSunElevation(lat, lon) { - const now = new Date(); + const now = new Date(skyTime()); // Approximate local solar time by shifting UTC hours by longitude offset const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const solarNoon = 12; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index b66775f1e..a14c8c4ac 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { COURSE_HOLD_SPEED_MPS, lerpAngleDeg, @@ -156,7 +157,7 @@ export function createMotion({ const renderTime = sampledRenderTime ?? Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchRenderTime), + julianAt(flightState.now(), flightState._scratchRenderTime), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -332,7 +333,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs index dcb809d86..dd7a0ab03 100644 --- a/src/layers/flights/motion.test.mjs +++ b/src/layers/flights/motion.test.mjs @@ -12,6 +12,7 @@ function fixture() { _scratchArc: {}, _scratchOffset: new Cesium.Cartesian3(), _scratchRenderTime: new Cesium.JulianDate(), + now: Date.now, }; const motion = createMotion({ flightState, @@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in assert.ok(Cesium.JulianDate.equals(renderTime, savedTime)); }); -test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => { +test('tracked callers still sample a fresh delayed time without a fleet epoch', () => { const { flightState, motion } = fixture(); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const history = [ @@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', flightState.records.data.set('tracked', { velocity: 100, true_track: 90 }); flightState._positionHistory.set('tracked', history); let elapsed = 60; - const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { - assert.equal(result, flightState._scratchRenderTime); - return Cesium.JulianDate.addSeconds(start, elapsed, result); - }); + let reads = 0; + flightState.now = () => { + reads += 1; + return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000; + }; const out = new Cesium.Cartesian3(); close( motion._deadReckon('tracked', out), @@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', new Cesium.Cartesian3(), ), ); - assert.equal(spy.mock.callCount(), 2); + assert.equal(reads, 2); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index d4075a565..f14870590 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; @@ -863,7 +864,7 @@ export function createRendering({ flightState._lastFleetTickMs = nowMs; // Every contact in this pass shares the same delayed display instant. const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchFleetRenderTime), + julianAt(flightState.now(), flightState._scratchFleetRenderTime), -RENDER_DELAY_SEC, flightState._scratchFleetRenderTime, ); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index b297d5a9d..d87eaf880 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,6 +1,7 @@ import { FlightRecords } from './records.js'; import { createFlightFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; export function createFlightState({ source, services }) { @@ -12,6 +13,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('flights'); /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs * vertex-baked to real-world METERS in the airplane.glb axis convention, so diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dde8f4f95..c9569a914 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; @@ -382,7 +383,7 @@ export function createTracking({ // would draw the trail in front of the plane. They join the trail via _appendTrailFix // as they age past the delay. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index ba0e9ff5d..7a9087998 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), + // The end of the window shown; retries and receipt times stay on `now`. + worldNow = now, monotonicNow = () => performance.now(), windowMs: requestedWindowMs, } = {}) { @@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({ /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { - pruneGnssObservations(_observations, now(), { windowMs }); + pruneGnssObservations(_observations, worldNow(), { windowMs }); _cells = binGnssCells(_observations); render(); } diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js index f0a87ce6a..9fd438762 100644 --- a/src/layers/launches/launchPad.js +++ b/src/layers/launches/launchPad.js @@ -105,7 +105,7 @@ export function createLaunchPad({ } const nowMs = Date.now(); if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { - layerState._missionRingDate.setTime(nowMs); + layerState._missionRingDate.setTime(layerState.orbitNow()); parts.orbitRendering.updateMissionOrbitPrimitiveFrames( layerState._missionRingDate, ); diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 5697e07e8..f9d712246 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -175,7 +175,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @returns {Array} */ - function normalizeRocketLaunches(payload, now = new Date()) { + function normalizeRocketLaunches(payload, now = new Date(layerState.now())) { const launches = Array.isArray(payload) ? payload : payload?.results; if (!Array.isArray(launches)) return []; const cutoff = now.getTime() - WINDOW_DAYS * 86400000; diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index d907904eb..b9a6cab09 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -124,7 +124,7 @@ export function createOrbitRendering({ collection.show = missionOrbitPrimitiveVisible(launch.id); orbitFrameModelMatrix( satelliteTrack.gmstAtBake, - new Date(), + new Date(layerState.orbitNow()), collection.modelMatrix, ); layerState._viewer.scene.primitives.add(collection); diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 6379e78e1..52eb39961 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -394,7 +394,7 @@ export function createRendering({ orbitCurrent.latitude, orbitCurrent.altitude, ); - const liveTime = new Date(); + const liveTime = new Date(layerState.orbitNow()); const liveTelemetry = { speedMps: null }; layerState._satelliteTelemetry.set(launch.id, liveTelemetry); let liveFrameNumber = -1; @@ -405,7 +405,7 @@ export function createRendering({ layerState._viewer?.scene?.frameState?.frameNumber ?? -1; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; liveFrameNumber = frameNumber; - const nowMs = Date.now(); + const nowMs = layerState.orbitNow(); liveTime.setTime(nowMs); if (satelliteTrack) { const propagated = diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..469013da7 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -147,6 +148,14 @@ export function createState({ services }) { state._missionRingDate = new Date(0); + /** The time the launch window ends at, in epoch ms. */ + + state.now = worldTimeFor('rocket-launches'); + + /** A launched mission's satellite flies on the satellites layer's time. */ + + state.orbitNow = worldTimeFor('satellites'); + state._missionOrbitPatternRegistered = false; state._pathDistanceCache = new WeakMap(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 27489400b..a13508e54 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { lerpAngleDeg, speedRamp, @@ -67,7 +68,7 @@ export function createMotion({ // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -213,7 +214,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f1a2d691f..f27595d2b 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,6 +1,7 @@ import { MilitaryFlightRecords } from './records.js'; import { createMilitaryFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; @@ -11,6 +12,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('military'); /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: * military contacts should read as their WEIGHT CLASS, always in this layer's diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8c9723176..d90d43c35 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { t } from '../../i18n/index.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; @@ -499,7 +500,7 @@ export function createTracking({ // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join via _appendTrailFix as they age. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 097d9e3be..c5c1aaa75 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(layerState.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(layerState.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index f3327b332..bb33a0c40 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 01653611c..3db7d7471 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -103,7 +103,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(layerState.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 313d999ee..d08f8b7a8 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -32,6 +32,7 @@ function ingestionFor(catalogText) { _denseLoadToken: 0, _catalogRevision: 0, _params: { catalog: 'core', showOrbits: false }, + now: Date.now, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 642a6879f..896f2fe96 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; -import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; +import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js'; +import { worldClock } from '../../time/worldClock.js'; export function createLifecycle({ state: layerState, @@ -8,6 +9,26 @@ export function createLifecycle({ source, }) { const { clearFocusTarget } = services.focus; + // While on with a catalog, satellites can be shown at any time in reach of + // their element sets. + let releaseCoverage = null; + const coverWorldTime = () => { + releaseCoverage ??= worldClock.register({ + id: 'satellites', + coverage: () => { + if (!layerState._catalog.size) return null; + const now = worldClock.wallNow(); + return { + start: now - SATELLITE_COVERAGE_MS, + end: now + SATELLITE_COVERAGE_MS, + }; + }, + }); + }; + const uncoverWorldTime = () => { + releaseCoverage?.(); + releaseCoverage = null; + }; const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -64,6 +85,7 @@ export function createLifecycle({ enable(viewer) { layerState._enabled = true; + coverWorldTime(); holdContinuousRender('satellites'); // per-frame animator (perf wave 2) if (layerState._pointCollection) layerState._pointCollection.show = @@ -98,6 +120,7 @@ export function createLifecycle({ parts.catalog._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); layerState._enabled = false; + uncoverWorldTime(); releaseContinuousRender('satellites'); if (layerState._pointCollection) layerState._pointCollection.show = false; for (const path of layerState._orbitPaths.values()) @@ -125,6 +148,7 @@ export function createLifecycle({ parts.catalog._abortActiveUpdates(); releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; + uncoverWorldTime(); parts.tracking._clearTracking(); parts.tracking._cancelPendingTrackingRestore(); if (layerState._clickHandler) { diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 0e4461e85..37a56d106 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -133,7 +133,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -163,7 +163,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }, ) { const id = Number(noradId); @@ -280,7 +280,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(layerState.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index e7ea5456d..a6bfef8fe 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -28,6 +28,8 @@ export const ORBIT_PATH_STEPS = 180; // points per orbital path export const POSITION_UPDATE_MS = 1000; +/** How far either side of now SGP4 on current element sets stays meaningful. */ +export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000; // re-propagate every 1s (SGP4 is smooth at this rate) export const RING_ROTATION_MS = 1000; diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index d4fb4d076..9f99d6b0d 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -79,7 +79,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(state.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 08e4396bc..e722ce7c2 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -42,7 +42,7 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(layerState.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; @@ -143,7 +143,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(layerState.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -211,7 +211,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(layerState.now())); layerState._lastRingRotation = now; } } diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 50a127b54..44e004476 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -151,6 +152,10 @@ export function createState({ services }) { state._trackedFrameNowForTest = null; + /** The time satellites are propagated to, in epoch ms. */ + + state.now = worldTimeFor('satellites'); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 2b2624ff7..7353d92c8 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) { sat.satrec, layerState._trackedFrameNowForTest ? new Date(layerState._trackedFrameNowForTest()) - : new Date(), + : new Date(layerState.now()), ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; @@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) { // Comfortable tracking landing: ~726 km back for LEO (user-validated // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const initialPos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 1f7b0f7f0..e7303523e 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -8,6 +8,7 @@ import { createSelection } from './selection.js'; import { createViewerHost } from './viewerHost.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; +import { worldTimeFor } from '../../time/worldClock.js'; import { decodeParams, encodeParams } from './params.js'; import { composeUIState, @@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000; * a time. * @param {{providers: Array, services?: object}} options */ +// "Since N days" counts back from the world time. +const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID); + export function createStreetLevelLayer({ providers: definitions, services = {}, @@ -93,7 +97,7 @@ export function createStreetLevelLayer({ return Object.freeze({ services: state.services, /** Resolved against now, so "since N days" keeps up with the clock. */ - getFilter: () => resolveFilter(state.filter), + getFilter: () => resolveFilter(state.filter, streetLevelTime()), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, notify, @@ -356,7 +360,7 @@ export function createStreetLevelLayer({ const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; state.filter = filter; - const resolved = resolveFilter(filter); + const resolved = resolveFilter(filter, streetLevelTime()); for (const entry of state.providers.values()) entry.instance.setFilter(resolved); notify(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..d1c14dd64 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera _rotationRevision: -1, _cameraRevision: 0, _rotationDirty: false, + now: () => Date.now(), }; const noop = () => {}; const rendering = createRendering({ diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 2309978a5..f3868444a 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) { } function sampleIdle(entry) { if (!state._moving.has(entry)) { - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); } } @@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) { () => { entry.wakeTimer = null; if (!state._enabled || !state._vehicles.has(entry.key)) return; - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); schedulePlayback(entry); syncRenderHold(); @@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) { ) return; if (!state._enabled) return; - const now = Date.now(); + const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); syncRenderHold(); @@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) { } parts.trails.update(); - refreshRotations(now); + refreshRotations(Date.now()); const selected = state._vehicles.get(state._selectedKey); if (selected && state._visible.has(selected)) { selected.marker.rotation = Number.isFinite(selected.courseDeg) @@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) { const noSample = !entry.sample || !Number.isFinite(entry.sample.lat); if (entry.track?.count && (!state._visible.has(entry) || noSample)) { entry.sample ||= {}; - updatePlayback(entry, now, performance.now()); + updatePlayback(entry, state.now(), performance.now()); } sphere.center = state._visible.has(entry) || !entry.sample @@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) { } function resumePlayback() { if (!state._enabled) return; - const now = Date.now(), + const now = state.now(), mono = performance.now(); const entries = new Set(state._visible); const selected = state._vehicles.get(state._selectedKey); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..5bb152015 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** * One Transit layer's mutable scene state. Every field lives on the instance, @@ -14,6 +15,8 @@ export function createState({ services }) { const state = {}; state._historyBudget = createHistoryBudget(); + /** The time vehicles play back behind by their display delay, in epoch ms. */ + state.now = worldTimeFor('transit'); state.DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index a758dddd2..9e2d5a96f 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -1,19 +1,35 @@ +import { createWorldClock } from '../../time/worldClock.js'; + export const RADAR_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; -/** Transient observed time. Products must honor the signal before installing a frame. */ +/** + * Transient observed time. Products must honor the signal before installing a + * frame. Its time is the world clock's: latest while the world is live, + * history at the world's time otherwise. Replay steps the observed frames and + * moves the world clock as it goes, as the world clock's driver. + */ export function createWeatherClock({ now = Date.now, setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout, + world = createWorldClock({ now }), } = {}) { const registry = new Map(); const listeners = new Set(); let mode = 'latest'; let target = null; let playing = false; + // The world time last applied; while this clock moves the world, the + // transition its move started. + let seen = 'latest'; + let moving = false; + let moved = null; + const releases = new Map(); + // Attached at the end, once the API exists. + let driver = null; let generation = 0; let timer = null; let pending = null; @@ -65,11 +81,32 @@ export function createWeatherClock({ timer = null; }; const canPlay = () => getTimeline().length >= 2; + const stopPlaying = () => { + if (!playing) return; + playing = false; + driver?.driven(false); + }; + // Move the world clock; the change comes back through followWorld. Returns + // the transition the move started, if it started one. + const move = (fn) => { + const outer = [moving, moved]; + moving = true; + moved = null; + try { + fn(); + return moved; + } finally { + [moving, moved] = outer; + } + }; + const selections = () => + [...registry.values()].map(({ product }) => selectedFor(product)).join(); + let applied = null; function schedule() { cancelTimer(); if (!playing || destroyed || pending) return; if (!canPlay()) { - playing = false; + stopPlaying(); notify(); return; } @@ -92,7 +129,8 @@ export function createWeatherClock({ if (destroyed) return Promise.resolve(false); cancelTimer(); const owner = ++generation; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); + applied = selections(); for (const entry of registry.values()) entry.controller?.abort(); // Start all products together; a rejection must not skip the settle barrier. const tasks = [...registry.values()].map((entry) => { @@ -107,12 +145,13 @@ export function createWeatherClock({ const result = Promise.allSettled(tasks).then(() => { if (destroyed || owner !== generation) return false; pending = null; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); notify(); schedule(); return true; }); pending = result; + if (moving) moved ??= result; notify(); return result; } @@ -123,11 +162,14 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); + releases.set(product.id, world.hold(product.id)); void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; entry.controller?.abort(); registry.delete(product.id); + releases.get(product.id)?.(); + releases.delete(product.id); void api.refresh(); }; }, @@ -140,9 +182,11 @@ export function createWeatherClock({ const normalized = new Date(time).toISOString(); if (mode === 'history' && target === normalized) return pending ?? Promise.resolve(true); - mode = 'history'; - target = normalized; - return transition(); + return ( + move(() => world.seek(Date.parse(normalized))) ?? + pending ?? + Promise.resolve(true) + ); }, step(direction) { if (destroyed || ![-1, 1].includes(direction)) @@ -159,10 +203,9 @@ export function createWeatherClock({ }, latest() { if (destroyed) return Promise.resolve(false); - mode = 'latest'; - target = null; - playing = false; - return transition(); + stopPlaying(); + if (mode === 'latest') return transition(); + return move(() => world.goLive()) ?? pending ?? Promise.resolve(true); }, play() { if (destroyed || playing || !canPlay()) @@ -170,18 +213,28 @@ export function createWeatherClock({ playing = true; if (mode === 'latest') { const timeline = getTimeline(); - return api.setTarget( - timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], + const start = + timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0]; + // Playing and at the first frame in one move of the world. + return ( + move(() => driver.driven(true, Date.parse(start))) ?? + pending ?? + Promise.resolve(true) ); } + driver.driven(true); notify(); schedule(); return pending ?? Promise.resolve(true); }, pause() { cancelTimer(); - if (!playing) return; - playing = false; + if (!playing) { + // The world may be playing on its own; a pause or step here holds it. + if (world.isPlaying()) world.pause(); + return; + } + stopPlaying(); notify(); }, togglePlay() { @@ -190,7 +243,7 @@ export function createWeatherClock({ refresh() { if (destroyed) return Promise.resolve(false); if (!canPlay()) { - playing = false; + stopPlaying(); cancelTimer(); } if (mode === 'history') return transition(); @@ -204,15 +257,55 @@ export function createWeatherClock({ }, destroy() { if (destroyed) return; + stopPlaying(); destroyed = true; ++generation; - playing = false; cancelTimer(); + unfollowWorld(); + uncover(); + // The last weather clock leaves the world live, as a new one expects. + if (driver.detach() === 0) world.goLive(); + for (const release of releases.values()) release(); + releases.clear(); for (const entry of registry.values()) entry.controller?.abort(); registry.clear(); listeners.clear(); pending = null; }, }; + // Follow the world clock. A move of this clock's own always applies; one + // from elsewhere (a host's seek, continuous play) applies only when it + // changes the mode or a product's frame. + function followWorld(state) { + if (destroyed) return; + const nextMode = state.live ? 'latest' : 'history'; + const nextTarget = state.live + ? null + : new Date(Math.floor(state.timeMs)).toISOString(); + const key = nextTarget ?? 'latest'; + if (key === seen) return; + seen = key; + const modeChanged = nextMode !== mode; + mode = nextMode; + target = nextTarget; + if (moving || modeChanged || selections() !== applied) void transition(); + else notify(); + } + const unfollowWorld = world.subscribe(followWorld); + // The observed frames this clock can show, for a host's timeline. + const uncover = world.register({ + id: 'weather', + coverage: () => { + const marks = getTimeline().map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + driver = world.attachDriver({ + play: () => (canPlay() ? void api.play() : false), + pause: () => api.pause(), + }); + followWorld(world.getState()); return api; } diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 24bd124b8..9561c10c7 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -7,6 +7,7 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, } from './clock.js'; +import { createWorldClock } from '../../time/worldClock.js'; const time = (minutes) => new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); @@ -37,7 +38,15 @@ function harness(t) { let at = Date.parse(time(20)); const timers = new Map(); let id = 0; + let mono = 0; + const world = createWorldClock({ + now: () => at, + monotonicNow: () => mono, + setInterval: () => 0, + clearInterval() {}, + }); const clock = createWeatherClock({ + world, now: () => at, setTimeout(fn, delay) { timers.set(++id, { fn, due: at + delay }); @@ -50,9 +59,11 @@ function harness(t) { t.after(() => clock.destroy()); return { clock, + world, timers, tick(ms) { at += ms; + mono += ms; for (const [id, timer] of [...timers]) { if (timer.due > at) continue; timers.delete(id); @@ -275,3 +286,168 @@ test('registration joins history; unregister and destroy abort work and release assert.deepEqual(clock.getTimeline(), []); assert.equal(await clock.setTarget(time(5)), false); }); + +test('the weather time is the world time, both ways', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + assert.equal(world.follows('radar'), true); + await clock.setTarget(time(10)); + assert.equal(world.isLive(), false); + assert.equal(world.now(), Date.parse(time(10))); + world.seek(Date.parse(time(3))); + await flush(); + assert.equal(clock.getState().target, time(3)); + assert.equal(radar.shown, time(0)); + world.goLive(); + await flush(); + assert.equal(clock.getState().mode, 'latest'); + assert.equal(radar.shown, time(20)); +}); + +test('world play and pause drive the weather replay', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + world.play(); + assert.equal(clock.getState().playing, true); + assert.equal(world.isPlaying(), true); + tick(2000); + await flush(); + assert.equal(radar.shown, time(10)); + assert.equal(world.now(), Date.parse(time(10))); + world.pause(); + assert.equal(clock.getState().playing, false); + clock.play(); + assert.equal(world.isPlaying(), true); + clock.pause(); + assert.equal(world.isPlaying(), false); + clock.play(); + world.goLive(); + await flush(); + assert.equal(clock.getState().playing, false); + assert.equal(clock.getState().mode, 'latest'); +}); + +test('a moving world re-applies frames only when a product changes frame', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + const applied = radar.calls.length; + world.seek(Date.parse(time(0)) + 60_000); + await flush(); + assert.equal(radar.calls.length, applied); + assert.equal( + clock.getState().target, + new Date(Date.parse(time(1))).toISOString(), + ); + world.seek(Date.parse(time(11))); + await flush(); + assert.equal(radar.calls.length, applied + 1); + assert.equal(radar.shown, time(10)); +}); + +test('a world without frames to step plays on its own', async (t) => { + const { clock, world, tick } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(clock.getState().playing, false); + tick(1000); + assert.equal(world.now(), Date.parse(time(0)) + 1000); +}); + +function worldAt(at) { + return createWorldClock({ + now: () => at, + monotonicNow: () => 0, + setInterval: () => 0, + clearInterval() {}, + }); +} + +test('weather clocks sharing a world hand play over, and the last leaves it live', async () => { + const world = worldAt(Date.parse(time(20))); + const a = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const b = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const radarA = product('radar', [0, 10, 20]); + const radarB = product('radar', [0, 10, 20]); + a.register(radarA); + b.register(radarB); + await a.play(); + assert.equal(world.isPlaying(), true); + world.pause(); + assert.equal(a.getState().playing, false); + assert.equal(world.isPlaying(), false); + await a.play(); + world.goLive(); + assert.equal(a.getState().playing, false); + b.destroy(); + assert.equal(world.follows('radar'), true); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(a.getState().playing, true); + a.destroy(); + assert.equal(world.follows('radar'), false); + assert.equal(world.isLive(), true); + assert.equal(world.isPlaying(), false); +}); + +test('pausing from inside the first playing notice stops the world too', async (t) => { + const { clock, world } = harness(t); + clock.register(product('radar', [0, 10, 20])); + const off = clock.subscribe((state) => { + if (state.playing) { + off(); + clock.pause(); + } + }); + await clock.play(); + assert.equal(clock.getState().playing, false); + assert.equal(world.isPlaying(), false); +}); + +test('a step holds a world that was playing on its own', async (t) => { + const { clock, world } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + clock.register(product('radar', [0, 10, 20])); + await clock.step(1); + assert.equal(world.isPlaying(), false); + assert.equal(world.now(), Date.parse(time(10))); +}); + +test('a target superseded from a listener resolves false', async (t) => { + const { clock } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + await flush(); + let redirected = false; + clock.subscribe((state) => { + if (state.target === time(0) && !redirected) { + redirected = true; + void clock.setTarget(time(10)); + } + }); + assert.equal(await clock.setTarget(time(0)), false); + assert.equal(radar.shown, time(10)); +}); + +test('the observed frames are the world clock coverage, while registered', (t) => { + const { clock, world } = harness(t); + assert.equal(world.coverage(), null); + const radar = product('radar', [0, 10]); + const unregister = clock.register(radar); + clock.register(product('lightning', [5])); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(0)), + end: Date.parse(time(10)), + marks: [0, 5, 10].map((m) => Date.parse(time(m))), + }); + unregister(); + clock.destroy(); + assert.equal(world.coverage(), null); +}); diff --git a/src/time/julian.js b/src/time/julian.js new file mode 100644 index 000000000..b63a53621 --- /dev/null +++ b/src/time/julian.js @@ -0,0 +1,13 @@ +import * as Cesium from 'cesium'; + +const scratch = new Date(); + +/** + * Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time. + * @param {number} ms + * @param {Cesium.JulianDate} [result] + */ +export function julianAt(ms, result) { + scratch.setTime(ms); + return Cesium.JulianDate.fromDate(scratch, result); +} diff --git a/src/time/viewerClock.js b/src/time/viewerClock.js new file mode 100644 index 000000000..e4c456d42 --- /dev/null +++ b/src/time/viewerClock.js @@ -0,0 +1,46 @@ +import * as Cesium from 'cesium'; + +/** + * Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and + * the lighting on the tiles all read `frameState.time`, which Cesium takes + * from its own clock. That clock never animates in GEV, so before this the + * sky stayed at the moment the page loaded. + * + * Each frame's time is set in `preUpdate`, before the scene stamps the frame + * with it, so the sky is drawn at the same moment the layers read that frame. + * The clock's `currentTime` follows too, for anything that samples it. + * @param {Cesium.Viewer} viewer + * @param {() => number} readTime Epoch ms the sky is drawn at. + * @param {{subscribe?: Function, requestRender?: () => void}} [options] + * A world clock to hear moves from, so a resting scene repaints when the + * world time jumps. + * @returns {() => void} Stop following. + */ +export function followWorldTime( + viewer, + readTime, + { subscribe, requestRender = () => viewer.scene.requestRender() } = {}, +) { + const date = new Date(); + const removePreUpdate = viewer.scene.preUpdate.addEventListener( + (_scene, time) => { + date.setTime(readTime()); + const clock = viewer.clock; + Cesium.JulianDate.fromDate(date, clock.currentTime); + // The frame's own time: Cesium stamps the frame with this object next. + if (time) Cesium.JulianDate.clone(clock.currentTime, time); + }, + ); + let last = readTime(); + const unsubscribe = + subscribe?.(() => { + const next = readTime(); + if (next === last) return; + last = next; + requestRender(); + }) ?? (() => {}); + return () => { + removePreUpdate(); + unsubscribe(); + }; +} diff --git a/src/time/worldClock.js b/src/time/worldClock.js new file mode 100644 index 000000000..446318a43 --- /dev/null +++ b/src/time/worldClock.js @@ -0,0 +1,308 @@ +/** + * The world's one time, in epoch ms. Every layer that draws something at a + * moment reads it here instead of calling `Date.now()` itself, so layers can + * never disagree about when the world is. + * + * Live is a mode, not a timestamp: while live the clock is the wall clock and + * every layer behaves as it would reading `Date.now()`. Away from live the + * clock holds a cursor, fixed or playing. Only the layers that follow the clock + * (`follow(id)`) move with the cursor; the rest keep reading the live edge + * through `timeFor(id)`, so a host opts layers in one at a time. + * + * Playback is continuous (`cursor + elapsed × rate`) unless a driver takes + * it: weather replay steps its observed frames itself and moves the clock as + * it goes, so play and pause hand over to it. Several drivers may be attached; + * play goes to the newest that can play, pause to the one playing. + */ + +/** The span a JS Date can hold, either side of 1970. */ +const MAX_DATE_MS = 8.64e15; + +/** Whether `ms` is a time the world clock can hold. */ +export const isWorldTime = (ms) => + Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; + +/** How often subscribers hear of a continuously playing cursor. */ +export const WORLD_CLOCK_TICK_MS = 250; + +/** + * @param {{now?: () => number, monotonicNow?: () => number, + * setInterval?: typeof globalThis.setInterval, + * clearInterval?: typeof globalThis.clearInterval}} [options] + */ +export function createWorldClock({ + // Read at each call, so a stand-in for Date.now (a test's, a harness's) + // reaches every layer as it did when they called Date.now themselves. + now = () => Date.now(), + monotonicNow = () => globalThis.performance.now(), + setInterval = globalThis.setInterval, + clearInterval = globalThis.clearInterval, +} = {}) { + const listeners = new Set(); + // Layers a host follows, and layers a part of the app holds following. + const following = new Set(); + const holds = new Map(); + const readers = new Map(); + const sources = new Set(); + const drivers = []; + let live = true; + // The cursor while fixed; while playing continuously, the cursor at `anchorMono`. + let cursorMs = null; + let anchorMono = 0; + let rate = 1; + let playing = false; + // The driver handle stepping the clock, or null. + let active = null; + let ticker = null; + let notifying = 0; + + const continuous = () => playing && !active; + const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0; + const followedIds = () => [ + ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]), + ]; + const time = () => { + if (live) return now(); + if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate; + return cursorMs; + }; + const getState = () => + Object.freeze({ + live, + timeMs: time(), + playing, + rate, + following: followedIds(), + }); + // Each listener hears the state as it is when called. A change made from + // inside a listener notifies everyone afresh, and the outer round stops, so + // nobody is left holding a state that has since changed. + const notify = () => { + const round = ++notifying; + for (const listener of [...listeners]) { + if (round !== notifying) return; + if (!listeners.has(listener)) continue; + try { + listener(getState()); + } catch (error) { + console.error('World clock listener failed', error); + } + } + }; + const syncTicker = () => { + if (continuous() && listeners.size) { + ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS); + } else if (ticker !== null) { + clearInterval(ticker); + ticker = null; + } + }; + const changed = () => { + syncTicker(); + notify(); + }; + // Freeze a continuously playing cursor where it is. + const settle = () => { + if (continuous()) cursorMs = time(); + anchorMono = monotonicNow(); + }; + + const api = { + /** The live edge: the wall clock, whatever the cursor does. */ + wallNow: () => now(), + /** The world time: the wall clock while live, else the cursor. */ + now: time, + isLive: () => live, + isPlaying: () => playing, + getRate: () => rate, + + /** Hold the world at `ms`; a playing clock plays on from there. */ + seek(ms) { + if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms'); + if (!live && cursorMs === ms && !continuous()) return; + live = false; + cursorMs = ms; + anchorMono = monotonicNow(); + changed(); + }, + /** Back to the wall clock; playback stops. */ + goLive() { + if (live && !playing) return; + const stepping = active; + live = true; + cursorMs = null; + playing = false; + active = null; + // Its driven(false) finds nothing left to stop. + stepping?.driver.pause(); + changed(); + }, + /** Play from the cursor (from now, when live). */ + play({ rate: nextRate } = {}) { + if (nextRate !== undefined) api.setRate(nextRate); + if (playing) return; + for (let index = drivers.length - 1; index >= 0; index -= 1) { + if (drivers[index].driver.play() !== false) return; + } + if (live) { + live = false; + cursorMs = now(); + } + playing = true; + anchorMono = monotonicNow(); + changed(); + }, + pause() { + if (!playing) return; + if (active) { + active.driver.pause(); + return; + } + settle(); + playing = false; + changed(); + }, + togglePlay() { + return playing ? api.pause() : api.play(); + }, + /** Simulated ms per real ms for continuous playback. */ + setRate(nextRate) { + if (!Number.isFinite(nextRate) || nextRate <= 0) + throw new RangeError('World clock rate must be positive'); + if (nextRate === rate) return; + settle(); + rate = nextRate; + changed(); + }, + + /** + * Offer a stepping player. The driver moves the clock with `seek()` and + * reports through its handle's `driven(playing, atMs?)`; its `play()` + * returns false when it cannot play, and the clock tries an older driver + * or plays continuously instead. + * @param {{play: () => (boolean|void), pause: () => void}} driver + * @returns {{driven: (playing: boolean, atMs?: number) => void, + * detach: () => number}} `detach()` returns how many drivers remain. + */ + attachDriver(driver) { + const handle = { + driver, + /** This driver started stepping (at `atMs`, if given) or stopped. */ + driven(isPlaying, atMs) { + if (!drivers.includes(handle)) return; + if (!isPlaying) { + if (active !== handle) return; + active = null; + playing = false; + changed(); + return; + } + // A continuous run hands over where it is; another driver stops. + settle(); + const previous = active; + active = handle; + playing = true; + live = false; + if (Number.isFinite(atMs)) cursorMs = atMs; + else cursorMs ??= now(); + anchorMono = monotonicNow(); + if (previous && previous !== handle) previous.driver.pause(); + changed(); + }, + detach() { + const index = drivers.indexOf(handle); + if (index < 0) return drivers.length; + handle.driven(false); + drivers.splice(index, 1); + return drivers.length; + }, + }; + drivers.push(handle); + return handle; + }, + + /** Let layer `id` move with the cursor instead of reading the live edge. */ + follow(id, on = true) { + if (on === following.has(id)) return; + if (on) following.add(id); + else following.delete(id); + changed(); + }, + /** + * Keep layer `id` following until the returned release is called, however + * many parts of the app hold it. + */ + hold(id) { + const was = isFollowed(id); + holds.set(id, (holds.get(id) ?? 0) + 1); + if (!was) changed(); + let held = true; + return () => { + if (!held) return; + held = false; + const count = holds.get(id) - 1; + if (count > 0) holds.set(id, count); + else holds.delete(id); + if (!isFollowed(id)) changed(); + }; + }, + follows: isFollowed, + /** + * The time layer `id` draws at: the cursor when it follows the clock, + * otherwise the live edge. + * @returns {() => number} + */ + timeFor(id) { + let reader = readers.get(id); + if (!reader) { + reader = () => (isFollowed(id) ? time() : now()); + readers.set(id, reader); + } + return reader; + }, + + /** + * A layer's span of time it can show, for a host's timeline; null while + * it has none (off, or nothing loaded). Returns the unregister. + * @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source + */ + register(source) { + // Each registration is its own, even under a shared id. + const entry = { source }; + sources.add(entry); + return () => sources.delete(entry); + }, + /** The union of the registered sources' coverage, or null with none. */ + coverage() { + let start = Infinity; + let end = -Infinity; + const marks = new Set(); + for (const { source } of sources) { + const span = source.coverage(); + if (!span || !(span.end >= span.start)) continue; + start = Math.min(start, span.start); + end = Math.max(end, span.end); + for (const mark of span.marks ?? []) marks.add(mark); + } + if (start > end) return null; + return { start, end, marks: [...marks].sort((a, b) => a - b) }; + }, + + getState, + subscribe(listener) { + listeners.add(listener); + syncTicker(); + return () => { + listeners.delete(listener); + syncTicker(); + }; + }, + }; + return api; +} + +/** The page's world clock. Layers, the sky and a host all read this one. */ +export const worldClock = createWorldClock(); + +/** The time layer `id` draws at on the page's clock. */ +export const worldTimeFor = (id) => worldClock.timeFor(id); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs new file mode 100644 index 000000000..a954f60fd --- /dev/null +++ b/src/time/worldClock.test.mjs @@ -0,0 +1,254 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js'; + +function fakeTime() { + const t = { + wall: Date.UTC(2026, 10, 1, 12), + mono: 1_000, + timers: new Map(), + nextId: 1, + advance(ms) { + t.wall += ms; + t.mono += ms; + }, + options: { + now: () => t.wall, + monotonicNow: () => t.mono, + setInterval: (fn, ms) => { + const id = t.nextId++; + t.timers.set(id, { fn, ms }); + return id; + }, + clearInterval: (id) => t.timers.delete(id), + }, + }; + return t; +} + +test('live is the wall clock for every layer', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + assert.equal(clock.isLive(), true); + assert.equal(clock.now(), t.wall); + t.advance(5_000); + assert.equal(clock.now(), t.wall); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('flights'); + assert.equal(clock.timeFor('flights')(), t.wall); +}); + +test('away from live only following layers move with the cursor', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const satellites = clock.timeFor('satellites'); + const flights = clock.timeFor('flights'); + clock.follow('satellites'); + const past = t.wall - 3_600_000; + clock.seek(past); + assert.equal(clock.isLive(), false); + assert.equal(clock.now(), past); + assert.equal(satellites(), past); + assert.equal(flights(), t.wall); + assert.equal(clock.timeFor('satellites'), satellites); + clock.goLive(); + assert.equal(satellites(), t.wall); +}); + +test('continuous play advances the cursor at the rate and pause holds it', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const start = t.wall - 60_000; + clock.seek(start); + clock.play({ rate: 60 }); + t.advance(1_000); + assert.equal(clock.now(), start + 60_000); + clock.setRate(120); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + clock.pause(); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + assert.equal(clock.isPlaying(), false); +}); + +test('play from live starts at now, and goLive returns to the wall clock', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const from = t.wall; + clock.play({ rate: 2 }); + assert.equal(clock.isLive(), false); + t.advance(1_000); + assert.equal(clock.now(), from + 2_000); + clock.goLive(); + assert.equal(clock.isPlaying(), false); + assert.equal(clock.now(), t.wall); +}); + +test('subscribers hear changes, and ticks only while playing continuously', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const states = []; + const off = clock.subscribe((state) => states.push(state)); + clock.seek(t.wall - 1_000); + assert.equal(states.length, 1); + assert.equal(states[0].live, false); + assert.equal(t.timers.size, 0); + clock.play(); + assert.equal(t.timers.size, 1); + assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS); + clock.pause(); + assert.equal(t.timers.size, 0); + clock.play(); + off(); + assert.equal(t.timers.size, 0); +}); + +test('a driver takes play and pause, and moves the clock with seek', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const handle = clock.attachDriver({ + play() { + calls.push('play'); + handle.driven(true, t.wall - 10_000); + }, + pause() { + calls.push('pause'); + handle.driven(false); + }, + }); + clock.play(); + assert.deepEqual(calls, ['play']); + assert.equal(clock.isPlaying(), true); + assert.equal(clock.now(), t.wall - 10_000); + const at = clock.now(); + t.advance(5_000); + // Stepped, not continuous: the cursor waits for the driver. + assert.equal(clock.now(), at); + clock.pause(); + assert.deepEqual(calls, ['play', 'pause']); + assert.equal(clock.isPlaying(), false); + clock.play(); + clock.goLive(); + assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']); + assert.equal(clock.isLive(), true); + assert.equal(clock.isPlaying(), false); + assert.equal(handle.detach(), 0); + clock.play(); + assert.equal(calls.length, 4); + t.advance(1_000); + assert.equal(clock.now(), t.wall); +}); + +test('play goes to the newest driver that can play, pause to the one playing', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const driver = (name, canPlay) => { + const handle = clock.attachDriver({ + play() { + if (!canPlay()) return false; + calls.push(`${name} play`); + handle.driven(true); + }, + pause() { + calls.push(`${name} pause`); + handle.driven(false); + }, + }); + return handle; + }; + let bCanPlay = false; + const a = driver('a', () => true); + const b = driver('b', () => bCanPlay); + clock.seek(t.wall - 1_000); + clock.play(); + assert.deepEqual(calls, ['a play']); + // b starting takes over from a. + bCanPlay = true; + b.driven(true); + assert.deepEqual(calls, ['a play', 'a pause']); + clock.pause(); + assert.deepEqual(calls, ['a play', 'a pause', 'b pause']); + assert.equal(clock.isPlaying(), false); + // A detached driver stops, and the other one plays next. + clock.play(); + assert.equal(b.detach(), 1); + assert.equal(clock.isPlaying(), false); + clock.play(); + assert.equal(calls.at(-1), 'a play'); + assert.equal(a.detach(), 0); + assert.equal(clock.isPlaying(), false); +}); + +test('a listener that changes the clock leaves nobody with a stale state', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const seen = []; + clock.subscribe((state) => { + if (state.timeMs === 0) clock.seek(1_000); + }); + clock.subscribe((state) => seen.push(state.timeMs)); + clock.seek(0); + assert.deepEqual(seen, [1_000]); +}); + +test('holds keep a layer following until every holder releases it', () => { + const clock = createWorldClock(fakeTime().options); + const first = clock.hold('radar'); + const second = clock.hold('radar'); + first(); + first(); + assert.equal(clock.follows('radar'), true); + second(); + assert.equal(clock.follows('radar'), false); + clock.follow('radar'); + const third = clock.hold('radar'); + third(); + assert.equal(clock.follows('radar'), true); + assert.deepEqual(clock.getState().following, ['radar']); +}); + +test('a driver that cannot play leaves continuous playback', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + clock.attachDriver({ play: () => false, pause() {} }); + clock.seek(t.wall - 10_000); + clock.play(); + assert.equal(clock.isPlaying(), true); + t.advance(1_000); + // Wall and cursor moved the same second at rate 1. + assert.equal(clock.now(), t.wall - 10_000); +}); + +test('coverage is the union of the registered sources', () => { + const clock = createWorldClock(fakeTime().options); + assert.equal(clock.coverage(), null); + const off = clock.register({ + id: 'radar', + coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }), + }); + clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) }); + clock.register({ id: 'empty', coverage: () => null }); + assert.deepEqual(clock.coverage(), { + start: 50, + end: 200, + marks: [100, 150, 200], + }); + off(); + assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] }); +}); + +test('seek refuses anything but epoch ms and rate must be positive', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(Number.NaN), TypeError); + assert.throws(() => clock.setRate(0), RangeError); +}); + +test('seek refuses a time no Date can hold', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(8.8e15), TypeError); + clock.seek(-8.64e15); + assert.equal(clock.now(), -8.64e15); +}); From 32ec6ed436498d7d0f0f60c15c93fe30aaeba118 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:50:09 -0800 Subject: [PATCH 015/174] Share links carry the world time when made away from live Field 28, worldTime, holds the world time of a link made away from live: zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the link's creation (a forecast moment). Opening such a link pauses the world clock at that time; a link without it leaves the clock alone, so a page opens live and Back or Forward does not stop a replay. Times no JS Date can hold are dropped. --- docs/SHARE-LINK-FORMAT.md | 5 +++++ scripts/package-boundaries.json | 9 ++++++--- src/layers/weather/index.js | 3 ++- src/share/viewCodec.js | 16 ++++++++++++++-- src/share/viewCodec.test.mjs | 21 +++++++++++++++++++++ src/sharelink.js | 13 +++++++++++-- src/sharelink.navigation.test.mjs | 25 +++++++++++++++++++++++++ src/ui/shareRestoration.js | 12 ++++++++++++ 8 files changed, 96 insertions(+), 8 deletions(-) diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..bf69653f9 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -132,6 +133,10 @@ style also extends the pinned style-defaults table. `hudVariant` code 1 (`operator`) is retired: the app no longer offers that layout and restores it as `tactical`. The code stays reserved. +Field 28 is the world time the link was made at, written only when it was away +from live. A link without it opens live. Unlike field 8 it may lie ahead of the +link's creation (a forecast moment), and it opens paused at that time. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b6ba070e2..00f369e40 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1105,7 +1105,8 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -3890,7 +3891,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": [] }, @@ -4580,7 +4582,8 @@ "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", "src/i18n/marker.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/time/worldClock.js" ], "external": [] }, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index e318e3087..4ccfdef6e 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -52,7 +52,8 @@ const utc = (value) => : t('Unavailable'); /** A per-application observation layer, using the existing layer lifecycle and - * row controls. History is transient: shared links always open latest imagery. */ + * row controls. Its time is the world clock's, so a link made away from live + * opens its imagery at that time; one made live opens the latest. */ export function createWeatherLayer({ feed, clock, diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index b72f79d34..5b54d8f2d 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -31,6 +31,7 @@ import { } from '../data/detectionPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { VIEW_STYLES } from '../view/index.js'; +import { isWorldTime } from '../time/worldClock.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { GLOBAL_POST_DEFAULTS, @@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([ { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, + { number: 28, key: 'worldTime', type: 'epoch', default: null }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) { : null; state.panelState = normalizePanelState(source.panelState); state.sharedAtMs = epochMsOr(source.sharedAtMs); + state.worldTime = isWorldTime(source.worldTime) + ? Math.floor(source.worldTime / 1000) * 1000 + : null; let view = normalizeView(source.view); if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (view.kind === 'thing') { @@ -948,8 +953,9 @@ export function encodeShareView(input) { writer.sintField(field.number, signedAngle(state[field.key])); break; case 'sharedAtMs': - if (state.sharedAtMs !== null) - writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + case 'worldTime': + if (state[field.key] !== null) + writer.sintField(field.number, epochSeconds(state[field.key])); break; case 'flags': { let bits = 0; @@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) { raw.sharedAtMs = createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; } + // The world time the link was made at, when it was away from live. It + // may lie ahead of the link's creation (a forecast) or of now. + if (get('worldTime')) { + const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000; + raw.worldTime = isWorldTime(ms) ? ms : null; + } if (get('layerState')) raw.layerState = decodeLayers( diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 6cc6fd9b1..f9fd81d38 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => { })), }, sharedAtMs: NOW_MS, + worldTime: NOW_MS - 6 * 3_600_000, }; // A thing view derives its camera (roll 0), so the camera fields are // checked on the same state as a place view. @@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => { [25, 'scopeFeatherPct', 100], [26, 'scopeTerminusPct', null], [27, 'styleParams', BY_STYLE], + [28, 'worldTime', null], ], ); assert.deepEqual( @@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => { { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, ]); }); + +test('the world time is written only away from live, and may lie ahead', () => { + const at = (worldTime) => + decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), { + nowMs: NOW_MS, + }).state.worldTime; + assert.equal(at(null), null); + assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000); + // A forecast moment ahead of the link's creation is kept. + assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000); + // Before the 2026 epoch too: the field is zigzag. + assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1)); + // Past what a Date can hold: dropped, so the link opens live. + assert.equal(at(8.8e15), null); + assert.ok( + encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length > + encodeShareView({ sharedAtMs: NOW_MS }).length, + ); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 4a0291a84..fcef04ea5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { worldClock as pageWorldClock } from './time/worldClock.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, @@ -68,9 +69,11 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, + worldClock = pageWorldClock, } = {}, ) { this.viewer = viewer; + this._worldClock = worldClock; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -163,8 +166,9 @@ export class ShareLinkManager { /** * The restore state for a share code, in the long form's parsed shape plus - * `shareView` (what the view follows, with its offset and last-known fix), - * or null when the code is refused. + * `shareView` (what the view follows, with its offset and last-known fix) + * and `worldTime` (null when the link was made live), or null when the + * code is refused. */ _parseShareCode(code) { const result = decodeShareView(code); @@ -174,6 +178,7 @@ export class ShareLinkManager { } const state = toLegacyShareState(result.state); state.shareView = result.state.view; + state.worldTime = result.state.worldTime; return state; } @@ -592,6 +597,10 @@ export class ShareLinkManager { // that state in the share format. const state = fromLegacyShareState(this._parseShareHash(params.toString())); state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + // Away from live, the link opens at the same world time. + state.worldTime = this._worldClock.isLive() + ? null + : Math.floor(this._worldClock.now() / 1000) * 1000; let subject = null; try { subject = this._followSubjectProvider?.({ nowMs }) ?? null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e4e70b629..e486ba58d 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); coordinator.destroy(); }); + +test('a link made away from live carries its world time to the page that opens it', (t) => { + installTab(t, SHARE_A); + const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42); + const sharer = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => false, now: () => worldTime + 400 }, + }); + const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) }); + sharer.destroy(); + const code = new URL(link).pathname.split('/').at(-1); + const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code }); + assert.equal(opener.parseInitialHash().worldTime, worldTime); + opener.destroy(); + + const live = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => true, now: () => worldTime }, + }); + const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1); + live.destroy(); + const liveOpener = new ShareLinkManager(fakeViewer(), { + initialShareCode: liveCode, + }); + assert.equal(liveOpener.parseInitialHash().worldTime, null); + liveOpener.destroy(); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 12f09c18b..9fc4c6f45 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +import { isWorldTime, worldClock } from '../time/worldClock.js'; + +// A link made away from live opens at its world time, paused. One without a +// world time leaves the clock as it is: a page opens live, and Back or Forward +// through long-form hashes (which never carry one) does not stop a replay. +function applySharedWorldTime(state) { + if (!isWorldTime(state?.worldTime)) return; + worldClock.pause(); + worldClock.seek(state.worldTime); +} /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -146,6 +156,7 @@ export class ShareRestoration { : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); if (savedState) { this._hasShareState = true; + applySharedWorldTime(savedState); // Reserve camera authority now; the delayed mesh-friendly flight may // run only if no newer user, voice, or tracking navigation has won. this._initialShareNavigationGeneration = @@ -301,6 +312,7 @@ export class ShareRestoration { layers: [], }); this._initialShareState = state; + applySharedWorldTime(state); this._initialShareSelectionSuperseded = false; this._hasShareState = true; this._navigatedShareRestoreActive = true; From 434fc9602d1cbd06238391754687aa35673fba70 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:55:33 -0800 Subject: [PATCH 016/174] Show a basic timeline while the world time is away from live A bar above the command dock, like a video player's: play and pause on the left, the world time's date and time in UTC beside it, and Live to return. It stays hidden while live, so Core looks as before until weather history is scrubbed or replayed, a host moves the clock, or a link made away from live is opened. Adds the pause and play_arrow glyphs and the strings in every catalog. --- index.html | 2 +- scripts/package-boundaries.json | 5 +- src/i18n/locales/ar.json | 5 +- src/i18n/locales/de.json | 5 +- src/i18n/locales/es.json | 5 +- src/i18n/locales/fr.json | 5 +- src/i18n/locales/it.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/pt.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/uk.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/i18n/locales/zh-Hant.json | 5 +- src/i18n/same-as-english.json | 1 + src/ui/panelLayoutController.js | 2 + src/ui/stores/worldTime.js | 29 ++++++++++ src/ui/styles/world-timeline.css | 62 ++++++++++++++++++++++ src/ui/views/SceneChromeView.vue | 2 + src/ui/views/panels/WorldTimeline.test.mjs | 45 ++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 34 ++++++++++++ style.css | 1 + 21 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 src/ui/stores/worldTime.js create mode 100644 src/ui/styles/world-timeline.css create mode 100644 src/ui/views/panels/WorldTimeline.test.mjs create mode 100644 src/ui/views/panels/WorldTimeline.vue diff --git a/index.html b/index.html index 803bc2417..b563cf718 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 00f369e40..389adde77 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -146,7 +146,10 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", - "src/time/worldClock.js" + "src/time/worldClock.js", + "src/ui/kit/UiIcon.vue", + "src/ui/stores/worldTime.js", + "src/ui/views/panels/WorldTimeline.vue" ], "external": ["vue", "pinia", "vue-i18n"] }, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 3dd0b6d73..69ae84544 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3218,5 +3218,8 @@ "Load scenes from an exported JSON file": "تحميل المشاهد من ملف JSON مُصدَّر", "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 أو حزمة موارد" + "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", + "World time": "وقت العالم", + "Back to live": "العودة إلى البث المباشر", + "Live": "مباشر" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 144617819..da1bebb4a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2938,5 +2938,8 @@ "Left panels": "Linke Panels", "Right panels": "Rechte Panels", "CONFIRMED": "BESTÄTIGT", - "PAD / RTLS": "STARTPLATZ / RTLS" + "PAD / RTLS": "STARTPLATZ / RTLS", + "World time": "Weltzeit", + "Back to live": "Zurück zu live", + "Live": "Live" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e1d21a5c7..f592a72c5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,5 +3008,8 @@ "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", "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" + "Right panels": "Paneles derechos", + "World time": "Hora mundial", + "Back to live": "Volver al directo", + "Live": "En directo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4a6987fad..4d7289ff7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3008,5 +3008,8 @@ "Left panels": "Panneaux de gauche", "Right panels": "Panneaux de droite", "CONFIRMED": "CONFIRMÉE", - "PAD / RTLS": "PAS DE TIR / RTLS" + "PAD / RTLS": "PAS DE TIR / RTLS", + "World time": "Heure du monde", + "Back to live": "Revenir au direct", + "Live": "Direct" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1cf792029..065137689 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3008,5 +3008,8 @@ "Left panels": "Pannelli a sinistra", "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", - "PAD / RTLS": "SITO DI LANCIO / RTLS" + "PAD / RTLS": "SITO DI LANCIO / RTLS", + "World time": "Ora del mondo", + "Back to live": "Torna alla diretta", + "Live": "Diretta" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index df96321e3..3ff743076 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2868,5 +2868,8 @@ "Left panels": "左パネル", "Right panels": "右パネル", "CONFIRMED": "確認済み", - "PAD / RTLS": "射場 / RTLS" + "PAD / RTLS": "射場 / RTLS", + "World time": "世界時刻", + "Back to live": "ライブに戻る", + "Live": "ライブ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index b4326fc74..455515ab2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3008,5 +3008,8 @@ "Left panels": "Painéis à esquerda", "Right panels": "Painéis à direita", "CONFIRMED": "CONFIRMADA", - "PAD / RTLS": "PLATAFORMA / RTLS" + "PAD / RTLS": "PLATAFORMA / RTLS", + "World time": "Hora do mundo", + "Back to live": "Voltar ao vivo", + "Live": "Ao vivo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7bd1ea870..90099d66c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3078,5 +3078,8 @@ "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", "Left panels": "Левые панели", - "Right panels": "Правые панели" + "Right panels": "Правые панели", + "World time": "Мировое время", + "Back to live": "Вернуться к прямому эфиру", + "Live": "Сейчас" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index fbe8a6d3f..6ca398f07 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3078,5 +3078,8 @@ "Left panels": "Ліві панелі", "Right panels": "Праві панелі", "CONFIRMED": "ПІДТВЕРДЖЕНО", - "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS" + "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", + "World time": "Світовий час", + "Back to live": "Повернутися до прямого ефіру", + "Live": "Зараз" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ffdd8a4b4..019e97701 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", "Left panels": "左侧面板", - "Right panels": "右侧面板" + "Right panels": "右侧面板", + "World time": "世界时间", + "Back to live": "返回实时", + "Live": "实时" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c7b0bb5eb..3e41803e6 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Left panels": "左側面板", - "Right panels": "右側面板" + "Right panels": "右側面板", + "World time": "世界時間", + "Back to live": "返回即時", + "Live": "即時" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 1cbf64140..a4edfe8d6 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -88,6 +88,7 @@ "Landsat 8/9", "Latin", "Layout", + "Live", "London", "Los Angeles", "MIL", diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..ee0d5541b 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#gev-voice-control', '#pp-toggles', '#param-slider-panel', + '#world-timeline', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle @@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', + '#world-timeline', ].join(', '); export class PanelLayoutController { constructor({ diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js new file mode 100644 index 000000000..8169982f3 --- /dev/null +++ b/src/ui/stores/worldTime.js @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia'; +import { onScopeDispose, shallowRef } from 'vue'; +import { worldClock } from '../../time/worldClock.js'; + +/** + * The world clock as the timeline shows it: `{live, playing, timeMs}`. It + * follows the page's world clock, which tells it of every move, and of the + * time a few times a second while it plays on its own. + */ +export const useWorldTimeStore = defineStore('gev-world-time', () => { + const read = (state = worldClock.getState()) => + Object.freeze({ + live: state.live, + playing: state.playing, + timeMs: state.timeMs, + }); + const time = shallowRef(read()); + onScopeDispose( + worldClock.subscribe((state) => { + time.value = read(state); + }), + ); + + return { + time, + togglePlay: () => worldClock.togglePlay(), + goLive: () => worldClock.goLive(), + }; +}); diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css new file mode 100644 index 000000000..b791b9520 --- /dev/null +++ b/src/ui/styles/world-timeline.css @@ -0,0 +1,62 @@ +/* ── World timeline ─────────────────────────── */ +/* Shown while the world time is away from live, centred above the dock. */ +#world-timeline { + position: fixed; + bottom: calc(2vh + 7rem); + left: 50%; + z-index: 146; + display: flex; + align-items: center; + gap: 10px; + max-width: calc(100vw - 32px); + box-sizing: border-box; + padding: 4px 6px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + transform: translateX(-50%); +} + +body.ui-clean-view #world-timeline, +body.recording-mode #world-timeline { + bottom: 2vh; +} + +#world-timeline .world-timeline-play, +#world-timeline .world-timeline-live { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(123, 189, 211, 0.28); + border-radius: 8px; + background: rgba(0, 212, 255, 0.08); + color: var(--text-primary); + font: inherit; + cursor: pointer; +} + +#world-timeline .world-timeline-play:hover, +#world-timeline .world-timeline-live:hover { + background: rgba(0, 212, 255, 0.18); +} + +#world-timeline .world-timeline-live { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +#world-timeline .world-timeline-time { + overflow: hidden; + font-family: var(--font-mono); + font-size: 13px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..32900f42a 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -117,4 +118,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs new file mode 100644 index 000000000..0fc35c5c2 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -0,0 +1,45 @@ +// The world timeline shows the world time while it is away from live, with +// play and pause on the left and the way back to live on the right. +import '../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { worldClock } from '../../../time/worldClock.js'; +import WorldTimeline from './WorldTimeline.vue'; + +test('the timeline shows away from live and plays, pauses and goes back', async (t) => { + document.body.replaceChildren(); + const app = createApp(WorldTimeline).use(createPinia()); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => { + app.unmount(); + worldClock.goLive(); + }); + const bar = () => document.getElementById('world-timeline'); + await nextTick(); + assert.equal(bar(), null, 'hidden while live'); + + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + worldClock.seek(at); + await nextTick(); + assert.ok(bar()); + const time = bar().querySelector('time'); + assert.equal(time.getAttribute('datetime'), new Date(at).toISOString()); + assert.match(time.textContent, /UTC$/); + const play = bar().querySelector('.world-timeline-play'); + assert.equal(play.getAttribute('aria-label'), 'Play'); + + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(play.getAttribute('aria-label'), 'Pause'); + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + + bar().querySelector('.world-timeline-live').click(); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.equal(bar(), null); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue new file mode 100644 index 000000000..33026f2a1 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.vue @@ -0,0 +1,34 @@ + + + diff --git a/style.css b/style.css index 7229ab02f..1def60bb3 100644 --- a/style.css +++ b/style.css @@ -29,6 +29,7 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/kit.css'; From 690a6637e26513cb95fd003e22048a1dc8b4836f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 22:36:45 -0800 Subject: [PATCH 017/174] Show the timeline only while a layer with time to show is on The bar now needs more than the world clock being away from live: a layer that moves with the clock must be on with time to show. In Core that is weather radar, satellite or lightning with frames; a host adds layers by following them. Turning the last one off hides the bar; turning it back on brings it back. The world clock reports this as `supported` in its state. Weather products register their frames as coverage one by one, suspended or not, so the way back to live stays on screen with reduced motion. Weather refreshes and satellite catalog changes tell the clock their coverage may have changed. A coverage that throws counts as none, and the clock reads its state once per notification round. --- src/layers/satellites/catalog.js | 2 + src/layers/satellites/ingestion.js | 1 + src/layers/satellites/ingestion.test.mjs | 1 + src/layers/satellites/lifecycle.js | 9 ++++- src/layers/satellites/state.js | 6 ++- src/layers/weather/clock.js | 42 +++++++++++++++------ src/layers/weather/clock.test.mjs | 44 +++++++++++++++++++++- src/time/worldClock.js | 37 ++++++++++++++++-- src/time/worldClock.test.mjs | 35 +++++++++++++++++ src/ui/stores/worldTime.js | 5 ++- src/ui/views/panels/WorldTimeline.test.mjs | 42 +++++++++++++++++++-- src/ui/views/panels/WorldTimeline.vue | 6 ++- 12 files changed, 205 insertions(+), 25 deletions(-) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index c5c1aaa75..32e420a04 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -164,6 +164,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._denseStatus = 'ready'; console.log( `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, @@ -231,6 +232,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); } return { _abortActiveUpdates, diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 3db7d7471..b02f8371a 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -170,6 +170,7 @@ export function createIngestion({ layerState._coreStale = coreStale; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._lastUpdate = Date.now(); layerState._lastPropagation = Date.now(); layerState._lastTrackingRefreshOutcome = { diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index d08f8b7a8..663541c9c 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -33,6 +33,7 @@ function ingestionFor(catalogText) { _catalogRevision: 0, _params: { catalog: 'core', showOrbits: false }, now: Date.now, + coverageChanged() {}, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 896f2fe96..b12600c10 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -13,7 +13,8 @@ export function createLifecycle({ // their element sets. let releaseCoverage = null; const coverWorldTime = () => { - releaseCoverage ??= worldClock.register({ + if (releaseCoverage) return; + const release = worldClock.register({ id: 'satellites', coverage: () => { if (!layerState._catalog.size) return null; @@ -24,6 +25,10 @@ export function createLifecycle({ }; }, }); + // Registering tells the world's listeners, which may turn the layer off + // (or off and on, registering again) before this returns. + if (layerState._enabled && !releaseCoverage) releaseCoverage = release; + else release(); }; const uncoverWorldTime = () => { releaseCoverage?.(); @@ -67,6 +72,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); // Point primitives for satellite dots layerState._pointCollection = new Cesium.PointPrimitiveCollection(); @@ -185,6 +191,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._rowControlsListener = null; layerState._count = 0; layerState._lastUpdate = null; diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 44e004476..8464fb275 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { worldTimeFor } from '../../time/worldClock.js'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -156,6 +156,10 @@ export function createState({ services }) { state.now = worldTimeFor('satellites'); + /** The catalog changed, so the time satellites can be shown at may have. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 9e2d5a96f..67973bf94 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -162,7 +162,34 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); - releases.set(product.id, world.hold(product.id)); + // The product moves with the world clock, and its frames are time the + // world clock's timeline can show while it is on, suspended or not, so + // the way back to live stays on screen. Both calls notify the world's + // listeners, which may destroy this clock: the release is in place + // first, and anything acquired after a destroy is let go at once. + let unhold = null; + let uncover = null; + const release = () => { + uncover?.(); + unhold?.(); + uncover = unhold = null; + }; + releases.set(product.id, release); + unhold = world.hold(product.id); + if (!destroyed) + uncover = world.register({ + id: product.id, + coverage: () => { + const marks = timesFor(product).map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + if (destroyed) { + release(); + return () => {}; + } void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; @@ -242,6 +269,8 @@ export function createWeatherClock({ }, refresh() { if (destroyed) return Promise.resolve(false); + // New frames, or products added or dropped. + world.coverageChanged(); if (!canPlay()) { stopPlaying(); cancelTimer(); @@ -262,7 +291,6 @@ export function createWeatherClock({ ++generation; cancelTimer(); unfollowWorld(); - uncover(); // The last weather clock leaves the world live, as a new one expects. if (driver.detach() === 0) world.goLive(); for (const release of releases.values()) release(); @@ -292,16 +320,6 @@ export function createWeatherClock({ else notify(); } const unfollowWorld = world.subscribe(followWorld); - // The observed frames this clock can show, for a host's timeline. - const uncover = world.register({ - id: 'weather', - coverage: () => { - const marks = getTimeline().map((time) => Date.parse(time)); - return marks.length - ? { start: marks[0], end: marks.at(-1), marks } - : null; - }, - }); driver = world.attachDriver({ play: () => (canPlay() ? void api.play() : false), pause: () => api.pause(), diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 9561c10c7..c803e5d72 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -436,9 +436,10 @@ test('a target superseded from a listener resolves false', async (t) => { assert.equal(radar.shown, time(10)); }); -test('the observed frames are the world clock coverage, while registered', (t) => { +test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { const { clock, world } = harness(t); assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); const radar = product('radar', [0, 10]); const unregister = clock.register(radar); clock.register(product('lightning', [5])); @@ -447,7 +448,48 @@ test('the observed frames are the world clock coverage, while registered', (t) = end: Date.parse(time(10)), marks: [0, 5, 10].map((m) => Date.parse(time(m))), }); + assert.equal(world.getState().supported, true); unregister(); + assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); clock.destroy(); assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +test('a suspended product keeps its coverage, so the way back to live stays', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10]); + clock.register(radar); + radar.suspended = true; + void clock.refresh(); + assert.equal(world.getState().supported, true); + assert.deepEqual( + world.coverage().marks, + [0, 10].map((m) => Date.parse(time(m))), + ); +}); + +test('new frames reach the world clock through refresh', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', []); + clock.register(radar); + const supported = []; + world.subscribe((state) => supported.push(state.supported)); + assert.equal(world.getState().supported, false); + radar.times = [time(0)]; + void clock.refresh(); + assert.equal(supported.at(-1), true); +}); + +test('a clock destroyed while it registers a product lets go of it', (t) => { + const { clock, world } = harness(t); + const off = world.subscribe(() => { + off(); + clock.destroy(); + }); + const unregister = clock.register(product('radar', [0, 10])); + unregister(); + assert.equal(world.follows('radar'), false); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); }); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 446318a43..b50d8f5a4 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -61,6 +61,25 @@ export function createWorldClock({ const followedIds = () => [ ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]), ]; + // A host's coverage that throws counts as none, so it cannot break the + // listeners every state reaches. + const spanOf = (source) => { + let span; + try { + span = source.coverage(); + } catch (error) { + console.error(`World time coverage failed: ${source.id}`, error); + return null; + } + return span && span.end >= span.start ? span : null; + }; + // Whether a layer that moves with the clock has time to show: only then is + // there anything for a timeline to play. + const isSupported = () => { + for (const { source } of sources) + if (isFollowed(source.id) && spanOf(source)) return true; + return false; + }; const time = () => { if (live) return now(); if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate; @@ -73,17 +92,20 @@ export function createWorldClock({ playing, rate, following: followedIds(), + supported: isSupported(), }); // Each listener hears the state as it is when called. A change made from // inside a listener notifies everyone afresh, and the outer round stops, so // nobody is left holding a state that has since changed. + // A round's state is read once: any change made during it starts a new one. const notify = () => { const round = ++notifying; + let state = null; for (const listener of [...listeners]) { if (round !== notifying) return; if (!listeners.has(listener)) continue; try { - listener(getState()); + listener((state ??= getState())); } catch (error) { console.error('World clock listener failed', error); } @@ -270,16 +292,23 @@ export function createWorldClock({ // Each registration is its own, even under a shared id. const entry = { source }; sources.add(entry); - return () => sources.delete(entry); + changed(); + return () => { + if (sources.delete(entry)) changed(); + }; }, + /** A source's coverage changed (frames loaded, the layer paused). */ + coverageChanged: () => changed(), + /** Whether a followed layer has time to show; see getState().supported. */ + isSupported, /** The union of the registered sources' coverage, or null with none. */ coverage() { let start = Infinity; let end = -Infinity; const marks = new Set(); for (const { source } of sources) { - const span = source.coverage(); - if (!span || !(span.end >= span.start)) continue; + const span = spanOf(source); + if (!span) continue; start = Math.min(start, span.start); end = Math.max(end, span.end); for (const mark of span.marks ?? []) marks.add(mark); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index a954f60fd..99d81084f 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -252,3 +252,38 @@ test('seek refuses a time no Date can hold', () => { clock.seek(-8.64e15); assert.equal(clock.now(), -8.64e15); }); + +test('only a followed layer with time to show supports the timeline', () => { + const clock = createWorldClock(fakeTime().options); + let span = null; + const states = []; + clock.subscribe((state) => states.push(state.supported)); + const off = clock.register({ id: 'satellites', coverage: () => span }); + span = { start: 0, end: 10 }; + clock.coverageChanged(); + assert.equal(clock.isSupported(), false, 'covered but not followed'); + clock.follow('satellites'); + assert.equal(clock.isSupported(), true); + assert.equal(states.at(-1), true); + span = null; + clock.coverageChanged(); + assert.equal(states.at(-1), false); + span = { start: 0, end: 10 }; + off(); + assert.equal(clock.isSupported(), false); + assert.equal(states.at(-1), false); +}); + +test('a coverage that throws counts as none', (t) => { + t.mock.method(console, 'error', () => {}); + const clock = createWorldClock(fakeTime().options); + clock.follow('broken'); + clock.register({ + id: 'broken', + coverage() { + throw new Error('no'); + }, + }); + assert.equal(clock.getState().supported, false); + assert.equal(clock.coverage(), null); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 8169982f3..1c39fbe66 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -3,7 +3,9 @@ import { onScopeDispose, shallowRef } from 'vue'; import { worldClock } from '../../time/worldClock.js'; /** - * The world clock as the timeline shows it: `{live, playing, timeMs}`. It + * The world clock as the timeline shows it: `{live, playing, timeMs, + * supported}`, where `supported` says a layer that moves with the clock has + * time to show. It * follows the page's world clock, which tells it of every move, and of the * time a few times a second while it plays on its own. */ @@ -13,6 +15,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { live: state.live, playing: state.playing, timeMs: state.timeMs, + supported: state.supported, }); const time = shallowRef(read()); onScopeDispose( diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 0fc35c5c2..2a10d06b3 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -8,7 +8,7 @@ import { createPinia } from 'pinia'; import { worldClock } from '../../../time/worldClock.js'; import WorldTimeline from './WorldTimeline.vue'; -test('the timeline shows away from live and plays, pauses and goes back', async (t) => { +function mount(t) { document.body.replaceChildren(); const app = createApp(WorldTimeline).use(createPinia()); app.mount(document.body.appendChild(document.createElement('div'))); @@ -16,11 +16,33 @@ test('the timeline shows away from live and plays, pauses and goes back', async app.unmount(); worldClock.goLive(); }); - const bar = () => document.getElementById('world-timeline'); + return () => document.getElementById('world-timeline'); +} + +// A layer that moves with the clock and has an hour of frames. +function supportingLayer(t, at) { + const release = worldClock.hold('test-radar'); + const unregister = worldClock.register({ + id: 'test-radar', + coverage: () => ({ start: at - 3_600_000, end: at }), + }); + t.after(() => { + unregister(); + release(); + }); + return () => { + unregister(); + release(); + }; +} + +test('the timeline shows away from live and plays, pauses and goes back', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); await nextTick(); assert.equal(bar(), null, 'hidden while live'); - const at = Date.UTC(2026, 9, 30, 6, 15, 42); worldClock.seek(at); await nextTick(); assert.ok(bar()); @@ -43,3 +65,17 @@ test('the timeline shows away from live and plays, pauses and goes back', async assert.equal(worldClock.isLive(), true); assert.equal(bar(), null); }); + +test('the timeline hides while no layer with time to show is on', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + worldClock.seek(at); + await nextTick(); + assert.equal(bar(), null, 'away from live, but nothing to play'); + const remove = supportingLayer(t, at); + await nextTick(); + assert.ok(bar()); + remove(); + await nextTick(); + assert.equal(bar(), null, 'the layer went off'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 33026f2a1..ea1da1fc3 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -8,7 +8,9 @@ import UiIcon from '../../kit/UiIcon.vue'; // The world time while it is away from live: what is playing, or where it // was paused, with play and pause on the left and the way back to live on -// the right. Live, the bar stays hidden. +// the right. It shows only while a layer that moves with the clock (weather +// radar, satellite or lightning, or one a host follows) is on with time to +// show; live, or with none on, the bar stays hidden. const store = useWorldTimeStore(); const { time } = storeToRefs(store); @@ -25,7 +27,7 @@ const readout = computed(() => From 09d833ed960bdd82ac831b6d149c0d466782300e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:17:06 -0800 Subject: [PATCH 027/174] Add the changelog entry for the world clock and timeline --- changelog.d/1512-world-clock-timeline.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1512-world-clock-timeline.md diff --git a/changelog.d/1512-world-clock-timeline.md b/changelog.d/1512-world-clock-timeline.md new file mode 100644 index 000000000..a43231150 --- /dev/null +++ b/changelog.d/1512-world-clock-timeline.md @@ -0,0 +1,5 @@ +- One world clock now drives everything with a time of its own: the sky, + the HUD clock, satellites, launches and weather. A timeline along the + bottom of the screen, shown while one of those layers is on, plays, pauses + and scrubs it, with a tick for each launch or frame and a way back to + live. Share links made away from live carry the time (samehkhamis, #1512). From 497016b0848f32affbc4dcb53b55da7b0881ff3a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:17:28 -0800 Subject: [PATCH 028/174] Add the changelog entry for launches, speed and buffering on the timeline --- changelog.d/1513-timeline-launches-speed-buffering.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1513-timeline-launches-speed-buffering.md diff --git a/changelog.d/1513-timeline-launches-speed-buffering.md b/changelog.d/1513-timeline-launches-speed-buffering.md new file mode 100644 index 000000000..f4663019b --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -0,0 +1,5 @@ +- The timeline brings launches up as the time passes them, offers play + speeds from real time to a week a second, and waits on each weather frame + until it loads instead of running past it. The time now rides in a bubble + over the handle. Zooming out on the globe stops while the globe is still in + view instead of leaving it a speck (samehkhamis, #1513). From 287c881c17b88ae84916c007970e06ddd317e123 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:53:08 -0800 Subject: [PATCH 029/174] Zoom the timeline track around the handle With layers of very different reach on together, a month of launches and a few hours of lightning, the short-range ticks were crushed at the end of the one shared track. Scroll or pinch on the track now zooms it around the handle, plus and minus zoom from the keyboard, and zero or a double-click shows it all again. Everything still plays together; zooming only changes what the track shows, and the view follows the handle as it plays, steps or is left behind by now. A scrub's live edge goes by the stretch shown, so a zoomed track can land minutes from now, and a double-click or pinch puts back the time its first press moved. --- 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/ui/stores/worldTime.js | 16 +- src/ui/styles/world-timeline.css | 13 ++ src/ui/views/panels/WorldTimeline.test.mjs | 112 ++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 161 +++++++++++++++++++-- 15 files changed, 303 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index ce7902009..3715c31b0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3230,5 +3230,6 @@ "1 h/s": "ساعة/ث", "1 d/s": "يوم/ث", "1 wk/s": "أسبوع/ث", - "Play speed": "سرعة التشغيل" + "Play speed": "سرعة التشغيل", + "Scroll or pinch to zoom · double-click to see it all": "مرّر أو اقرص للتكبير · انقر نقرًا مزدوجًا لرؤية الكل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ae988568d..ec76e1298 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2950,5 +2950,6 @@ "1 h/s": "1 Std./s", "1 d/s": "1 Tag/s", "1 wk/s": "1 Wo./s", - "Play speed": "Wiedergabegeschwindigkeit" + "Play speed": "Wiedergabegeschwindigkeit", + "Scroll or pinch to zoom · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen · Doppelklick zeigt alles" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ad207bdb0..81f0e6403 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", - "Play speed": "Velocidad de reproducción" + "Play speed": "Velocidad de reproducción", + "Scroll or pinch to zoom · double-click to see it all": "Desplaza o pellizca para ampliar · doble clic para verlo todo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3345cf46b..c95e70e93 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 j/s", "1 wk/s": "1 sem/s", - "Play speed": "Vitesse de lecture" + "Play speed": "Vitesse de lecture", + "Scroll or pinch to zoom · double-click to see it all": "Faites défiler ou pincez pour zoomer · double-clic pour tout voir" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c1717f5b..47145cc25 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 g/s", "1 wk/s": "1 sett/s", - "Play speed": "Velocità di riproduzione" + "Play speed": "Velocità di riproduzione", + "Scroll or pinch to zoom · double-click to see it all": "Scorri o pizzica per lo zoom · doppio clic per vedere tutto" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fd70f8541..fc4ad110b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2880,5 +2880,6 @@ "1 h/s": "1時間/秒", "1 d/s": "1日/秒", "1 wk/s": "1週間/秒", - "Play speed": "再生速度" + "Play speed": "再生速度", + "Scroll or pinch to zoom · double-click to see it all": "スクロールまたはピンチでズーム · ダブルクリックで全体表示" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3b9a03e29..c01472e42 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", - "Play speed": "Velocidade de reprodução" + "Play speed": "Velocidade de reprodução", + "Scroll or pinch to zoom · double-click to see it all": "Role ou faça pinça para ampliar · clique duplo para ver tudo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c4b0e2046..83d18b2d2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 ч/с", "1 d/s": "1 дн/с", "1 wk/s": "1 нед/с", - "Play speed": "Скорость воспроизведения" + "Play speed": "Скорость воспроизведения", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутите или сведите пальцы для масштаба · двойной щелчок — показать всё" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 1bad7daa6..ab598a937 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 год/с", "1 d/s": "1 дн/с", "1 wk/s": "1 тиж/с", - "Play speed": "Швидкість відтворення" + "Play speed": "Швидкість відтворення", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу · подвійне клацання — показати все" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ec77d015d..cb34c135d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小时/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 周/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "滚动或双指缩放 · 双击查看全部" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 7753a6f95..7d82fbcd9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小時/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "捲動或雙指縮放 · 按兩下查看全部" } diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index ea73e262f..35cad2edc 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -48,9 +48,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { marks: covered.marks, }); }; - const nearNow = (ms, span) => + const nearNow = (ms, length) => Math.abs(ms - worldClock.wallNow()) <= - Math.max(LIVE_EDGE_MS, span ? (span.end - span.start) * LIVE_EDGE : 0); + Math.max(LIVE_EDGE_MS, length * LIVE_EDGE); // Whether the track ends at now (and moves on with it), not past it. const endsAtNow = (covered) => covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; @@ -146,9 +146,15 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { worldClock.play({ rate: rate(), continuous: true }); }, goLive: () => worldClock.goLive(), - /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ - scrub(ms) { - if (nearNow(ms, time.value.span)) { + /** + * Scrub to `ms`: playback stops where the hand puts it; at now, live. + * How near now counts as now goes by `length`, the stretch the track + * shows, all of the span unless it is zoomed. + */ + scrub(ms, length) { + const span = time.value.span; + length ??= span ? span.end - span.start : 0; + if (nearNow(ms, length)) { worldClock.goLive(); return; } diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 0fd12fe28..f6c379e8e 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -128,11 +128,24 @@ body.cockpit-mode #world-timeline { range input on top for dragging and the keyboard. */ #world-timeline .world-timeline-track { position: relative; + /* Two fingers pinch the track, not the page. */ + touch-action: none; flex: 1 1 auto; min-width: 80px; height: 24px; } +/* Zoomed, the track's ends fade, as it goes on past them. */ +#world-timeline .world-timeline-track.is-zoomed::before { + background: linear-gradient( + 90deg, + transparent, + rgba(123, 189, 211, 0.22) 12px, + rgba(123, 189, 211, 0.22) calc(100% - 12px), + transparent + ); +} + #world-timeline .world-timeline-track::before { position: absolute; top: 50%; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 746158ddb..06301f3a2 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -299,3 +299,115 @@ test('paused just before the end, play carries on rather than restarting', async worldClock.pause(); assert.ok(worldClock.now() >= end - 4_000, 'not back to the start'); }); + +test('the track zooms around the handle, follows it, and shows it all again', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + // A month of launches and three hours of lightning, sharing one track. + coverage: () => ({ + start, + end: now, + marks: [start, now - 3 * 3_600_000, now - 2 * 3_600_000, now - 3_600_000], + }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + const length = () => Number(input.max) - Number(input.min); + const full = length(); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 4); + + // Live, the handle is at the end: zooming in keeps the view on now. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); + await nextTick(); + } + assert.ok(length() < full / 100, 'zoomed in'); + assert.ok(Number(input.max) >= now, 'still ending at now'); + assert.equal(track.classList.contains('is-zoomed'), true); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'only the ticks in view'); + + // A seek off the view takes the view along. + worldClock.seek(start + 86_400_000); + await nextTick(); + assert.ok(Number(input.min) <= start + 86_400_000 && Number(input.max) >= start + 86_400_000); + assert.ok(length() < full / 100, 'at the same zoom'); + + // Minus widens, zero and a double-click show it all. + input.dispatchEvent(new KeyboardEvent('keydown', { key: '-' })); + await nextTick(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '0' })); + await nextTick(); + assert.ok(Math.abs(length() - full) < 1_000, 'all of it'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + assert.ok(length() < full); + track.dispatchEvent(new MouseEvent('dblclick')); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); +}); + +test('zoomed, a scrub near now stays off live, and the view keeps a still handle', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + let end = now; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end, marks: [start, now - 3_600_000] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + // Ten notches of a line-mode wheel, as Firefox sends them. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -12, deltaMode: 1, cancelable: true })); + await nextTick(); + } + assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels'); + // A sideways swipe leaves the zoom alone. + const sideways = new WheelEvent('wheel', { deltaX: 50, deltaY: 1, cancelable: true }); + track.dispatchEvent(sideways); + assert.equal(sideways.defaultPrevented, false); + + // Twenty minutes back is near now on a month, not on a few hours. + input.value = String(now - 20 * 60_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false); + assert.ok(Math.abs(worldClock.now() - (now - 20 * 60_000)) < 1_000); + + // The span running on past the paused handle takes the view back to it. + end = now + 86_400_000; + worldClock.coverageChanged(); + await nextTick(); + const at = worldClock.now(); + assert.ok(Number(input.min) <= at && Number(input.max) >= at, 'handle in view'); + + // With a modifier, plus is the browser's zoom. + const browserZoom = new KeyboardEvent('keydown', { key: '+', ctrlKey: true, cancelable: true }); + input.dispatchEvent(browserZoom); + assert.equal(browserZoom.defaultPrevented, false); + + // A double-click puts back the time its first press moved. + track.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + input.value = String(at - 3_600_000); + input.dispatchEvent(new Event('input')); + track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); + assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index e56737fda..9588b7e64 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@ @@ -93,20 +221,21 @@ const onKey = (event) => { >
{{ t('Live') }} Date: Mon, 2 Nov 2026 15:11:47 -0800 Subject: [PATCH 030/174] Show a spinner by the time while play waits on data Play holds while a layer loads the frame for its time, and the bar's only sign of it was a faint pulse, so a stopped handle read as a stall. The bubble now shows a small spinner beside the time while play is held. It comes on once a hold lasts long enough to notice and stays through a run of slow frames, so neither a quick frame nor the gaps between frames flash it. --- src/ui/styles/world-timeline.css | 25 +++++++++++++------ src/ui/views/panels/WorldTimeline.test.mjs | 29 ++++++++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 28 +++++++++++++++++++-- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f6c379e8e..2eeea8504 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -106,21 +106,32 @@ body.cockpit-mode #world-timeline { pointer-events: none; } -/* Playing, held while a layer loads the time's data. */ -#world-timeline .world-timeline-time.is-buffering { - animation: world-timeline-buffering 1s ease-in-out infinite alternate; +/* Playing, held while a layer loads the time's data: a spinner by the time. */ +#world-timeline .world-timeline-time.is-waiting { + display: inline-flex; + align-items: center; + gap: 6px; } -@keyframes world-timeline-buffering { +#world-timeline .world-timeline-spinner { + width: 10px; + height: 10px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent); + border-radius: 50%; + animation: world-timeline-spin 0.9s linear infinite; +} + +@keyframes world-timeline-spin { to { - opacity: 0.45; + transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - #world-timeline .world-timeline-time.is-buffering { + #world-timeline .world-timeline-spinner { animation: none; - opacity: 0.6; + border-color: var(--accent); } } diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 06301f3a2..40e7d3ad1 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -411,3 +411,32 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle assert.equal(track.classList.contains('is-zoomed'), false); assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was'); }); + +test('play held for a layer to load shows a spinner once the hold lasts', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); + worldClock.seek(at - 1_800_000); + worldClock.play({ continuous: true }); + await nextTick(); + const bubble = () => bar().querySelector('.world-timeline-time'); + const release = worldClock.wait('test-radar'); + t.after(release); + // A clock tick (a quarter second) tells of the hold; the spinner waits on. + await new Promise((resolve) => setTimeout(resolve, 250)); + await nextTick(); + assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); + // The clock tells of the hold on its next tick, and the bar shows it after. + const until = async (shown) => { + for (let waited = 0; waited < 3_000; waited += 50) { + if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + await nextTick(); + } + return false; + }; + assert.ok(await until(true), 'spinner once the hold lasts'); + assert.match(bubble().textContent, /UTC$/, 'the time stays'); + release(); + assert.ok(await until(false), 'gone once play goes on'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 9588b7e64..40baecaae 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@ `, + ), + null, + ); +}); + +test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => { + const at = Date.UTC(2026, 10, 3, 12); + assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false); + assert.equal( + isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at), + true, + ); + assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false); + for (const value of [null, undefined, NaN]) + assert.equal(isGhostMapsEventExpired(value, at), false); +}); + // The shape ArcGIS Pro writes into every S2 placemark description. const ARCGIS = ` @@ -27,6 +102,14 @@ const ARCGIS = ` function changeImage(a, b) { document.getElementById('x').innerHTML = b; } `; +test('custom ages change the cutoff and all markers disables expiry', () => { + const at = Date.UTC(2026, 10, 3, 12); + const event = at - 10 * 86400_000; + assert.equal(isGhostMapsEventExpired(event, at, 7), true); + assert.equal(isGhostMapsEventExpired(event, at, 14), false); + assert.equal(isGhostMapsEventExpired(event, at, null), false); +}); + test('ArcGIS popup tables become plain label/value fields and http links', () => { const { fields, links } = parseDescriptionFields(ARCGIS); assert.deepEqual(fields, [ From 134597c3cf53aab6252fce260e1fa8cdc0f8c6ad Mon Sep 17 00:00:00 2001 From: Dylan Maniatakes Date: Tue, 3 Nov 2026 13:23:40 -0500 Subject: [PATCH 040/174] docs: add S2 age filter changelog for PR 1528 --- changelog.d/1528-s2-ghostmaps-age-filter.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1528-s2-ghostmaps-age-filter.md diff --git a/changelog.d/1528-s2-ghostmaps-age-filter.md b/changelog.d/1528-s2-ghostmaps-age-filter.md new file mode 100644 index 000000000..2ba019286 --- /dev/null +++ b/changelog.d/1528-s2-ghostmaps-age-filter.md @@ -0,0 +1,4 @@ +- Add an adjustable event-age filter across S2 GhostMaps products, defaulting + to seven days with 1-, 3-, 14- and 30-day choices and an All markers option. + Preserve undated features, save the selected window and expire old events + while the map stays open. Thanks @dylanmaniatakes (#1528). From 4f43ded5da41303d71258ffbf7b244e7f81b16f9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:05:17 -0800 Subject: [PATCH 041/174] test(nominatim): check cache file modes only where POSIX modes exist The disk-cache test asserts the cache directory is 0o700 and its files 0o600. Windows has no POSIX mode bits (the directory stats as 0o666), so the test always failed there. Both checks now run only off Windows, as localServices.test.mjs and mcpPanelKey.test.mjs already do for their 0o600 checks. The cache code is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/tooling/nominatimOutlineRoute.test.mjs | 30 ++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index c29b41f21..774099efe 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,25 +684,29 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - assert.equal( - fs.statSync(dir).mode & 0o777, - 0o700, - 'the query cache directory is private', - ); + // Windows has no POSIX mode bits (directories stat as 0o666), so only + // POSIX systems can check privacy this way. + if (process.platform !== 'win32') + assert.equal( + fs.statSync(dir).mode & 0o777, + 0o700, + 'the query cache directory is private', + ); const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - for (const name of fs - .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) - assert.equal( - fs.statSync(path.join(dir, name)).mode & 0o777, - 0o600, - `${name} is private`, - ); + if (process.platform !== 'win32') + for (const name of fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json'))) + assert.equal( + fs.statSync(path.join(dir, name)).mode & 0o777, + 0o600, + `${name} is private`, + ); fs.rmSync(dir, { recursive: true, force: true }); }); From 1c2cdc67752df80491ff6aa97708074b18d3029c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:06:45 -0800 Subject: [PATCH 042/174] changelog: Nominatim cache mode test on Windows (#1547) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1547-nominatim-cache-mode-windows.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1547-nominatim-cache-mode-windows.md diff --git a/changelog.d/1547-nominatim-cache-mode-windows.md b/changelog.d/1547-nominatim-cache-mode-windows.md new file mode 100644 index 000000000..9a48b1ee6 --- /dev/null +++ b/changelog.d/1547-nominatim-cache-mode-windows.md @@ -0,0 +1,3 @@ +- The Nominatim disk-cache test checks the cache directory and file modes + only where POSIX modes exist, so it no longer always fails on Windows + (quanticsoul4772, #1547). From e2ccac56ae9c8e8b087197c925c726281ae4fd7c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 21:58:42 -0500 Subject: [PATCH 043/174] fix(perimeters): show a 0.25-acre fire as 0.25 ac, not 0 ac The incident card rounded the WFIGS size to whole acres, so every fire under half an acre read "0 ac". The live perimeter feed carries many of these (0.01 to 0.37 acres). Sizes under one acre now keep the reported hundredths. --- docs/CURRENT-STATE.md | 4 ++++ src/layers/perimeters/cards.js | 5 ++++- src/layers/perimeters/cards.test.mjs | 31 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..e954ae082 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4239,6 +4239,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +The incident card rounds the WFIGS size to whole acres, except under one acre, +where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not +`0 ac`. + Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index aef5913ce..a6cb3ac4d 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -159,7 +159,10 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) { // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { - const acres = Math.round(row.acres); + // WFIGS reports sizes to the hundredth of an acre; a fire under one acre + // keeps them rather than reading "0 ac". + const acres = + row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres); facts.push( tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }), ); diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs index 0e064bff0..fe4aac06b 100644 --- a/src/layers/perimeters/cards.test.mjs +++ b/src/layers/perimeters/cards.test.mjs @@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => { 'Catron County · $85K to date', ]); }); + +test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => { + const row = { + stableId: 'x', + name: 'Grey', + acres: 0.25, + containedPct: null, + state: 'US-WA', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }; + assert.equal( + buildIncidentCard(row, 1758000000000).details[0], + '0.25 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0], + '0.01 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0], + '1 ac · containment unknown · US-WA', + ); +}); From 79c867c8cbd923b58bbed7873217eb43be12b643 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 22:14:16 -0500 Subject: [PATCH 044/174] docs(changelog): add the perimeter size fragment for #1569 --- changelog.d/1569-perimeters-sub-acre-size.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1569-perimeters-sub-acre-size.md diff --git a/changelog.d/1569-perimeters-sub-acre-size.md b/changelog.d/1569-perimeters-sub-acre-size.md new file mode 100644 index 000000000..5ee4ffa98 --- /dev/null +++ b/changelog.d/1569-perimeters-sub-acre-size.md @@ -0,0 +1,3 @@ +- Show a fire perimeter under one acre at its reported size. The incident card + rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac" + (Rudy Celekli, @rudycelekli, #1569). From 33a2876bc0533ad8a0e1015e8ef264dca2f952cd Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:36:13 +0400 Subject: [PATCH 045/174] Fix the translations of the voice geometry, fire filter, airspace and sea mark strings --- src/i18n/locales/ar.json | 38 ++++++------ src/i18n/locales/de.json | 34 +++++------ src/i18n/locales/es.json | 28 ++++----- src/i18n/locales/fr.json | 54 ++++++++--------- src/i18n/locales/it.json | 40 ++++++------- src/i18n/locales/ja.json | 68 +++++++++++----------- src/i18n/locales/pt.json | 44 +++++++------- src/i18n/locales/ru.json | 40 ++++++------- src/i18n/locales/uk.json | 42 ++++++------- src/i18n/locales/zh-CN.json | 40 ++++++------- src/i18n/locales/zh-Hant.json | 48 +++++++-------- src/voice/voiceCardPlanSteps.i18n.test.mjs | 10 ++-- 12 files changed, 243 insertions(+), 243 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..215b440a8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3220,7 +3220,7 @@ "Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة", "Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية", "High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط", - "Fire intensity (FRP)": "الشدة (FRP)", + "Fire intensity (FRP)": "شدة الحريق (FRP)", "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", @@ -3238,15 +3238,15 @@ "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", "Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)", "OSM Places": "أماكن OSM", - "state or province": "ولاية أو مقاطعة", - "county or district": "محافظة أو قضاء", + "state or province": "ولاية أو إقليم", + "county or district": "مقاطعة أو قضاء", "neighbourhood": "حي", "natural region": "منطقة طبيعية", "Which {place}?": "أي {place} تقصد؟", "Which place?": "أي مكان تقصد؟", - "Not drawn: {parts}": "لم يُرسم: {parts}", + "Not drawn: {parts}": "لم يُرسم المخطط: {parts}", "approximate": "تقريبي", - "{pct}% cloud": "غيوم {pct}%", + "{pct}% cloud": "الغيوم {pct}%", "Over {area}": "فوق {area}", "{count} of {total} acquisitions match": { "few": "الصور المطابقة: {count} من {total}", @@ -3257,12 +3257,12 @@ "other": "الصور المطابقة: {count} من {total}" }, "{n} acquisitions searched": { - "few": "الصور التي جرى البحث فيها: {n}", - "many": "الصور التي جرى البحث فيها: {n}", - "one": "الصور التي جرى البحث فيها: {n}", - "two": "الصور التي جرى البحث فيها: {n}", - "zero": "الصور التي جرى البحث فيها: {n}", - "other": "الصور التي جرى البحث فيها: {n}" + "zero": "الصور المفحوصة: {n}", + "one": "الصور المفحوصة: {n}", + "two": "الصور المفحوصة: {n}", + "few": "الصور المفحوصة: {n}", + "many": "الصور المفحوصة: {n}", + "other": "الصور المفحوصة: {n}" }, "No matching imagery": "لا توجد صور مطابقة", "Unknown number of {kind}": "{kind}: العدد غير معروف", @@ -3270,10 +3270,10 @@ "At least {count} {kind}": "{kind}: {count} على الأقل", "{count} {kind}": "{kind}: {count}", "In {area}": "في {area}", - "Area around {place}": "المنطقة المحيطة بـ{place}", - "Area around landmark": "المنطقة المحيطة بمعلم", - "Outline {place}": "تحديد مخطط {place}", - "Outline area": "تحديد مخطط منطقة", + "Area around {place}": "المنطقة حول {place}", + "Area around landmark": "المنطقة حول المعلم", + "Outline {place}": "رسم مخطط {place}", + "Outline area": "رسم مخطط منطقة", "Find {place}": "البحث عن {place}", "Find area": "البحث عن منطقة", "Find recent imagery": "البحث عن صور حديثة", @@ -3316,9 +3316,9 @@ "zero": "التكرارات المدمجة: {count}" }, "listed the first {limit}": "عُرض أول {limit}", - "counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود", + "counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية", "Search cancelled": "أُلغي البحث", - "Sea Marks": "علامات بحرية", + "Sea Marks": "العلامات البحرية", "Ski Pistes": "مسارات التزلج", "Fly to Tokyo": "انتقل إلى طوكيو", "Show live flights": "اعرض تتبّع الرحلات المباشر", @@ -3393,7 +3393,7 @@ "Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.", "The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.", - "{lower} to {upper}": "{lower} إلى {upper}", + "{lower} to {upper}": "من {lower} إلى {upper}", "Up to {upper}": "حتى {upper}", "From {lower}": "من {lower}", "Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات", @@ -3401,7 +3401,7 @@ "Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح", "Prohibited (P)": "محظور (P)", "Restricted (R)": "مقيّد (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).", "Prohibited": "محظور", "Restricted": "مقيّد", "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..b0509c5bf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2912,7 +2912,7 @@ "From {lower}": "Ab {lower}", "Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung", "Unnamed zone": "Unbenannte Zone", - "Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar", + "Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar", "Prohibited (P)": "Sperrgebiet (P)", "Restricted (R)": "Flugbeschränkungsgebiet (R)", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).", @@ -2949,13 +2949,13 @@ "fire confidence filter|All": "Alle", "fire confidence filter|Nominal+": "Mittel+", "fire confidence filter|High": "Hoch", - "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz", + "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz", "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", "Fire intensity (FRP)": "Intensität (FRP)", "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", - "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt", @@ -2995,9 +2995,9 @@ "{count} {kind}": "{kind}: {count}", "In {area}": "Gebiet: {area}", "Area around {place}": "Gebiet um {place}", - "Area around landmark": "Gebiet um ein Wahrzeichen", - "Outline {place}": "{place} umreißen", - "Outline area": "Gebiet umreißen", + "Area around landmark": "Gebiet um einen Ort", + "Outline {place}": "Umriss von {place} zeichnen", + "Outline area": "Umriss zeichnen", "Find {place}": "{place} finden", "Find area": "Gebiet finden", "Find recent imagery": "Aktuelle Bilddaten finden", @@ -3008,27 +3008,27 @@ "Loading imagery": "Bilddaten werden geladen", "Searching OpenStreetMap": "OpenStreetMap wird durchsucht", "voice area|Area": "Gebiet", - "buffer sized to the place": "Puffer in Größe des Ortes", + "buffer sized to the place": "Puffer entsprechend der Ortsgröße", "enclosing mapped area": "umschließendes kartiertes Gebiet", - "the map was cleared while looking it up": "die Karte wurde während der Suche geleert", - "OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich", + "the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert", + "OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich", "the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage", "there are more": "es gibt weitere", - "{radius} m around the landmark": "{radius} m um das Wahrzeichen", + "{radius} m around the landmark": "{radius} m um den Ort", "{count} parts": { - "one": "Teile: {count}", - "other": "Teile: {count}" + "one": "{count} Teil", + "other": "{count} Teile" }, "{drawn} of {total} parts drawn": { - "one": "Gezeichnete Teile: {drawn} von {total}", - "other": "Gezeichnete Teile: {drawn} von {total}" + "one": "{drawn} von {total} Teil gezeichnet", + "other": "{drawn} von {total} Teilen gezeichnet" }, "{count} duplicate mappings merged": { - "one": "Zusammengeführte Doppelkartierungen: {count}", - "other": "Zusammengeführte Doppelkartierungen: {count}" + "one": "{count} Duplikat zusammengeführt", + "other": "{count} Duplikate zusammengeführt" }, "listed the first {limit}": "die ersten {limit} aufgelistet", - "counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss", + "counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses", "Search cancelled": "Suche abgebrochen", "Sea Marks": "Seezeichen", "Ski Pistes": "Skipisten", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..935e21b1c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2986,7 +2986,7 @@ "fire confidence filter|All": "Todas", "fire confidence filter|Nominal+": "Nominal+", "fire confidence filter|High": "Alta", - "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", + "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "High confidence detections only": "Solo detecciones de confianza alta", "Fire intensity (FRP)": "Intensidad (FRP)", @@ -2995,8 +2995,8 @@ "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", - "{shown} of {total} shown": "{shown} de {total} mostrados", - "{lower} to {upper}": "{lower} a {upper}", + "{shown} of {total} shown": "Mostrando {shown} de {total}", + "{lower} to {upper}": "De {lower} a {upper}", "Up to {upper}": "Hasta {upper}", "From {lower}": "Desde {lower}", "Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos", @@ -3004,9 +3004,9 @@ "Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible", "Prohibited (P)": "Prohibido (P)", "Restricted (R)": "Restringido (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).", - "Prohibited": "Prohibido", - "Restricted": "Restringido", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).", + "Prohibited": "Zona prohibida", + "Restricted": "Zona restringida", "Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido", "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", "route proxy error": "error del proxy de rutas", @@ -3044,11 +3044,11 @@ "county or district": "condado o distrito", "neighbourhood": "barrio", "natural region": "región natural", - "Which {place}?": "¿Cuál {place}?", + "Which {place}?": "¿Qué {place}?", "Which place?": "¿Qué lugar?", "Not drawn: {parts}": "Sin dibujar: {parts}", - "approximate": "aproximado", - "{pct}% cloud": "{pct} % de nubes", + "approximate": "aproximada", + "{pct}% cloud": "{pct}% de nubes", "Over {area}": "Zona: {area}", "{count} of {total} acquisitions match": { "many": "Adquisiciones coincidentes: {count} de {total}", @@ -3062,7 +3062,7 @@ }, "No matching imagery": "No hay imágenes que coincidan", "Unknown number of {kind}": "{kind}: número desconocido", - "About {count} {kind}": "{kind}: unos {count}", + "About {count} {kind}": "{kind}: aproximadamente {count}", "At least {count} {kind}": "{kind}: al menos {count}", "{count} {kind}": "{kind}: {count}", "In {area}": "En {area}", @@ -3081,12 +3081,12 @@ "Searching OpenStreetMap": "Buscando en OpenStreetMap", "voice area|Area": "Área", "buffer sized to the place": "zona aproximada del tamaño del lugar", - "enclosing mapped area": "zona cartografiada que lo rodea", + "enclosing mapped area": "zona mapeada que contiene el punto de interés", "the map was cleared while looking it up": "el mapa se borró durante la búsqueda", - "OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar", + "OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar", "the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días", "there are more": "hay más", - "{radius} m around the landmark": "{radius} m alrededor del lugar", + "{radius} m around the landmark": "{radius} m alrededor del punto de interés", "{count} parts": { "many": "Partes: {count}", "one": "Partes: {count}", @@ -3103,7 +3103,7 @@ "other": "Duplicados fusionados: {count}" }, "listed the first {limit}": "se listaron los primeros {limit}", - "counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno", + "counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno", "Search cancelled": "Búsqueda cancelada", "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 108e56f36..c69a95350 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2977,18 +2977,18 @@ "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Malformed cyclone snapshot": "Instantané cyclonique mal formé", "Cyclone request timed out": "Délai dépassé pour la requête cyclonique", - "{lower} to {upper}": "{lower} à {upper}", - "Up to {upper}": "Jusqu’à {upper}", + "{lower} to {upper}": "De {lower} à {upper}", + "Up to {upper}": "Jusqu'à {upper}", "From {lower}": "À partir de {lower}", - "Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol", - "Unnamed zone": "Zone sans nom", - "Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible", + "Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols", + "Unnamed zone": "Sans nom", + "Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible", "Prohibited (P)": "Zone interdite (P)", "Restricted (R)": "Zone réglementée (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).", "Prohibited": "Zone interdite", "Restricted": "Zone réglementée", - "Prohibited & Restricted Airspace": "Zones interdites et réglementées", + "Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés", "scene|PLAY": "LIRE", "AUTHORING": "CRÉATION", "Create and edit scenes": "Créer et modifier des scènes", @@ -3019,7 +3019,7 @@ "fire confidence filter|All": "Toutes", "fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|High": "Élevée", - "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible", + "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible", "Nominal and high confidence detections": "Détections de confiance nominale et élevée", "High confidence detections only": "Détections de confiance élevée uniquement", "Fire intensity (FRP)": "Intensité (FRP)", @@ -3028,7 +3028,7 @@ "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", - "{shown} of {total} shown": "{shown} sur {total} affichées", + "{shown} of {total} shown": "Affichés : {shown} sur {total}", "Name of {name}": "Nom de {name}", "Change the colour of {name}": "Changer la couleur de {name}", "Colour of {name}": "Couleur de {name}", @@ -3038,7 +3038,7 @@ "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", - "Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)", + "Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)", "OSM Places": "Lieux OSM", "state or province": "État ou province", "county or district": "comté ou district", @@ -3046,9 +3046,9 @@ "natural region": "région naturelle", "Which {place}?": "Quel lieu nommé {place} ?", "Which place?": "Quel lieu ?", - "Not drawn: {parts}": "Non dessiné : {parts}", + "Not drawn: {parts}": "Contour non tracé : {parts}", "approximate": "approximatif", - "{pct}% cloud": "{pct} % de nuages", + "{pct}% cloud": "{pct} % de nuages", "Over {area}": "Zone : {area}", "{count} of {total} acquisitions match": { "many": "Acquisitions correspondantes : {count} sur {total}", @@ -3067,43 +3067,43 @@ "{count} {kind}": "{kind} : {count}", "In {area}": "Zone : {area}", "Area around {place}": "Environs : {place}", - "Area around landmark": "Environs d'un repère", + "Area around landmark": "Environs d'un lieu", "Outline {place}": "Délimiter {place}", "Outline area": "Délimiter une zone", "Find {place}": "Trouver {place}", "Find area": "Trouver une zone", "Find recent imagery": "Trouver des images récentes", - "Find {what}": "Trouver {what}", + "Find {what}": "Trouver : {what}", "Find places": "Trouver des lieux", "Drawing outline": "Dessin du contour", "Searching imagery catalog": "Recherche dans le catalogue d'images", "Loading imagery": "Chargement des images", "Searching OpenStreetMap": "Recherche dans OpenStreetMap", "voice area|Area": "Zone", - "buffer sized to the place": "zone tampon à la taille du lieu", + "buffer sized to the place": "zone tampon adaptée à la taille du lieu", "enclosing mapped area": "zone cartographiée englobante", "the map was cleared while looking it up": "la carte a été effacée pendant la recherche", - "OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux", + "OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux", "the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours", "there are more": "il y en a d'autres", "{radius} m around the landmark": "{radius} m autour du lieu", "{count} parts": { - "many": "Parties : {count}", - "one": "Parties : {count}", - "other": "Parties : {count}" + "many": "Parties : {count}", + "one": "Parties : {count}", + "other": "Parties : {count}" }, "{drawn} of {total} parts drawn": { - "many": "Parties tracées : {drawn} sur {total}", - "one": "Parties tracées : {drawn} sur {total}", - "other": "Parties tracées : {drawn} sur {total}" + "many": "Parties tracées : {drawn} sur {total}", + "one": "Parties tracées : {drawn} sur {total}", + "other": "Parties tracées : {drawn} sur {total}" }, "{count} duplicate mappings merged": { - "many": "Doublons fusionnés : {count}", - "one": "Doublons fusionnés : {count}", - "other": "Doublons fusionnés : {count}" + "many": "Doublons fusionnés : {count}", + "one": "Doublons fusionnés : {count}", + "other": "Doublons fusionnés : {count}" }, - "listed the first {limit}": "les {limit} premiers sont listés", - "counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour", + "listed the first {limit}": "liste limitée à {limit}", + "counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact", "Search cancelled": "Recherche annulée", "Sea Marks": "Balisage maritime", "Ski Pistes": "Pistes de ski", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..2c0b38a26 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2977,18 +2977,18 @@ "Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.", "Malformed cyclone snapshot": "Snapshot dei cicloni non valido", "Cyclone request timed out": "La richiesta dei cicloni è andata in timeout", - "{lower} to {upper}": "da {lower} a {upper}", + "{lower} to {upper}": "Da {lower} a {upper}", "Up to {upper}": "Fino a {upper}", "From {lower}": "Da {lower}", "Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo", - "Unnamed zone": "Zona senza nome", + "Unnamed zone": "Senza nome", "Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile", - "Prohibited (P)": "Zona proibita (P)", + "Prohibited (P)": "Zona vietata (P)", "Restricted (R)": "Zona regolamentata (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).", - "Prohibited": "Zona proibita", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).", + "Prohibited": "Zona vietata", "Restricted": "Zona regolamentata", - "Prohibited & Restricted Airspace": "Zone proibite e regolamentate", + "Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato", "scene|PLAY": "AVVIA", "AUTHORING": "CREAZIONE", "Create and edit scenes": "Crea e modifica le scene", @@ -3019,7 +3019,7 @@ "fire confidence filter|All": "Tutti", "fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|High": "Alta", - "Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa", + "Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa", "Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta", "High confidence detections only": "Solo rilevamenti con confidenza alta", "Fire intensity (FRP)": "Intensità (FRP)", @@ -3028,7 +3028,7 @@ "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", - "{shown} of {total} shown": "{shown} di {total} mostrati", + "{shown} of {total} shown": "Mostrati: {shown} su {total}", "Name of {name}": "Nome di {name}", "Change the colour of {name}": "Cambia il colore di {name}", "Colour of {name}": "Colore di {name}", @@ -3046,10 +3046,10 @@ "natural region": "regione naturale", "Which {place}?": "Quale {place}?", "Which place?": "Quale luogo?", - "Not drawn: {parts}": "Non disegnati: {parts}", - "approximate": "approssimativo", + "Not drawn: {parts}": "Contorno non disegnato: {parts}", + "approximate": "approssimata", "{pct}% cloud": "{pct}% di nuvole", - "Over {area}": "Zona: {area}", + "Over {area}": "Area: {area}", "{count} of {total} acquisitions match": { "many": "Acquisizioni corrispondenti: {count} su {total}", "one": "Acquisizioni corrispondenti: {count} su {total}", @@ -3068,8 +3068,8 @@ "In {area}": "Area: {area}", "Area around {place}": "Area intorno a {place}", "Area around landmark": "Area intorno a un punto di interesse", - "Outline {place}": "Traccia contorno di {place}", - "Outline area": "Traccia contorno area", + "Outline {place}": "Traccia il contorno di {place}", + "Outline area": "Traccia il contorno dell'area", "Find {place}": "Trova {place}", "Find area": "Trova area", "Find recent imagery": "Trova immagini recenti", @@ -3077,16 +3077,16 @@ "Find places": "Trova luoghi", "Drawing outline": "Disegno del contorno", "Searching imagery catalog": "Ricerca nel catalogo immagini", - "Loading imagery": "Caricamento immagini", + "Loading imagery": "Caricamento delle immagini", "Searching OpenStreetMap": "Ricerca in OpenStreetMap", "voice area|Area": "Area", "buffer sized to the place": "zona tampone delle dimensioni del luogo", - "enclosing mapped area": "area mappata circostante", - "the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca", - "OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo", + "enclosing mapped area": "area mappata che contiene il punto di interesse", + "the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca", + "OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo", "the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni", "there are more": "ce ne sono altri", - "{radius} m around the landmark": "{radius} m attorno al luogo", + "{radius} m around the landmark": "{radius} m intorno al punto di interesse", "{count} parts": { "many": "Parti: {count}", "one": "Parti: {count}", @@ -3102,8 +3102,8 @@ "one": "Duplicati uniti: {count}", "other": "Duplicati uniti: {count}" }, - "listed the first {limit}": "elencati i primi {limit}", - "counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno", + "listed the first {limit}": "elenco limitato ai primi risultati: {limit}", + "counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno", "Search cancelled": "Ricerca annullata", "Sea Marks": "Segnalamenti marittimi", "Ski Pistes": "Piste da sci", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..26f634f94 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2837,18 +2837,18 @@ "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", - "{lower} to {upper}": "{lower}~{upper}", + "{lower} to {upper}": "{lower}–{upper}", "Up to {upper}": "{upper}まで", "From {lower}": "{lower}から", "Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください", - "Unnamed zone": "名称なしのゾーン", + "Unnamed zone": "名称不明の区域", "Restricted airspace source unavailable": "制限空域のデータソースを利用できません", - "Prohibited (P)": "飛行禁止区域 (P)", - "Restricted (R)": "制限区域 (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。", + "Prohibited (P)": "飛行禁止区域(P)", + "Restricted (R)": "飛行制限区域(R)", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。", "Prohibited": "飛行禁止区域", - "Restricted": "制限区域", - "Prohibited & Restricted Airspace": "飛行禁止区域と制限区域", + "Restricted": "飛行制限区域", + "Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域", "scene|PLAY": "再生", "AUTHORING": "作成", "Create and edit scenes": "シーンを作成・編集", @@ -2879,10 +2879,10 @@ "fire confidence filter|All": "すべて", "fire confidence filter|Nominal+": "中以上", "fire confidence filter|High": "高", - "Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています", - "Nominal and high confidence detections": "信頼度が中・高の検知", - "High confidence detections only": "信頼度が高の検知のみ", - "Fire intensity (FRP)": "強度(FRP)", + "Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています", + "Nominal and high confidence detections": "信頼度「中」以上の検知", + "High confidence detections only": "信頼度「高」の検知のみ", + "Fire intensity (FRP)": "火災強度(FRP)", "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", @@ -2900,16 +2900,16 @@ "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)", "OSM Places": "OSMの場所", - "state or province": "州・省", + "state or province": "州・県", "county or district": "郡・地区", "neighbourhood": "近隣地区", "natural region": "自然地域", - "Which {place}?": "どの{place}ですか?", + "Which {place}?": "どの「{place}」ですか?", "Which place?": "どの場所ですか?", "Not drawn: {parts}": "未描画:{parts}", - "approximate": "概算", - "{pct}% cloud": "雲量{pct}%", - "Over {area}": "{area}上空", + "approximate": "近似", + "{pct}% cloud": "雲量 {pct}%", + "Over {area}": "範囲:{area}", "{count} of {total} acquisitions match": { "other": "一致した画像:{count}/{total}件" }, @@ -2918,45 +2918,45 @@ }, "No matching imagery": "一致する画像はありません", "Unknown number of {kind}": "{kind}:件数不明", - "About {count} {kind}": "{kind}:約{count}件", - "At least {count} {kind}": "{kind}:{count}件以上", - "{count} {kind}": "{kind}:{count}件", + "About {count} {kind}": "{kind}:約{count}か所", + "At least {count} {kind}": "{kind}:{count}か所以上", + "{count} {kind}": "{kind}:{count}か所", "In {area}": "{area}内", "Area around {place}": "{place}周辺のエリア", "Area around landmark": "ランドマーク周辺のエリア", - "Outline {place}": "{place}の輪郭を描く", - "Outline area": "エリアの輪郭を描く", + "Outline {place}": "{place}の輪郭を描画", + "Outline area": "エリアの輪郭を描画", "Find {place}": "{place}を検索", "Find area": "エリアを検索", "Find recent imagery": "最近の衛星画像を検索", "Find {what}": "{what}を検索", "Find places": "場所を検索", - "Drawing outline": "輪郭を作図中", + "Drawing outline": "輪郭を地図に描画中", "Searching imagery catalog": "画像カタログを検索中", "Loading imagery": "画像を読み込み中", "Searching OpenStreetMap": "OpenStreetMapを検索中", "voice area|Area": "エリア", - "buffer sized to the place": "場所の大きさに合わせたバッファ", - "enclosing mapped area": "周囲の地図上のエリア", - "the map was cleared while looking it up": "検索中に地図がクリアされました", - "OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります", - "the catalog covers the last 30 days": "カタログは過去 30 日分です", + "buffer sized to the place": "場所の大きさに合わせたバッファー", + "enclosing mapped area": "ランドマークを含むマップ済みのエリア", + "the map was cleared while looking it up": "検索中に地図が消去されました", + "OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります", + "the catalog covers the last 30 days": "カタログの対象は過去30日間です", "there are more": "ほかにもあります", - "{radius} m around the landmark": "ランドマークの周囲 {radius} m", + "{radius} m around the landmark": "ランドマークから半径{radius} m", "{count} parts": { - "other": "パーツ数: {count}" + "other": "パーツ数:{count}" }, "{drawn} of {total} parts drawn": { - "other": "描画したパーツ: {drawn}/{total}" + "other": "描画したパーツ:{drawn}/{total}" }, "{count} duplicate mappings merged": { - "other": "統合した重複: {count}" + "other": "重複登録{count}件を統合" }, - "listed the first {limit}": "最初の {limit} 件を表示", - "counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません", + "listed the first {limit}": "最初の{limit}件を表示", + "counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています", "Search cancelled": "検索をキャンセルしました", "Sea Marks": "航路標識", - "Ski Pistes": "スキーゲレンデ", + "Ski Pistes": "スキーコース", "Fly to Tokyo": "東京へ移動して", "Show live flights": "ライブフライトを表示して", "Reset to globe view": "地球全体ビューに戻して", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..3639d818d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2981,14 +2981,14 @@ "Up to {upper}": "Até {upper}", "From {lower}": "A partir de {lower}", "Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo", - "Unnamed zone": "Zona sem nome", + "Unnamed zone": "Sem nome", "Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível", "Prohibited (P)": "Área proibida (P)", "Restricted (R)": "Área restrita (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).", "Prohibited": "Área proibida", "Restricted": "Área restrita", - "Prohibited & Restricted Airspace": "Áreas proibidas e restritas", + "Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito", "scene|PLAY": "INICIAR", "AUTHORING": "CRIAÇÃO", "Create and edit scenes": "Criar e editar cenas", @@ -3028,7 +3028,7 @@ "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", - "{shown} of {total} shown": "{shown} de {total} exibidas", + "{shown} of {total} shown": "Detecções exibidas: {shown} de {total}", "Name of {name}": "Nome de {name}", "Change the colour of {name}": "Mudar a cor de {name}", "Colour of {name}": "Cor de {name}", @@ -3047,18 +3047,18 @@ "Which {place}?": "Qual {place}?", "Which place?": "Qual lugar?", "Not drawn: {parts}": "Não desenhado: {parts}", - "approximate": "aproximado", + "approximate": "aproximada", "{pct}% cloud": "{pct}% de nuvens", "Over {area}": "Área: {area}", "{count} of {total} acquisitions match": { - "many": "Aquisições correspondentes: {count} de {total}", - "one": "Aquisições correspondentes: {count} de {total}", - "other": "Aquisições correspondentes: {count} de {total}" + "one": "Imagens correspondentes: {count} de {total}", + "many": "Imagens correspondentes: {count} de {total}", + "other": "Imagens correspondentes: {count} de {total}" }, "{n} acquisitions searched": { - "many": "Aquisições pesquisadas: {n}", - "one": "Aquisições pesquisadas: {n}", - "other": "Aquisições pesquisadas: {n}" + "one": "Imagens pesquisadas: {n}", + "many": "Imagens pesquisadas: {n}", + "other": "Imagens pesquisadas: {n}" }, "No matching imagery": "Nenhuma imagem correspondente", "Unknown number of {kind}": "{kind}: número desconhecido", @@ -3070,23 +3070,23 @@ "Area around landmark": "Área ao redor de um ponto de referência", "Outline {place}": "Traçar contorno de {place}", "Outline area": "Traçar contorno da área", - "Find {place}": "Encontrar {place}", - "Find area": "Encontrar área", - "Find recent imagery": "Encontrar imagens recentes", - "Find {what}": "Encontrar {what}", - "Find places": "Encontrar lugares", + "Find {place}": "Buscar {place}", + "Find area": "Buscar área", + "Find recent imagery": "Buscar imagens recentes", + "Find {what}": "Buscar {what}", + "Find places": "Buscar lugares", "Drawing outline": "Desenhando contorno", "Searching imagery catalog": "Buscando no catálogo de imagens", "Loading imagery": "Carregando imagens", "Searching OpenStreetMap": "Buscando no OpenStreetMap", "voice area|Area": "Área", - "buffer sized to the place": "zona de margem do tamanho do local", - "enclosing mapped area": "área mapeada envolvente", + "buffer sized to the place": "círculo do tamanho aproximado do lugar", + "enclosing mapped area": "área mapeada que contém o ponto de referência", "the map was cleared while looking it up": "o mapa foi limpo durante a busca", "OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar", "the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias", "there are more": "há mais", - "{radius} m around the landmark": "{radius} m ao redor do local", + "{radius} m around the landmark": "{radius} m ao redor do ponto de referência", "{count} parts": { "many": "Partes: {count}", "one": "Partes: {count}", @@ -3102,10 +3102,10 @@ "one": "Duplicatas mescladas: {count}", "other": "Duplicatas mescladas: {count}" }, - "listed the first {limit}": "listados os primeiros {limit}", - "counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno", + "listed the first {limit}": "lista limitada a {limit}", + "counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno", "Search cancelled": "Busca cancelada", - "Sea Marks": "Sinais marítimos", + "Sea Marks": "Sinalização náutica", "Ski Pistes": "Pistas de esqui", "Fly to Tokyo": "Voe para Tóquio", "Show live flights": "Mostre os voos ao vivo", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..028549f1c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -128,7 +128,7 @@ "SRC {value}": "ИСТ. {value}", "{value} CONTEXT": "КОНТЕКСТ {value}", "Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}", - "{value} conf": "увер.: {value}", + "{value} conf": "дост.: {value}", "{value} ago": "{value} назад", "sensor n/a": "сенсор н/д", "FIRE · {frp} MW": "ПОЖАР · {frp} МВт", @@ -3056,27 +3056,27 @@ "fire confidence filter|All": "Все", "fire confidence filter|Nominal+": "Средняя+", "fire confidence filter|High": "Высокая", - "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", - "Nominal and high confidence detections": "Детекции средней и высокой достоверности", - "High confidence detections only": "Только детекции высокой достоверности", + "Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность", + "Nominal and high confidence detections": "Очаги средней и высокой достоверности", + "High confidence detections only": "Только очаги высокой достоверности", "Fire intensity (FRP)": "Интенсивность (FRP)", "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", "fire intensity filter|All": "Все", - "Detection confidence": "Достоверность детекции", - "{shown} of {total} shown": "показано {shown} из {total}", - "{lower} to {upper}": "от {lower} до {upper}", + "Detection confidence": "Достоверность обнаружения", + "{shown} of {total} shown": "Показано {shown} из {total}", + "{lower} to {upper}": "От {lower} до {upper}", "Up to {upper}": "До {upper}", "From {lower}": "От {lower}", "Informational only · not for flight planning": "Только для информации · не для планирования полётов", - "Unnamed zone": "Зона без названия", - "Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен", + "Unnamed zone": "Без названия", + "Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен", "Prohibited (P)": "Запретная зона (P)", - "Restricted (R)": "Зона ограничения (R)", + "Restricted (R)": "Зона ограничения полётов (R)", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).", "Prohibited": "Запретная зона", - "Restricted": "Зона ограничения", + "Restricted": "Зона ограничения полётов", "Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения", "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", "route proxy error": "ошибка прокси маршрутов", @@ -3110,13 +3110,13 @@ "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)", "OSM Places": "Места OSM", - "state or province": "штат или провинция", + "state or province": "штат, провинция или область", "county or district": "округ или район", "neighbourhood": "район города", "natural region": "природный регион", "Which {place}?": "Какой из вариантов «{place}»?", "Which place?": "Какое место?", - "Not drawn: {parts}": "Не нарисовано: {parts}", + "Not drawn: {parts}": "Контур не нарисован: {parts}", "approximate": "приблизительно", "{pct}% cloud": "Облачность {pct}%", "Over {area}": "Область: {area}", @@ -3137,7 +3137,7 @@ "About {count} {kind}": "{kind}: около {count}", "At least {count} {kind}": "{kind}: не менее {count}", "{count} {kind}": "{kind}: {count}", - "In {area}": "Территория: {area}", + "In {area}": "Область: {area}", "Area around {place}": "Окрестности: {place}", "Area around landmark": "Окрестности ориентира", "Outline {place}": "Контур: {place}", @@ -3153,12 +3153,12 @@ "Searching OpenStreetMap": "Поиск в OpenStreetMap", "voice area|Area": "Область", "buffer sized to the place": "буферная зона по размеру места", - "enclosing mapped area": "охватывающая область на карте", + "enclosing mapped area": "область на карте, включающая ориентир", "the map was cleared while looking it up": "карта была очищена во время поиска", "OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах", "the catalog covers the last 30 days": "каталог охватывает последние 30 дней", - "there are more": "есть ещё", - "{radius} m around the landmark": "{radius} м вокруг объекта", + "there are more": "есть и другие", + "{radius} m around the landmark": "{radius} м вокруг ориентира", "{count} parts": { "few": "Частей: {count}", "many": "Частей: {count}", @@ -3177,11 +3177,11 @@ "one": "Объединено дублей: {count}", "other": "Объединено дублей: {count}" }, - "listed the first {limit}": "показаны первые {limit}", + "listed the first {limit}": "показаны первые результаты: {limit}", "counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру", "Search cancelled": "Поиск отменён", - "Sea Marks": "Морские знаки", - "Ski Pistes": "Горнолыжные трассы", + "Sea Marks": "Навигационные знаки", + "Ski Pistes": "Лыжные трассы", "Fly to Tokyo": "Перелети в Токио", "Show live flights": "Покажи рейсы в реальном времени", "Reset to globe view": "Вернись к виду всего глобуса", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..9da54e06e 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3057,19 +3057,19 @@ "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", - "{shown} of {total} shown": "показано {shown} з {total}", - "{lower} to {upper}": "від {lower} до {upper}", + "{shown} of {total} shown": "Показано {shown} з {total}", + "{lower} to {upper}": "Від {lower} до {upper}", "Up to {upper}": "До {upper}", "From {lower}": "Від {lower}", "Informational only · not for flight planning": "Лише для довідки · не для планування польотів", - "Unnamed zone": "Зона без назви", - "Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне", + "Unnamed zone": "Без назви", + "Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне", "Prohibited (P)": "Заборонена зона (P)", "Restricted (R)": "Зона обмеження (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).", "Prohibited": "Заборонена зона", "Restricted": "Зона обмеження", - "Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження", + "Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів", "Malformed cyclone snapshot": "Некоректний знімок циклонів", "Cyclone request timed out": "Минув час очікування запиту циклонів", "scene|PLAY": "СТАРТ", @@ -3110,7 +3110,7 @@ "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)", "OSM Places": "Місця OSM", - "state or province": "штат або провінція", + "state or province": "область, штат або провінція", "county or district": "округ або район", "neighbourhood": "район міста", "natural region": "природний регіон", @@ -3141,9 +3141,9 @@ "Area around {place}": "Околиці: {place}", "Area around landmark": "Околиці орієнтира", "Outline {place}": "Контур: {place}", - "Outline area": "Контур області", + "Outline area": "Контур території", "Find {place}": "Пошук: {place}", - "Find area": "Знайти область", + "Find area": "Знайти територію", "Find recent imagery": "Знайти свіжі знімки", "Find {what}": "Пошук: {what}", "Find places": "Знайти місця", @@ -3151,14 +3151,14 @@ "Searching imagery catalog": "Пошук у каталозі знімків", "Loading imagery": "Завантаження знімків", "Searching OpenStreetMap": "Пошук в OpenStreetMap", - "voice area|Area": "Область", + "voice area|Area": "Територія", "buffer sized to the place": "буферна зона за розміром місця", - "enclosing mapped area": "охопна область на карті", + "enclosing mapped area": "нанесений контур, що охоплює орієнтир", "the map was cleared while looking it up": "карту було очищено під час пошуку", - "OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця", + "OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця", "the catalog covers the last 30 days": "каталог охоплює останні 30 днів", - "there are more": "є ще", - "{radius} m around the landmark": "{radius} м навколо об’єкта", + "there are more": "є й інші", + "{radius} m around the landmark": "{radius} м навколо орієнтира", "{count} parts": { "few": "Частин: {count}", "many": "Частин: {count}", @@ -3172,16 +3172,16 @@ "other": "Намальовано частин: {drawn} з {total}" }, "{count} duplicate mappings merged": { - "few": "Об’єднано дублікатів: {count}", - "many": "Об’єднано дублікатів: {count}", - "one": "Об’єднано дублікатів: {count}", - "other": "Об’єднано дублікатів: {count}" + "one": "Обʼєднано дублікатів: {count}", + "few": "Обʼєднано дублікатів: {count}", + "many": "Обʼєднано дублікатів: {count}", + "other": "Обʼєднано дублікатів: {count}" }, - "listed the first {limit}": "показано перші {limit}", + "listed the first {limit}": "показано перші результати: {limit}", "counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром", "Search cancelled": "Пошук скасовано", - "Sea Marks": "Морські знаки", - "Ski Pistes": "Гірськолижні траси", + "Sea Marks": "Навігаційні знаки", + "Ski Pistes": "Лижні траси", "Fly to Tokyo": "Полети до Токіо", "Show live flights": "Покажи рейси онлайн", "Reset to globe view": "Поверни вигляд глобуса", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..3d9c140c0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2844,26 +2844,26 @@ }, "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", "fire confidence filter|All": "全部", - "fire confidence filter|Nominal+": "标称+", + "fire confidence filter|Nominal+": "中+", "fire confidence filter|High": "高", - "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", - "Nominal and high confidence detections": "标称和高置信度的检测", - "High confidence detections only": "仅高置信度的检测", - "Fire intensity (FRP)": "火势强度 (FRP)", + "Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度", + "Nominal and high confidence detections": "中、高置信度的火点探测", + "High confidence detections only": "仅高置信度的火点探测", + "Fire intensity (FRP)": "火势强度(FRP)", "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "检测置信度", + "Detection confidence": "探测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", "{lower} to {upper}": "{lower} 至 {upper}", "Up to {upper}": "最高 {upper}", - "From {lower}": "自 {lower} 起", + "From {lower}": "最低 {lower}", "Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划", - "Unnamed zone": "未命名区域", - "Restricted airspace source unavailable": "受限空域数据源不可用", - "Prohibited (P)": "禁区 (P)", - "Restricted (R)": "限制区 (R)", + "Unnamed zone": "未命名空域", + "Restricted airspace source unavailable": "禁区与限制区空域数据源不可用", + "Prohibited (P)": "禁区(P)", + "Restricted (R)": "限制区(R)", "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。", "Prohibited": "禁区", "Restricted": "限制区", @@ -2909,7 +2909,7 @@ "Not drawn: {parts}": "未绘制:{parts}", "approximate": "近似", "{pct}% cloud": "云量 {pct}%", - "Over {area}": "{area} 上空", + "Over {area}": "覆盖 {area}", "{count} of {total} acquisitions match": { "other": "匹配影像:{count}/{total}" }, @@ -2924,36 +2924,36 @@ "In {area}": "位于 {area}", "Area around {place}": "{place} 周边区域", "Area around landmark": "地标周边区域", - "Outline {place}": "勾勒 {place} 轮廓", - "Outline area": "勾勒区域轮廓", + "Outline {place}": "绘制 {place} 轮廓", + "Outline area": "绘制区域轮廓", "Find {place}": "查找 {place}", "Find area": "查找区域", "Find recent imagery": "查找近期影像", "Find {what}": "查找 {what}", "Find places": "查找地点", - "Drawing outline": "正在勾勒轮廓", + "Drawing outline": "正在地图上绘制轮廓", "Searching imagery catalog": "正在搜索影像目录", "Loading imagery": "正在加载影像", "Searching OpenStreetMap": "正在搜索 OpenStreetMap", "voice area|Area": "区域", "buffer sized to the place": "按地点大小设置的缓冲区", - "enclosing mapped area": "包含该地点的地图区域", + "enclosing mapped area": "包含该地标的地图标注区域", "the map was cleared while looking it up": "查找期间地图已清空", "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异", "the catalog covers the last 30 days": "目录涵盖最近 30 天", "there are more": "还有更多", - "{radius} m around the landmark": "地标周围 {radius} 米", + "{radius} m around the landmark": "地标周围 {radius} m", "{count} parts": { - "other": "部分数:{count}" + "other": "{count} 个部分" }, "{drawn} of {total} parts drawn": { - "other": "已绘制部分:{drawn}/{total}" + "other": "已绘制 {drawn}/{total} 个部分" }, "{count} duplicate mappings merged": { "other": "已合并重复项:{count}" }, "listed the first {limit}": "已列出前 {limit} 个", - "counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓", + "counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓", "Search cancelled": "搜索已取消", "Sea Marks": "航标", "Ski Pistes": "滑雪道", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..7223c384c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2847,16 +2847,16 @@ "route proxy error": "路線代理出錯", "{lower} to {upper}": "{lower} 至 {upper}", "Up to {upper}": "最高 {upper}", - "From {lower}": "自 {lower} 起", + "From {lower}": "最低 {lower}", "Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃", - "Unnamed zone": "未命名區域", - "Restricted airspace source unavailable": "限制空域資料來源無法使用", - "Prohibited (P)": "禁航區 (P)", - "Restricted (R)": "限航區 (R)", - "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。", - "Prohibited": "禁航區", - "Restricted": "限航區", - "Prohibited & Restricted Airspace": "禁航區與限航區空域", + "Unnamed zone": "未命名空域", + "Restricted airspace source unavailable": "禁區與限制區空域數據源不可用", + "Prohibited (P)": "禁區(P)", + "Restricted (R)": "限制區(R)", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。", + "Prohibited": "禁區", + "Restricted": "限制區", + "Prohibited & Restricted Airspace": "禁區與限制區空域", "AUTHORING": "創作", "Create and edit scenes": "創建和編輯場景", "SCENE": "場景", @@ -2883,17 +2883,17 @@ "Left panels": "左側面板", "Right panels": "右側面板", "fire confidence filter|All": "全部", - "fire confidence filter|Nominal+": "標稱+", + "fire confidence filter|Nominal+": "中+", "fire confidence filter|High": "高", - "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", - "Nominal and high confidence detections": "標稱和高置信度的檢測", - "High confidence detections only": "僅高置信度的檢測", - "Fire intensity (FRP)": "火勢強度 (FRP)", + "Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度", + "Nominal and high confidence detections": "中、高置信度的火點探測", + "High confidence detections only": "僅高置信度的火點探測", + "Fire intensity (FRP)": "火勢強度(FRP)", "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "檢測置信度", + "Detection confidence": "探測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", "UTILITIES": "工具", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", @@ -2909,7 +2909,7 @@ "Not drawn: {parts}": "未繪製:{parts}", "approximate": "近似", "{pct}% cloud": "雲量 {pct}%", - "Over {area}": "{area} 上空", + "Over {area}": "覆蓋 {area}", "{count} of {total} acquisitions match": { "other": "匹配影像:{count}/{total}" }, @@ -2924,36 +2924,36 @@ "In {area}": "位於 {area}", "Area around {place}": "{place} 周邊區域", "Area around landmark": "地標周邊區域", - "Outline {place}": "勾勒 {place} 輪廓", - "Outline area": "勾勒區域輪廓", + "Outline {place}": "繪製 {place} 輪廓", + "Outline area": "繪製區域輪廓", "Find {place}": "查找 {place}", "Find area": "查找區域", "Find recent imagery": "查找近期影像", "Find {what}": "查找 {what}", "Find places": "查找地點", - "Drawing outline": "正在勾勒輪廓", + "Drawing outline": "正在地圖上繪製輪廓", "Searching imagery catalog": "正在搜索影像目錄", "Loading imagery": "正在加載影像", "Searching OpenStreetMap": "正在搜索 OpenStreetMap", "voice area|Area": "區域", "buffer sized to the place": "按地點大小設置的緩衝區", - "enclosing mapped area": "包含該地點的地圖區域", + "enclosing mapped area": "包含該地標的地圖標註區域", "the map was cleared while looking it up": "查找期間地圖已清空", "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異", "the catalog covers the last 30 days": "目錄涵蓋最近 30 天", "there are more": "還有更多", - "{radius} m around the landmark": "地標周圍 {radius} 米", + "{radius} m around the landmark": "地標周圍 {radius} m", "{count} parts": { - "other": "部分數:{count}" + "other": "{count} 個部分" }, "{drawn} of {total} parts drawn": { - "other": "已繪製部分:{drawn}/{total}" + "other": "已繪製 {drawn}/{total} 個部分" }, "{count} duplicate mappings merged": { "other": "已合併重複項:{count}" }, "listed the first {limit}": "已列出前 {limit} 個", - "counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓", + "counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓", "Search cancelled": "搜索已取消", "Sea Marks": "航標", "Ski Pistes": "滑雪道", diff --git a/src/voice/voiceCardPlanSteps.i18n.test.mjs b/src/voice/voiceCardPlanSteps.i18n.test.mjs index 4ac15394c..1cd8d9d65 100644 --- a/src/voice/voiceCardPlanSteps.i18n.test.mjs +++ b/src/voice/voiceCardPlanSteps.i18n.test.mjs @@ -295,10 +295,10 @@ test('area, imagery and OSM card lines are shown in the UI language', () => { notDrawn: 'the map was cleared while looking it up', }; const areaLines = inLocale('de', () => presentResult('resolve_area', area).display.lines); - assert.ok(areaLines.includes('250 m um das Wahrzeichen'), areaLines.join(' | ')); - assert.ok(areaLines.includes('Teile: 3')); - assert.ok(areaLines.includes('Gezeichnete Teile: 2 von 3')); - assert.ok(areaLines.some((line) => line.includes('die Karte wurde während der Suche geleert'))); + assert.ok(areaLines.includes('250 m um den Ort'), areaLines.join(' | ')); + assert.ok(areaLines.includes('3 Teile')); + assert.ok(areaLines.includes('2 von 3 Teilen gezeichnet')); + assert.ok(areaLines.some((line) => line.includes('die Zeichenfläche wurde während der Suche geleert'))); const osm = { ok: true, kind: 'hospitals', @@ -316,7 +316,7 @@ test('area, imagery and OSM card lines are shown in the UI language', () => { assert.match(osmCard.title, /\bhospital\b/); assert.doesNotMatch(osmCard.title, /hospitals/, 'one place takes the singular'); assert.equal(presentResult('osm_query', { ...osm, complete: true }).display.title, '1 hospital'); - assert.ok(osmCard.lines.includes('Zusammengeführte Doppelkartierungen: 2')); + assert.ok(osmCard.lines.includes('2 Duplikate zusammengeführt')); assert.deepEqual(osmCard.notes, ['die ersten 500 aufgelistet', 'es gibt weitere']); const imagery = { ok: true, From 5c04b121648873ccd37da6236685f4b2d59444a4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:43:49 +0400 Subject: [PATCH 046/174] Roll back a Space Missions entry whose mission index fails to load The launches layer swallowed every fetch error in its update, so the manager treated a 429 or timeout on /api/launches as a successful start. Context then committed the isolating mode with no missions: the other layers stayed off, unrelated enables were refused, the camera stayed at the full-globe view and the roster kept reading "loading". Until the first index loads, the update now reports the failure. The manager fails the enable closed and both entry routes roll back to the pre-entry layers. The camera pose captured at entry is restored when the mode is left before any index loaded, and the roster shows a failed state instead of loading. A valid empty index still enters, and a refresh failure after a loaded index keeps its records. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/launches/failedEntry.test.mjs | 348 +++++++++++++++++++++++ src/layers/launches/ingestion.js | 19 +- src/layers/launches/lifecycle.js | 34 +++ src/layers/launches/panel.js | 13 +- src/layers/launches/state.js | 2 + 18 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 src/layers/launches/failedEntry.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..afa8714ec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5169,7 +5169,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..f316bc72e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -474,6 +474,7 @@ "src/layers/satellites/stale.test.mjs", "src/layers/launches/source.test.mjs", "src/layers/launches/ingestion.test.mjs", + "src/layers/launches/failedEntry.test.mjs", "src/layers/installations/lifecycle.js", "src/layers/installations/viewport.js", "src/layers/installations/controls.js", diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..a96fccc7d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -301,6 +301,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", + "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..72796858c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -760,6 +760,7 @@ "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "Internet radio and local SDR": "Internetradio und lokales SDR", "STATION TAG": "KATEGORIE", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..7341841d5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -192,6 +192,7 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE", "RADIO": "RADIO", "ENABLE": "ACTIVAR", "Previous filtered radio station": "Emisora de radio filtrada anterior", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..d27bdd2eb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2799,6 +2799,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", "STATION TAG": "TAG DE STATION", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..431360c8e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2799,6 +2799,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", "STATION TAG": "TAG STAZIONE", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..0bd3c47ef 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2657,6 +2657,7 @@ "AVAILABLE MISSIONS": "利用可能なミッション", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", + "30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "Internet radio and local SDR": "インターネットラジオとローカルSDR", "STATION TAG": "局タグ", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..31ba6ba2e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2801,6 +2801,7 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", "STATION TAG": "TAG DA ESTAÇÃO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..c46f3375c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -722,6 +722,7 @@ "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН", "Radio playback": "Воспроизведение радио", "Radio volume": "Громкость радио", "STATION TAG": "ТЕГ СТАНЦИИ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..794c65023 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2869,6 +2869,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", + "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "STATION TAG": "ТЕГ СТАНЦІЇ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..41c4718a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2556,6 +2556,7 @@ "AVAILABLE MISSIONS": "可用任务", "SELECT A MISSION TO INSPECT": "选择一项任务以查看", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "Internet radio and local SDR": "网络电台与本地 SDR", "STATION TAG": "电台标签", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..31e16701e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2544,6 +2544,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", "STATION TAG": "電臺標籤", diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs new file mode 100644 index 000000000..f8b760dde --- /dev/null +++ b/src/layers/launches/failedEntry.test.mjs @@ -0,0 +1,348 @@ +// A Space Missions entry whose mission index never loads must not commit the +// mode. The real manager, Context coordinator and launches ingestion run +// against a local /api/launches that answers 429, so the test follows the +// same failure the browser sees: isolation clears the other layers, the first +// update fails, and the entry rolls back to the layers and view it replaced. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import * as Cesium from 'cesium'; +import { DataLayerManager } from '../../data/manager.js'; +import { ContextControls } from '../../ui/contextControls.js'; +import { createLaunchSource } from './source.js'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createPanel } from './panel.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; + +const noop = () => {}; + +const launchRecord = { + id: 'fixture-launch', + name: 'Fixture mission', + net: new Date(Date.now() - 3600000).toISOString(), + status: { name: 'Launch Successful' }, + pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 }, + rocket: { payloads: [] }, +}; + +function plainLayer(id, name, params = {}) { + let current = { ...params }; + return { + id, + name, + icon: '', + source: 'test', + updateInterval: -1, + init: noop, + enable: noop, + disable: noop, + update: noop, + getParams: () => ({ ...current }), + setParams(next) { + current = { ...current, ...next }; + return true; + }, + }; +} + +function fakeCamera() { + const camera = { + position: new Cesium.Cartesian3(1, 2, 3), + heading: 0.1, + pitch: -0.5, + roll: 0, + flights: 0, + cancelled: 0, + get positionWC() { + return camera.position; + }, + flyTo({ destination }) { + camera.flights += 1; + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = 0; + camera.pitch = -Math.PI / 2; + }, + cancelFlight() { + camera.cancelled += 1; + }, + setView({ destination, orientation }) { + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = orientation.heading; + camera.pitch = orientation.pitch; + camera.roll = orientation.roll; + }, + }; + return camera; +} + +async function fixture(t) { + const upstream = { status: 429, results: [launchRecord] }; + const server = http.createServer((req, res) => { + if (req.url === '/api/celestrak/active') { + res.end(''); + return; + } + if (upstream.status !== 200) { + res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ results: upstream.results })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const source = createLaunchSource({ + fetchImpl: (url, options) => fetch(new URL(url, origin), options), + }); + + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + }; + const state = createState({ services }); + const camera = fakeCamera(); + const canvas = Object.assign(new EventTarget(), { + disableRootEvents: true, + onwheel: null, + style: {}, + clientWidth: 1200, + clientHeight: 800, + }); + const viewer = { + camera, + dataSources: new Cesium.DataSourceCollection(), + scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, + }; + // Scene presentation is inert; source, ingestion, lifecycle, panel views + // and the satellites dependency run unchanged. + const parts = { + overlays: { + shortMissionLabel: (name) => name, + createReplayVehicleOverlay: noop, + clearMissionOverlaySources: noop, + hideReplayVehicleOverlay: noop, + destroyReplayVehicleOverlay: noop, + }, + replay: { stopMissionReplay: noop, presentReplay: () => null }, + selection: { + focusFullGlobe: (target) => + target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }), + setSelectedMission: noop, + focusMission: noop, + stopMissionZoomAnchor: noop, + entityLaunchId: () => null, + }, + orbitRendering: { + removeMissionOrbitPrimitives: noop, + syncMissionOrbitPrimitiveVisibility: noop, + }, + launchPad: { + initLaunchPadZonePrimitive: noop, + hideLaunchPadZone: noop, + destroyLaunchPadZonePrimitive: noop, + }, + rendering: { + updateMissionFrame: noop, + addLaunchEntity(record) { + state._dataSource.entities.add({ + id: record.id, + position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat), + }); + }, + }, + }; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.model = createModel(context); + parts.panel = createPanel(context); + parts.ingestion = createIngestion(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + const missions = Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + const rosters = []; + parts.panel.subscribePanel((view) => rosters.push(view.roster)); + + const manager = new DataLayerManager(viewer); + manager.register(plainLayer('earthquakes', 'Earthquakes (24h)')); + manager.register(plainLayer('satellites', 'Satellites')); + manager.register(plainLayer('wind', 'Wind')); + manager.register(missions); + missions.attachDataManager(manager); + + const toasts = []; + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: noop, + showToast: (message) => toasts.push(message), + setClearBusy: noop, + syncDetection: noop, + scheduleLayout: noop, + refreshRadio: noop, + setPanelCollapsed: noop, + }, + }); + controls.connect(manager); + + t.after(async () => { + controls.stop(); + controls.disconnect(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind']) + await manager.setEnabled(id, false); + await missions.destroy(viewer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + + const enabledIds = () => [...manager.getEnabledLayerIds()].sort(); + const view = () => ({ + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + heading: camera.heading, + pitch: camera.pitch, + }); + const settle = async () => { + await controls._waitForContextLayerSettlement(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes']) + await manager.waitForLayerSettled(id); + await controls._waitForContextLayerSettlement(); + }; + return { + upstream, + manager, + controls, + missions, + state, + camera, + rosters, + toasts, + enabledIds, + view, + settle, + }; +} + +test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => { + const f = await fixture(t); + assert.equal( + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }), + true, + ); + const before = f.view(); + + const entered = await f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await f.settle(); + + assert.equal(entered, false, 'a failed mission index fails the enable'); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.equal(f.controls.getContextModeState().active, false); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); + assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); + + // Nothing is left isolating the workspace. + assert.equal( + await f.manager.setEnabled('wind', true, { origin: 'user' }), + true, + 'an unrelated layer is not blocked after the failed entry', + ); +}); + +test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + + const result = await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }); + await f.settle(); + + assert.equal(result.ok, false); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.deepEqual(f.view(), before); +}); + +test('the roster reports a failed mission index instead of loading forever', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + + const roster = f.rosters.at(-1); + assert.ok(roster, 'a failed load publishes a roster view'); + assert.deepEqual(roster.entries, []); + assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE'); + + // A new attempt starts from the loading state, then shows the index. + f.upstream.status = 200; + const loadingFrom = f.rosters.length; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.rosters.slice(loadingFrom).includes(null), true); + assert.equal(f.rosters.at(-1).entries.length, 1); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); +}); + +test('a valid empty mission index is a successful entry', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + f.upstream.results = []; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal( + f.rosters.at(-1).empty, + 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + ); + assert.ok(f.camera.flights > 0, 'a successful entry frames the globe'); +}); + +test('a refresh failure after a loaded index keeps the mode and its records', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const framed = f.view(); + + f.upstream.status = 429; + await f.missions.update(); + await f.settle(); + + assert.equal(f.manager.isEnabled('rocket-launches'), true); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal(f.missions.getStats().count, 1); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.equal(f.rosters.at(-1).entries.length, 1); + + // Leaving a mode that loaded keeps today's camera behaviour. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.deepEqual(f.view(), framed); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index bd1529c0b..171c95a63 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -50,8 +50,9 @@ export function createIngestion({ layerState._postTleRetryCount++; layerState._retryTimer = setTimeout(() => { layerState._retryTimer = null; + // A failed retry is already recorded in the layer stats. if (layerState._enabled && token === layerState._lifecycleToken) - requestMissionUpdate(); + requestMissionUpdate().catch(() => {}); }, POST_TLE_RETRY_DELAY_MS); } @@ -201,6 +202,10 @@ export function createIngestion({ if (layerState._renderedTleText === layerState._activeTleText) clearPostTleRetry(); layerState._count = launches.length; + // The roster reads these to tell a loaded empty index from loading. + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + layerState._stale = stale === true; parts.panel.renderMissionRoster(); if ( !layerState._selectedLaunchId || @@ -219,13 +224,19 @@ export function createIngestion({ } layerState._focusAfterActiveLookup = false; parts.panel.renderMissionPanel(); - layerState._lastUpdate = Date.now(); - layerState._lastError = null; - layerState._stale = stale === true; + layerState._entryCameraView = null; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; console.warn('[Data:RocketLaunches] Fetch error:', error); + // Without one usable index there is no mode to enter: report the + // failure so the manager fails the enable closed and Context rolls + // back. A refresh after a loaded index keeps its records instead. + if (layerState._lastUpdate === null) { + parts.panel.renderMissionRoster(); + throw error; + } + layerState._entryCameraView = null; } } } diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index ca36a87ec..cf0332823 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -111,6 +111,28 @@ export function createLaunchHover({ }; } +/** The camera pose to give back, in world coordinates. */ +function captureCameraView(viewer) { + const camera = viewer?.camera; + if (!camera?.positionWC) return null; + return { + destination: Cesium.Cartesian3.clone(camera.positionWC), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + }; +} + +/** Stop the full-globe flight and return to a captured pose. */ +function restoreCameraView(viewer, view) { + const camera = viewer?.camera; + if (!camera) return; + camera.cancelFlight?.(); + camera.setView(view); +} + export function createLifecycle({ state: layerState, services, @@ -200,6 +222,14 @@ export function createLifecycle({ layerState._updateDirty = false; if (layerState._dataSource) layerState._dataSource.show = true; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + if (layerState._lastUpdate === null) { + // A new attempt at an index that never loaded starts from loading. + layerState._lastError = null; + parts.panel.renderMissionRoster(); + } + // Kept until the mission index loads, so an entry that never does + // gives the view back. + layerState._entryCameraView = captureCameraView(layerState._viewer); parts.selection.focusFullGlobe(layerState._viewer); if (layerState._selectedLaunchId) parts.selection.setSelectedMission( @@ -214,6 +244,9 @@ export function createLifecycle({ hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; + const entryView = layerState._entryCameraView; + layerState._entryCameraView = null; + if (entryView) restoreCameraView(layerState._viewer, entryView); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; @@ -239,6 +272,7 @@ export function createLifecycle({ layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; + layerState._entryCameraView = null; layerState._lifecycleToken++; layerState._updateDirty = false; await parts.ingestion.restoreSatelliteDependency(); diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 07f1c8497..d45d4364c 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -263,8 +263,19 @@ export function createPanel({ state: layerState, services, parts, source }) { publishPanel(); } - /** The roster entries as the list shows them. */ + /** + * The roster entries as the list shows them; null while the first index + * is still loading, and an empty failed roster once that load failed. + */ function presentRoster() { + if (layerState._lastUpdate === null && !layerState._launches.length) { + if (!layerState._lastError) return null; + return { + count: '—', + entries: [], + empty: t('30-DAY MISSION INDEX UNAVAILABLE'), + }; + } const entries = parts.policyHelpers .missionRosterEntries(layerState._launches) .map(({ launch, index }) => { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..c4c032b10 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._entryCameraView = null; + state._stale = false; state._orbitMatches = 0; From b6ad32009fec418d1a5bbff4aa18adb1fbf5cd4c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:52:28 +0400 Subject: [PATCH 047/174] Say why Transit and Bike Share rows are empty outside coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over a city with no feed or system (London, Tokyo) both rows read "ON · — · never", the same as a feed that never answered. Transit already computed a coverage note but put it only in `coverage`, which the row does not show; Bike Share reported nothing at all. Both layers now report a `zoom-in` guidance status with a `statusMessage`: "Fly below 3,000 km to a covered region" / "No feed here yet · N regions available" for Transit, and "Fly below 48 km to a covered city" / "No bike share here yet · N systems available" for Bike Share. A loading or failed Bike Share city keeps its own text. Leaving coverage clears the layer's last-update time, so the row no longer dates data it no longer draws. The coverage-note translations move from the voice card into `translateStatusMessage` (ui/statusText.js), which the Layers row and the voice card now share. --- docs/CURRENT-STATE.md | 13 ++ src/i18n/locales/ar.json | 9 ++ src/i18n/locales/de.json | 5 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 4 + src/i18n/locales/pt.json | 6 + src/i18n/locales/ru.json | 7 ++ src/i18n/locales/uk.json | 7 ++ src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-Hant.json | 4 + src/layers/bikeshare/controls.js | 22 +++- src/layers/bikeshare/coverageStatus.test.mjs | 124 +++++++++++++++++++ src/layers/bikeshare/viewport.js | 3 + src/layers/transit/lifecycle.test.mjs | 39 ++++++ src/layers/transit/queries.js | 13 +- src/layers/transit/viewport.js | 3 + src/ui/layerPanel.js | 9 +- src/ui/layerPanel.test.mjs | 54 ++++++++ src/ui/statusText.js | 63 ++++++++++ src/ui/statusText.test.mjs | 58 ++++++++- src/voice/resultDisplay.js | 31 +---- 23 files changed, 459 insertions(+), 37 deletions(-) create mode 100644 src/layers/bikeshare/coverageStatus.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..8368eeacf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1817,6 +1817,19 @@ Mapped-installation zoom guidance appears in the layer row without counting as a global loading failure. Guidance statuses do not suppress independent refresh errors. +Transit and Bike Share report the same kind of guidance when no feed or system +covers the view, instead of an undated `never`. Above their altitude gates the +rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +`GBFS · Fly below 48 km to a covered city`; lower down over a city neither +covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions +available` and `GBFS · No bike share here yet · N systems available`, N being +the enabled feeds and the catalogued systems. The +chip stays ON. A Bike Share city that is loading or failed keeps its own +loading or error text. Leaving every covered feed or system clears the layer's +last-update time, so the row never dates data it no longer draws. The same +English notes are the voice analyst's empty-result notes; both surfaces show +them translated through `translateStatusMessage` (`src/ui/statusText.js`). + ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..b7193b9e8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2912,6 +2912,15 @@ "many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة", "other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة" }, + "Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة", + "No bike share here yet · {n} systems available": { + "zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح", + "one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})", + "two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})", + "few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة", + "many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا", + "other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح" + }, "degraded": "متدهور", "stale": "متقادم", "partial": "جزئي", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..57be9f388 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2662,6 +2662,11 @@ "one": "Hier noch kein Feed · {n} Region verfügbar", "other": "Hier noch kein Feed · {n} Regionen verfügbar" }, + "Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen", + "No bike share here yet · {n} systems available": { + "one": "Hier noch kein Bikesharing · {n} System verfügbar", + "other": "Hier noch kein Bikesharing · {n} Systeme verfügbar" + }, "degraded": "eingeschränkt", "stale": "veraltet", "partial": "teilweise", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..2a3bb478d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2821,6 +2821,12 @@ "many": "Aún no hay fuente aquí · {n} de regiones disponibles", "other": "Aún no hay fuente aquí · {n} regiones disponibles" }, + "Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura", + "No bike share here yet · {n} systems available": { + "one": "Aún no hay bicis compartidas aquí · {n} sistema disponible", + "many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles", + "other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles" + }, "Wind speed field unavailable": "Campo de velocidad del viento no disponible", "Decoder feeds": "Fuentes del decodificador", "WebUSB + decoder feeds": "WebUSB + fuentes del decodificador", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..57186643b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2531,6 +2531,12 @@ "many": "Pas encore de flux ici · {n} de régions disponibles", "other": "Pas encore de flux ici · {n} régions disponibles" }, + "Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte", + "No bike share here yet · {n} systems available": { + "one": "Pas encore de vélos partagés ici · {n} système disponible", + "many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles", + "other": "Pas encore de vélos partagés ici · {n} systèmes disponibles" + }, "degraded": "dégradé", "stale": "obsolète", "partial": "partiel", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..cecbc7a03 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2531,6 +2531,12 @@ "many": "Ancora nessun feed qui · {n} di regioni disponibili", "other": "Ancora nessun feed qui · {n} regioni disponibili" }, + "Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta", + "No bike share here yet · {n} systems available": { + "one": "Ancora nessun bike sharing qui · {n} sistema disponibile", + "many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili", + "other": "Ancora nessun bike sharing qui · {n} sistemi disponibili" + }, "degraded": "degradato", "stale": "obsoleto", "partial": "parziale", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..47a84c9d4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2401,6 +2401,10 @@ "No feed here yet · {n} regions available": { "other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所" }, + "Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください", + "No bike share here yet · {n} systems available": { + "other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件" + }, "degraded": "性能低下", "stale": "更新遅延", "partial": "部分的", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..2ac851bc0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2533,6 +2533,12 @@ "many": "Ainda não há feed aqui · {n} de regiões disponíveis", "other": "Ainda não há feed aqui · {n} regiões disponíveis" }, + "Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura", + "No bike share here yet · {n} systems available": { + "one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível", + "many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis", + "other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis" + }, "degraded": "degradado", "stale": "desatualizado", "partial": "parcial", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..cba8c8796 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2884,6 +2884,13 @@ "many": "Здесь пока нет потока данных · доступно {n} регионов", "other": "Здесь пока нет потока данных · доступно {n} региона" }, + "Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием", + "No bike share here yet · {n} systems available": { + "one": "Здесь пока нет проката велосипедов · доступна {n} система", + "few": "Здесь пока нет проката велосипедов · доступны {n} системы", + "many": "Здесь пока нет проката велосипедов · доступно {n} систем", + "other": "Здесь пока нет проката велосипедов · доступно {n} системы" + }, "Wind speed field unavailable": "Поле скорости ветра недоступно", "Decoder feeds": "Потоки декодера", "WebUSB + decoder feeds": "WebUSB + потоки декодера", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..71054ea18 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2595,6 +2595,13 @@ "many": "Тут поки немає потоку даних · доступно {n} регіонів", "other": "Тут поки немає потоку даних · доступно {n} регіону" }, + "Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям", + "No bike share here yet · {n} systems available": { + "one": "Тут поки немає прокату велосипедів · доступна {n} система", + "few": "Тут поки немає прокату велосипедів · доступні {n} системи", + "many": "Тут поки немає прокату велосипедів · доступно {n} систем", + "other": "Тут поки немає прокату велосипедів · доступно {n} системи" + }, "degraded": "з перебоями", "stale": "застарів", "partial": "частково", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..0d4033ced 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2302,6 +2302,10 @@ "No feed here yet · {n} regions available": { "other": "此处暂无数据源 · 有 {n} 个覆盖区域" }, + "Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市", + "No bike share here yet · {n} systems available": { + "other": "此处暂无共享单车 · 有 {n} 个可用系统" + }, "degraded": "降级", "stale": "过期", "partial": "部分可用", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..457a32aeb 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2290,6 +2290,10 @@ "No feed here yet · {n} regions available": { "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" }, + "Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市", + "No bike share here yet · {n} systems available": { + "other": "此處暫無共享單車 · 有 {n} 個可用系統" + }, "degraded": "降級", "stale": "過期", "partial": "部分可用", diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 5f73a2dc9..993022506 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -1,5 +1,6 @@ import { msg, t } from '../../i18n/index.js'; -import { STATUS_POLL_MS } from './policy.js'; +import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js'; +import { GBFS_CITY_REGISTRY } from './registry.js'; export function createControls({ state: layerState, services, parts, source }) { const methods = { @@ -57,7 +58,7 @@ export function createControls({ state: layerState, services, parts, source }) { /** * Return current layer statistics for the UI status display. - * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }} + * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }} */ getStats() { const stats = { @@ -65,6 +66,23 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading: layerState._loading, }; + // No system in range is guidance, not an empty feed: the camera is too + // high, or no catalogued system is near the view. A city that failed + // leaves range empty too, and reports its failure below instead. + if ( + layerState._enabled && + !layerState._loading && + layerState._activeCityIds.size === 0 && + layerState._failedCityIds.size === 0 + ) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it through translateStatusMessage + // (ui/statusText.js). + const note = layerState._altitudeGateEnabled + ? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available` + : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; + return { ...stats, status: 'zoom-in', statusMessage: note }; + } if (layerState._loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs new file mode 100644 index 000000000..a9f17e73e --- /dev/null +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -0,0 +1,124 @@ +// BIKESHARE COVERAGE STATUS — a view with no system says so. +// +// Over a city with no catalogued system (London, Tokyo) the layer drew nothing +// and reported nothing, so its row read "ON · — · GBFS · never": the same as a +// feed that had never answered. These cases pin the rule that replaced it: +// with no system in range the layer reports a guidance status that says why, +// and a view it left keeps no date for data it no longer draws. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { GBFS_CITY_REGISTRY } from './registry.js'; + +const LONDON = { lat: 51.5072, lon: -0.1276 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createModel({ state, services, parts: {}, source: null }), + ingestion: { abortInFlight() {} }, + rendering: { removeCityPoints() {} }, + }; + parts.viewport = createViewport({ state, services, parts, source: null }); + const { methods } = createControls({ state, services, parts, source: {} }); + state._enabled = true; + return { state, methods, viewport: parts.viewport }; +} + +/** A viewer looking straight down at `at` from `height` metres. */ +function viewerAt(at, height) { + const rad = Math.PI / 180; + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +test('above the altitude gate the row says how low to fly', async () => { + const { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 2_000_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); + assert.equal(stats.error, undefined); +}); + +test('a city with no system says so and counts the ones there are', async () => { + const { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 8_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.statusMessage, + `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, + ); + assert.equal(stats.count, 0); +}); + +test('a city with a system in range is not given the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._activeCityIds.add('boston-bluebikes'); + + assert.equal(methods.getStats().status, undefined); +}); + +test('a loading or failed city reports itself, not the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._loading = true; + assert.equal(methods.getStats().status, undefined); + + // A city that failed is dropped from range, and still owns the row. + state._loading = false; + state._failedCityIds.add('boston-bluebikes'); + const stats = methods.getStats(); + assert.equal(stats.status, undefined); + assert.equal(stats.error, 'GBFS fetch error'); +}); + +test('a disabled layer reports no prompt', () => { + const { state, methods } = makeLayer(); + state._enabled = false; + assert.equal(methods.getStats().status, undefined); +}); + +test('leaving every system behind drops the date of the data it drew', async () => { + for (const height of [8_000, 2_000_000]) { + const { state, methods, viewport } = makeLayer(); + state._lastUpdate = Date.now(); + // Boston's stations were drawn and dated; the camera moves to London. + state._activeCityIds.add('boston-bluebikes'); + state._cityRuntime.set('boston-bluebikes', {}); + state._viewer = viewerAt(LONDON, height); + await viewport.runProximityCheck(); + + assert.equal(state._activeCityIds.size, 0); + assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 25a4df7d0..0739d341c 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -125,6 +125,8 @@ export function createViewport({ state: layerState, services, parts, source }) { // A failure belongs to a city in range; none are once everything is off. layerState._failedCityIds.clear(); layerState._count = 0; + // Nothing is left on the map to date. + layerState._lastUpdate = null; } /** @@ -227,6 +229,7 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; + layerState._lastUpdate = null; return; } diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..a269dc9e9 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3982,3 +3982,42 @@ test('parked transit reprojects held and reported courses at the bounded camera 'selected unknown course also stays screen-up', ); }); + +test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => { + const app = harness(t); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot'); + + // London has no registered feed: the row prompts instead of dating Boston's + // last snapshot over an empty map. + app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 }); + app.advance(1_000); + const away = app.layer.getStats(); + assert.equal(away.status, 'zoom-in'); + assert.equal(away.count, 0); + assert.equal(away.lastUpdate, null); + assert.match( + away.statusMessage, + /^No feed here yet · \d+ regions available$/, + ); + assert.equal(away.coverage, away.statusMessage); + + // Above the altitude gate the prompt says how low to fly. + app.viewer.camera.positionCartographic.height = 10_000_000; + app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 }); + app.advance(1_000); + assert.equal( + app.layer.getStats().statusMessage, + 'Fly below 3,000 km to a covered region', + ); +}); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..8023cdb39 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -240,17 +240,20 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it through translateStatusMessage + // (ui/statusText.js). + const note = state._altitudeGateOpen + ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; return { count: 0, lastUpdate: state._lastUpdate, error: null, source: 'GTFS-RT', status: 'zoom-in', - // English data (the analyst's empty note to the model); the voice - // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). - coverage: state._altitudeGateOpen - ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, + statusMessage: note, + coverage: note, }; } // Age is shown by the same rule the row state uses, so the text and the diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 730126f10..27cc0569c 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -108,6 +108,9 @@ export function createViewport({ state, services, parts }) { parts.ingestion.removeFeedVehicles(feedId); } } + // Leaving coverage leaves nothing on the map to date: a row that read + // "26s ago" over an empty view would claim a feed that is no longer here. + if (desired.size === 0) state._lastUpdate = null; for (const [feedId, feed] of desired) { if (!state._activeFeeds.has(feedId)) { state._activeFeeds.set(feedId, feed); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..29a4bc3c0 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -11,14 +11,17 @@ import { } from './layerNames.js'; export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; -import { translateFeedError, translateStatusLine } from './statusText.js'; +import { + translateFeedError, + translateStatusLine, + translateStatusMessage, +} from './statusText.js'; import { formatNumber, getLocale, msg, onLocaleChange, t, - translateKnown, } from '../i18n/index.js'; // Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the @@ -691,7 +694,7 @@ export class LayerPanel { typeof stats.statusMessage === 'string' && stats.statusMessage.trim() ) { - return `${source} · ${translateKnown(stats.statusMessage.trim())}`; + return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`; } const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never'); if (stats.loading) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 73710e3fc..1be2df5d7 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -431,3 +431,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie restore(); } }); + +test('a layer with no feed in view says why instead of "never"', async () => { + const { LayerPanel, presentLayerCount, presentLayerToggle } = + await import('./layerPanel.js'); + const { setLocale } = await import('../i18n/index.js'); + const panel = LayerPanel.prototype; + const rows = [ + { + id: 'transit', + name: 'Transit', + source: 'GTFS-RT', + enabled: true, + stats: { + count: 0, + lastUpdate: null, + source: 'GTFS-RT', + status: 'zoom-in', + statusMessage: 'No feed here yet · 16 regions available', + }, + meta: 'GTFS-RT · No feed here yet · 16 regions available', + }, + { + id: 'bikeshare', + name: 'Bikeshare', + source: 'GBFS', + enabled: true, + stats: { + count: 0, + lastUpdate: null, + loading: false, + status: 'zoom-in', + statusMessage: 'Fly below 48 km to a covered city', + }, + meta: 'GBFS · Fly below 48 km to a covered city', + }, + ]; + for (const layer of rows) { + // Guidance is normal operation: the layer is on and nothing has failed. + assert.equal(presentLayerToggle(layer).text, 'ON'); + assert.equal(presentLayerCount(layer.stats).empty, true); + assert.equal(panel._buildMetaText(layer), layer.meta); + } + + setLocale('ru'); + try { + for (const layer of rows) { + const meta = panel._buildMetaText(layer); + assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta); + assert.match(meta, /^(GTFS-RT|GBFS) · /, meta); + } + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index 1af6b8b2a..f4cbb59a0 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -265,3 +265,66 @@ export function translateStatusLine(text) { .map(translateStatusPiece) .join(STATUS_SEPARATOR); } + +/** Thousands separators out of an English number ("3,000" → 3000). */ +const englishNumber = (text) => Number(text.replace(/,/g, '')); + +// Coverage notes of a layer whose view has no feed, whole lines with a number +// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). +// Their " · " is part of one sentence, so they are matched before the line is +// split into pieces. Keep these in step with the producers. +const COVERAGE_NOTE_PATTERNS = [ + [ + /^Fly below ([\d,]+) km to a covered region$/, + ([, km]) => + t('Fly below {km} km to a covered region', { + km: formatNumber(englishNumber(km)), + }), + ], + [ + /^No feed here yet · ([\d,]+) regions available$/, + ([, regions]) => { + const count = englishNumber(regions); + return tn( + count, + 'No feed here yet · {n} region available', + 'No feed here yet · {n} regions available', + { n: formatNumber(count) }, + ); + }, + ], + [ + /^Fly below ([\d,]+) km to a covered city$/, + ([, km]) => + t('Fly below {km} km to a covered city', { + km: formatNumber(englishNumber(km)), + }), + ], + [ + /^No bike share here yet · ([\d,]+) systems available$/, + ([, systems]) => { + const count = englishNumber(systems); + return tn( + count, + 'No bike share here yet · {n} system available', + 'No bike share here yet · {n} systems available', + { n: formatNumber(count) }, + ); + }, + ], +]; + +/** + * A layer's status message in the UI language: its catalog entry, a coverage + * note by its shape, or a status line translated piece by piece. + */ +export function translateStatusMessage(text) { + if (typeof text !== 'string') return text; + const known = translateKnown(text); + if (known !== text) return known; + for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) { + const match = pattern.exec(text); + if (match) return format(match); + } + return translateStatusLine(text); +} diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index a64975c48..1b6479b45 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { setLocale, t } from '../i18n/index.js'; -import { translateFeedError, translateStatusLine } from './statusText.js'; +import { + translateFeedError, + translateStatusLine, + translateStatusMessage, +} from './statusText.js'; function inLocale(locale, run) { setLocale(locale); @@ -75,3 +79,55 @@ for (const locale of ['es', 'ru']) { }); }); } + +// A layer's coverage note when no feed covers the view: one sentence with a +// " · " in it (transit/queries.js, bikeshare/controls.js). +const COVERAGE_NOTES = [ + 'Fly below 3,000 km to a covered region', + 'No feed here yet · 16 regions available', + 'Fly below 48 km to a covered city', + 'No bike share here yet · 61 systems available', +]; + +test('en: coverage notes show as they came', () => { + setLocale('en'); + for (const text of COVERAGE_NOTES) + assert.equal(translateStatusMessage(text), text); + assert.equal( + translateStatusMessage('No bike share here yet · 1 systems available'), + 'No bike share here yet · 1 system available', + ); +}); + +for (const locale of ['es', 'ru', 'ja']) { + test(`${locale}: a coverage note translates whole, numbers kept`, () => { + inLocale(locale, () => { + for (const text of COVERAGE_NOTES) { + const shown = translateStatusMessage(text); + assert.doesNotMatch( + shown, + /\b(Fly|below|covered|feed|bike share|available)\b/, + shown, + ); + const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0]; + assert.ok( + shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')), + shown, + ); + } + }); + }); + + test(`${locale}: a status message without a coverage note keeps its other forms`, () => { + inLocale(locale, () => { + assert.equal( + translateStatusMessage('Upstream response too large'), + t('Upstream response too large'), + ); + assert.equal( + translateStatusMessage('2 feeds live · 14 heard'), + translateStatusLine('2 feeds live · 14 heard'), + ); + }); + }); +} diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 112200f7f..cd909d2f7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -29,7 +29,7 @@ import { } from '../i18n/index.js'; import { layerNameForId, sourceLabel } from '../ui/layerNames.js'; import { localizePlaceName } from '../ui/placeNames.js'; -import { translateStatusLine } from '../ui/statusText.js'; +import { translateStatusMessage } from '../ui/statusText.js'; import { formatQuantity } from '../ui/unitLabels.js'; /** Split "a; b" caveat text into separate notes. */ @@ -397,27 +397,6 @@ const NOTE_PATTERNS = [ 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory', )) + period, ], - // Transit's empty note when no feed covers the view (transit/queries.js); - // the model gets the English. - [ - /^Fly below ([\d,]+) km to a covered region$/, - ([, km]) => - t('Fly below {km} km to a covered region', { - km: formatNumber(Number(km.replace(/,/g, ''))), - }), - ], - [ - /^No feed here yet · ([\d,]+) regions available$/, - ([, regions]) => { - const count = Number(regions.replace(/,/g, '')); - return tn( - count, - 'No feed here yet · {n} region available', - 'No feed here yet · {n} regions available', - { n: formatNumber(count) }, - ); - }, - ], [ /^([a-z0-9-]+) (off|loading|error)$/, ([, key, status]) => @@ -444,10 +423,10 @@ function shownNote(text) { const shown = match ? show(match) : null; if (shown) return shown; } - // A layer's empty note can be its status line, English pieces joined by - // " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). - const known = translateKnown(text); - return known !== text ? known : translateStatusLine(text); + // A layer's empty note can be its coverage note ("Fly below 3,000 km to a + // covered region") or its status line, English pieces joined by " · " + // ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). + return translateStatusMessage(text); } /** Feed states as the card's chips show them; the values stay English. */ From 25c33893f666bf77b8cb416151657ab84a4a1834 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:09 +0400 Subject: [PATCH 048/174] Add the changelog entry for #1576 --- changelog.d/1576-transit-bikeshare-coverage-prompt.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1576-transit-bikeshare-coverage-prompt.md diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md new file mode 100644 index 000000000..c3dcbe82a --- /dev/null +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -0,0 +1,6 @@ +- Say why Transit and Bike Share are empty outside coverage. Over a city with + no feed or system (London, Tokyo) their Layers rows read + `No feed here yet · 16 regions available` and + `No bike share here yet · 61 systems available`, or how low to fly, instead + of `never`; leaving coverage clears the last-update time (German, @gekh, + #1576). From c64ed40c6238d4fbb411d53cee65f26b0d667934 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:54:36 +0400 Subject: [PATCH 049/174] Add the changelog entry for #1578 --- changelog.d/1578-space-missions-failed-entry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1578-space-missions-failed-entry.md diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md new file mode 100644 index 000000000..9ad1630db --- /dev/null +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -0,0 +1,5 @@ +- Space Missions no longer locks the workspace when its mission index fails to + load. A 429, timeout or bad payload on `/api/launches` now rolls the entry + back to the previous layers and camera view instead of leaving an empty, + isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` + rather than loading forever (German, @gekh, #1578). From ed0ad9cf0ff1f92e86d992c99f41ccc40cc1ce22 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 14:32:39 +0400 Subject: [PATCH 050/174] Add the changelog entry for #1581 --- changelog.d/1581-new-strings-translations.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1581-new-strings-translations.md diff --git a/changelog.d/1581-new-strings-translations.md b/changelog.d/1581-new-strings-translations.md new file mode 100644 index 000000000..946e8abb4 --- /dev/null +++ b/changelog.d/1581-new-strings-translations.md @@ -0,0 +1,4 @@ +- Fix the translations of the voice area cards, the fire confidence filter, + the drone airspace layer and Sea Marks: one word for "landmark" per + language, the airspace layer names the airspace again, and Traditional + Chinese follows Simplified Chinese again (German, @gekh, #1581). From e5ab5f945a3ea5ae28c21a200a63fd2277f4d0dd Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:36 -0800 Subject: [PATCH 051/174] test(nominatim): share one POSIX guard and say the cache is private only on POSIX Co-Authored-By: Claude Opus 5.5 --- src/tooling/nominatimOutlineRoute.test.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index 774099efe..74f0b2d6a 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,29 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - // Windows has no POSIX mode bits (directories stat as 0o666), so only - // POSIX systems can check privacy this way. - if (process.platform !== 'win32') + // The cache is private only through POSIX mode bits. Windows has none + // (directories stat as 0o666) and the cache keeps the checkout's ACLs, + // so these checks run, and privacy holds, only on POSIX systems. + const posixModes = process.platform !== 'win32'; + if (posixModes) { assert.equal( fs.statSync(dir).mode & 0o777, 0o700, - 'the query cache directory is private', + 'the query cache directory is private (POSIX modes)', ); + } const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - if (process.platform !== 'win32') + if (posixModes) { for (const name of fs .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) + .filter((entry) => entry.endsWith('.json'))) { assert.equal( fs.statSync(path.join(dir, name)).mode & 0o777, 0o600, - `${name} is private`, + `${name} is private (POSIX modes)`, ); + } + } fs.rmSync(dir, { recursive: true, force: true }); }); From df87419e64815164062637b00769c502999a3bb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:42 -0800 Subject: [PATCH 052/174] test(tools): pin the raw near-360 satellite azimuths and list the tools with the heading helper Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 9 ++++----- src/tools/queries/environment.js | 1 + src/tools/queries/space.test.mjs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e546fd563..d03064e8f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -246,10 +246,9 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft -`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite -`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a -direction that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Their whole-degree directions round through +`roundHeadingDegrees`, listed with the heading readouts below. +`aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; @@ -6091,7 +6090,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 08db732fc..0af696e36 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,6 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, + // Raw Open-Meteo value, like the fields above; it reports north as 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0e44fe208..41394e305 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; import { noradIdFromSatnum, parseTleText, @@ -301,7 +305,20 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + // These points are chosen so the ISS's raw azimuths fall just below 360°; + // pin that first, so the test cannot pass on a raw value already near 0. + const [, line1, line2] = TLE.split('\n'); + const iss = twoline2satrec(line1, line2); + const nearNorth = (deg) => deg > 359.5 && deg < 360; + const rawPass = findNextSatellitePass({ + satrec: iss, + latDeg: 10, + lonDeg: 30, + fromMs: CLOCK.now(), + }); + assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`); + const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75); + assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`); const pass = await catalog.call('next_satellite_pass', { location: { lat: 10, lon: 30 }, }); From 655d93cc925cb6e0a08c20f4e2e4cfb05712e0c3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:02 +0400 Subject: [PATCH 053/174] Keep a newer camera view when a failed Space Missions entry rolls back --- src/layers/launches/controls.js | 2 + src/layers/launches/failedEntry.test.mjs | 107 ++++++++++++++++++++++- src/layers/launches/lifecycle.js | 47 ++++++++-- src/layers/launches/selection.js | 8 +- src/layers/launches/state.js | 2 + 5 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index b0dc035cb..95a212a73 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,6 +12,8 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { + // A failed entry no longer gives back the view it replaced. + layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index f8b760dde..b82663597 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -57,17 +57,29 @@ function fakeCamera() { roll: 0, flights: 0, cancelled: 0, + flight: null, get positionWC() { return camera.position; }, - flyTo({ destination }) { + // Like Cesium, a new flight cancels the one in progress. + flyTo({ destination, complete, cancel }) { + camera.flight?.cancel?.(); + camera.flight = { complete, cancel }; camera.flights += 1; camera.position = Cesium.Cartesian3.clone(destination); camera.heading = 0; camera.pitch = -Math.PI / 2; }, + land() { + const flight = camera.flight; + camera.flight = null; + flight?.complete?.(); + }, cancelFlight() { camera.cancelled += 1; + const flight = camera.flight; + camera.flight = null; + flight?.cancel?.(); }, setView({ destination, orientation }) { camera.position = Cesium.Cartesian3.clone(destination); @@ -81,11 +93,12 @@ function fakeCamera() { async function fixture(t) { const upstream = { status: 429, results: [launchRecord] }; - const server = http.createServer((req, res) => { + const server = http.createServer(async (req, res) => { if (req.url === '/api/celestrak/active') { res.end(''); return; } + if (upstream.hold) await upstream.hold; if (upstream.status !== 200) { res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); @@ -134,8 +147,13 @@ async function fixture(t) { }, replay: { stopMissionReplay: noop, presentReplay: () => null }, selection: { - focusFullGlobe: (target) => - target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }), + focusFullGlobe(target, flight) { + target.camera.flyTo({ + destination: new Cesium.Cartesian3(9, 9, 9), + ...flight, + }); + return true; + }, setSelectedMission: noop, focusMission: noop, stopMissionZoomAnchor: noop, @@ -284,6 +302,87 @@ test('a Context-panel Space Missions entry that gets HTTP 429 restores the previ assert.deepEqual(f.view(), before); }); +// Holds the /api/launches reply until the returned release is called. +function holdReply(f) { + let release; + f.upstream.hold = new Promise((resolve) => { + release = resolve; + }); + return () => { + f.upstream.hold = null; + release(); + }; +} + +async function untilFlown(f) { + for (let i = 0; i < 200 && f.camera.flights === 0; i++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight'); +} + +test('a landed entry flight still gives the view back after a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), before); +}); + +test('a camera move after the entry flight survives a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + // The user drags the globe while the mission index is still loading. + f.camera.setView({ + destination: new Cesium.Cartesian3(7, 7, 7), + orientation: { heading: 0.3, pitch: -0.2, roll: 0 }, + }); + const moved = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), moved); +}); + +test('a navigation during a failed first load is not cancelled by the rollback', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + // Search or Home: navigation releases mission camera ownership, then flies. + f.missions.releaseCameraOwnership(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(5, 5, 5) }); + const cancelled = f.camera.cancelled; + const navigated = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.equal(f.camera.cancelled, cancelled); + assert.deepEqual(f.view(), navigated); +}); + test('the roster reports a failed mission index instead of loading forever', async (t) => { const f = await fixture(t); await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index cf0332823..6ef0ce4df 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -125,11 +125,29 @@ function captureCameraView(viewer) { }; } -/** Stop the full-globe flight and return to a captured pose. */ -function restoreCameraView(viewer, view) { +function sameCameraView(camera, view) { + return ( + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + view.destination, + 0, + 1, + ) && + ['heading', 'pitch', 'roll'].every((key) => + Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6), + ) + ); +} + +/** + * Return to the pose before entry while the entry's full-globe flight still + * holds the camera: in flight, or landed with nothing moved since. + */ +function restoreCameraView(viewer, view, landedView) { const camera = viewer?.camera; if (!camera) return; - camera.cancelFlight?.(); + if (!landedView) camera.cancelFlight?.(); + else if (!sameCameraView(camera, landedView)) return; camera.setView(view); } @@ -228,9 +246,21 @@ export function createLifecycle({ parts.panel.renderMissionRoster(); } // Kept until the mission index loads, so an entry that never does - // gives the view back. + // gives the view back. A newer flight takes the camera and drops it. + const token = layerState._lifecycleToken; layerState._entryCameraView = captureCameraView(layerState._viewer); - parts.selection.focusFullGlobe(layerState._viewer); + layerState._entryLandedView = null; + const flying = parts.selection.focusFullGlobe(layerState._viewer, { + complete: () => { + if (token === layerState._lifecycleToken) + layerState._entryLandedView = captureCameraView(layerState._viewer); + }, + cancel: () => { + if (token === layerState._lifecycleToken) + layerState._entryCameraView = null; + }, + }); + if (!flying) layerState._entryCameraView = null; if (layerState._selectedLaunchId) parts.selection.setSelectedMission( layerState._selectedLaunchId, @@ -246,7 +276,12 @@ export function createLifecycle({ layerState._enabled = false; const entryView = layerState._entryCameraView; layerState._entryCameraView = null; - if (entryView) restoreCameraView(layerState._viewer, entryView); + if (entryView) + restoreCameraView( + layerState._viewer, + entryView, + layerState._entryLandedView, + ); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 813b7875f..0f7955551 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -9,7 +9,8 @@ export function createSelection({ }) { const { getKeyholeGeometry } = services.geometry; - function focusFullGlobe(viewer, duration = 2.4) { + /** @returns {boolean} Whether the flight started. */ + function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) { const canvas = viewer?.scene?.canvas; const height = canvas?.clientHeight || canvas?.height; const width = canvas?.clientWidth || canvas?.width; @@ -23,7 +24,7 @@ export function createSelection({ fovy <= 0 || fovy >= Math.PI ) - return; + return false; const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry(width, height).radius; const targetScreenRadius = keyholeRadius * 0.61; @@ -45,7 +46,10 @@ export function createSelection({ }, duration, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete, + cancel, }); + return true; } function entityLaunchId(entity) { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index c4c032b10..9ef571428 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -21,6 +21,8 @@ export function createState({ services }) { state._entryCameraView = null; + state._entryLandedView = null; + state._stale = false; state._orbitMatches = 0; From 90db4eb2c9ed0c61adbfe6ffaf7a17b1ffe1d829 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:03 +0400 Subject: [PATCH 054/174] Name the real gate altitude and repaint coverage rows at once The Transit prompt said "Fly below 3,000 km", but the closed gate opens at 2,850 km; it now names the enter threshold, as Bike Share does. When the camera changes coverage, Transit and Bike Share ask the Layers panel to repaint instead of leaving the old prompt up until the next 15 s / 60 s tick. --- docs/CURRENT-STATE.md | 2 +- src/layers/bikeshare/controls.js | 9 ++ src/layers/bikeshare/lifecycle.js | 1 + src/layers/bikeshare/state.js | 4 + src/layers/bikeshare/viewport.js | 17 ++++ src/layers/promptRepaint.test.mjs | 122 +++++++++++++++++++++++++ src/layers/transit/gatePrompt.test.mjs | 66 +++++++++++++ src/layers/transit/lifecycle.test.mjs | 2 +- src/layers/transit/queries.js | 4 +- src/layers/transit/viewport.js | 7 ++ src/ui/statusText.test.mjs | 2 +- src/voice/analystEmptyNotes.test.mjs | 2 +- src/voice/resultDisplay.js | 2 +- 13 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 src/layers/promptRepaint.test.mjs create mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8368eeacf..946254fbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1819,7 +1819,7 @@ errors. Transit and Bike Share report the same kind of guidance when no feed or system covers the view, instead of an undated `never`. Above their altitude gates the -rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +rows read `GTFS-RT · Fly below 2,850 km to a covered region` and `GBFS · Fly below 48 km to a covered city`; lower down over a city neither covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions available` and `GBFS · No bike share here yet · N systems available`, N being diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 993022506..78edd907b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -14,6 +14,15 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, + /** + * Keep the manager so camera-driven coverage changes can repaint the row + * before the next status poll. + * @param {object} dataManager DataLayerManager instance. + */ + attachDataManager(dataManager) { + layerState._dataManager = dataManager; + }, + /** * Return a sampled array of detectable station objects for HUD overlay rendering. * @param {Object} [options] - Sampling options (maxCount, seed). diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 3e46b5972..050269bfd 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -169,6 +169,7 @@ export function createLifecycle({ viewer.scene.primitives.remove(layerState._pointCollection); layerState._pointCollection = null; } + layerState._dataManager = null; layerState._viewer = null; }, }; diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 3617914bf..518c2397a 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,6 +19,10 @@ export function createState({ services }) { layerState._viewer = null; + /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + + layerState._dataManager = null; + /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ layerState._pointCollection = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 0739d341c..dbc328f61 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,6 +116,14 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } + /** + * Repaint the layer's row now. Its text follows the camera (coverage + * prompt, station count), and the manager tick is 60 s. + */ + function refreshRow() { + layerState._dataManager?.refreshLayerStats?.(); + } + /** Deactivate all currently active cities and clear the active set. */ function deactivateAllCities() { @@ -205,9 +213,11 @@ export function createViewport({ state: layerState, services, parts, source }) { const generation = ++layerState._proximityGeneration; const altitude = getCameraAltitude(layerState._viewer); + const wasGateEnabled = layerState._altitudeGateEnabled; // Altitude gate: disable all cities when camera is too high if (!shouldActivateAtAltitude(altitude)) { deactivateAllCities(); + if (wasGateEnabled) refreshRow(); return; } @@ -226,12 +236,18 @@ export function createViewport({ state: layerState, services, parts, source }) { if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); } + const changed = + !wasGateEnabled || + nextActive.size !== layerState._activeCityIds.size || + [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; layerState._lastUpdate = null; + if (changed) refreshRow(); return; } + if (changed) refreshRow(); // Only activate cities that don't already have rendered points const toActivate = []; @@ -243,6 +259,7 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); + if (generation === layerState._proximityGeneration) refreshRow(); } /** Schedule a debounced proximity check after camera movement. */ diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs new file mode 100644 index 000000000..563aaeac7 --- /dev/null +++ b/src/layers/promptRepaint.test.mjs @@ -0,0 +1,122 @@ +// When the camera changes which coverage prompt a Transit or +// Bike Share row shows, the layer asks the panel to repaint at once, instead +// of leaving the old prompt up until the manager's next tick (Transit 15 s, +// Bike Share 60 s). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState as createBikeState } from './bikeshare/state.js'; +import { createControls as createBikeControls } from './bikeshare/controls.js'; +import { createModel as createBikeModel } from './bikeshare/model.js'; +import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; +import { createState as createTransitState } from './transit/state.js'; +import { createViewport as createTransitViewport } from './transit/viewport.js'; +import { createQueries as createTransitQueries } from './transit/queries.js'; + +const rad = Math.PI / 180; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +const services = () => ({ + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, +}); + +function viewer(at, height) { + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +function spyManager() { + const dm = { repaints: 0 }; + dm.refreshLayerStats = () => { + dm.repaints++; + }; + return dm; +} + +test('Bike Share: flying from London into Boston repaints the prompt row', async () => { + const svc = services(); + const state = createBikeState({ services: svc }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createBikeModel({ state, services: svc, parts: {}, source: null }), + ingestion: { + abortInFlight() {}, + loadCityStationInfo: async () => new Map(), + loadCityStationStatus: async () => new Map(), + }, + rendering: { + removeCityPoints() {}, + ensureCityPoints() {}, + applyStatusToPoints() {}, + }, + }; + parts.viewport = createBikeViewport({ + state, + services: svc, + parts, + source: null, + }); + const { methods } = createBikeControls({ + state, + services: svc, + parts, + source: {}, + }); + state._enabled = true; + const dm = spyManager(); + methods.attachDataManager(dm); + + state._viewer = viewer(LONDON, 8_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, 'zoom-in'); + + const before = dm.repaints; + state._viewer = viewer(BOSTON, 3_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, undefined, 'Boston is covered'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); + +test('Transit: leaving Boston for London repaints the row', () => { + const svc = services(); + const state = createTransitState({ services: svc }); + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed() {}, + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createTransitViewport({ state, services: svc, parts }); + const queries = createTransitQueries({ state, parts }).methods; + state._enabled = true; + const dm = spyManager(); + state._dataManager = dm; + + state._viewer = viewer(BOSTON, 4_000); + parts.viewport.runProximityCheck(); + assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); + state._lastUpdate = Date.now(); + + const before = dm.repaints; + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs new file mode 100644 index 000000000..7f439fcdc --- /dev/null +++ b/src/layers/transit/gatePrompt.test.mjs @@ -0,0 +1,66 @@ +// The Transit coverage prompt names the altitude the closed gate +// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above +// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" +// asks for something the camera has already done. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createViewport } from './viewport.js'; +import { createQueries } from './queries.js'; +import { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; + +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const polled = []; + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed: (feed) => polled.push(feed.id), + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createViewport({ state, services, parts }); + const queries = createQueries({ state, parts }).methods; + state._enabled = true; + const rad = Math.PI / 180; + state._viewer = { + camera: { + positionCartographic: { + height: 0, + latitude: BOSTON.lat * rad, + longitude: BOSTON.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; + return { state, queries, viewport: parts.viewport, polled }; +} + +test('below the prompted altitude but above the gate, the prompt names the gate', () => { + const { state, queries, viewport, polled } = makeLayer(); + state._viewer.camera.positionCartographic.height = 2_900_000; + viewport.runProximityCheck(); + + // Nothing loads at 2,900 km: the gate opens at the enter threshold. + assert.equal(state._altitudeGateOpen, false); + assert.deepEqual(polled, []); + + const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( + 'en-US', + ); + const { statusMessage } = queries.getStats(); + assert.equal(statusMessage, `Fly below ${km} km to a covered region`); +}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index a269dc9e9..dc18fd6f0 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4018,6 +4018,6 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async app.advance(1_000); assert.equal( app.layer.getStats().statusMessage, - 'Fly below 3,000 km to a covered region', + 'Fly below 2,850 km to a covered region', ); }); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 8023cdb39..748929213 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -1,5 +1,5 @@ import { - ACTIVATION_ALTITUDE_M, + ACTIVATION_ENTER_ALTITUDE_M, FEED_STALE_AFTER_MS, aggregateTransitFeedHealth, transitDetectionClass, @@ -245,7 +245,7 @@ export function createQueries({ state, parts }) { // (ui/statusText.js). const note = state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; + : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; return { count: 0, lastUpdate: state._lastUpdate, diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 27cc0569c..0e3b317d4 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -83,7 +83,9 @@ export function createViewport({ state, services, parts }) { if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); + const wasGateOpen = state._altitudeGateOpen; state._altitudeGateOpen = altitudeGateOpen(altitude); + let changed = state._altitudeGateOpen !== wasGateOpen; const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null; const desired = new Map(); if (center) { @@ -106,6 +108,7 @@ export function createViewport({ state, services, parts }) { state._activeFeeds.delete(feedId); state._feedStatus.delete(feedId); parts.ingestion.removeFeedVehicles(feedId); + changed = true; } } // Leaving coverage leaves nothing on the map to date: a row that read @@ -115,11 +118,15 @@ export function createViewport({ state, services, parts }) { if (!state._activeFeeds.has(feedId)) { state._activeFeeds.set(feedId, feed); void parts.ingestion.pollFeed(feed, state._generation); + changed = true; } } parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); governorRequestRender('transit-proximity'); + // The row's text follows the camera (coverage prompt, feed list), so it + // repaints now instead of at the next 15 s manager tick. + if (changed) state._dataManager?.refreshLayerStats?.(); } function onCameraChanged() { diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index 1b6479b45..6d1648b6d 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -83,7 +83,7 @@ for (const locale of ['es', 'ru']) { // A layer's coverage note when no feed covers the view: one sentence with a // " · " in it (transit/queries.js, bikeshare/controls.js). const COVERAGE_NOTES = [ - 'Fly below 3,000 km to a covered region', + 'Fly below 2,850 km to a covered region', 'No feed here yet · 16 regions available', 'Fly below 48 km to a covered city', 'No bike share here yet · 61 systems available', diff --git a/src/voice/analystEmptyNotes.test.mjs b/src/voice/analystEmptyNotes.test.mjs index b0de16ebd..cfacee4bf 100644 --- a/src/voice/analystEmptyNotes.test.mjs +++ b/src/voice/analystEmptyNotes.test.mjs @@ -70,7 +70,7 @@ const cases = [ // Transit's coverage text when nothing is in view (transit/queries.js). const transitCoverage = [ - 'transit: Fly below 3,000 km to a covered region', + 'transit: Fly below 2,850 km to a covered region', 'transit: No feed here yet · 12 regions available', ]; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index cd909d2f7..72a929b94 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -423,7 +423,7 @@ function shownNote(text) { const shown = match ? show(match) : null; if (shown) return shown; } - // A layer's empty note can be its coverage note ("Fly below 3,000 km to a + // A layer's empty note can be its coverage note ("Fly below 2,850 km to a // covered region") or its status line, English pieces joined by " · " // ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). return translateStatusMessage(text); From 433b336c2d57087986d8114735861f53d790d7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:50:42 -0300 Subject: [PATCH 055/174] feat(transit): add Szczecin buses and trams (ZDiTM, CC0) Keyless GTFS-Realtime feed published by ZDiTM Szczecin under CC0 1.0. Speeds are m/s (checked against vehicle movement), the file refreshes about every 20 s, and the feed carries no route ids, so vehicles take the default bus mode. Co-Authored-By: Claude Sonnet 5.5 --- DATA_SOURCES.md | 3 ++- README.md | 2 +- changelog.d/szczecin-transit.md | 3 +++ docs/CURRENT-STATE.md | 4 ++-- src/data/transitFeeds.js | 23 +++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 26 ++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 changelog.d/szczecin-transit.md diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..6532837d8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -99,6 +99,7 @@ How to read this: | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **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 | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | | **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 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)" | @@ -186,7 +187,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All nineteen registered feeds, covering eighteen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 6212f3991..6c8de0d79 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/szczecin-transit.md b/changelog.d/szczecin-transit.md new file mode 100644 index 000000000..5b2411211 --- /dev/null +++ b/changelog.d/szczecin-transit.md @@ -0,0 +1,3 @@ +- Add Szczecin, Poland to the Transit layer: buses and trams from ZDiTM's + keyless GTFS-Realtime feed, published under CC0 1.0. The feed carries no + route ids, so vehicles show as buses (IMBrandao). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index abfa3fc04..85bfd2e92 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4644,9 +4644,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Eighteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro -(municipal buses and the Mobi-Rio BRT, two feeds), and nine in +(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 48ca450a1..76b2764a6 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -788,6 +788,29 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: rioRouteLabel, }), + Object.freeze({ + id: 'zditm-szczecin', + name: 'Szczecin buses and trams', + operator: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin', + region: 'Szczecin, Poland', + // Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and + // 14.44–14.77 E, inside 20 km of this point. + center: Object.freeze({ lat: 53.44, lon: 14.56 }), + loadRadiusKm: 25, + url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb', + license: 'CC0 1.0', + licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + attribution: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.', + note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.', + }), + defaultMode: 'bus', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index d6dc93eb6..5fab7c27d 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -552,3 +552,29 @@ test('Rio feeds are CC BY 4.0 and live', () => { 'Rio has a DATA_SOURCES row', ); }); + +test('Szczecin is CC0, live, covers the city and not Berlin', () => { + const feed = getTransitFeed('zditm-szczecin'); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.license, 'CC0 1.0'); + assert.equal( + feed.licenseUrl, + 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + ); + assert.match(feed.terms.quote, /CC0 1\.0 licence/); + assert.match(feed.attribution, /ZDiTM/); + assert.equal(feed.routeLabel, undefined, 'the feed has no route ids'); + assert.equal(transitModeFor(feed, null), 'bus'); + const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre'); + assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge'); + assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin'); + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')), + 'Szczecin has a DATA_SOURCES row', + ); +}); From 0de347c328c5489648040039bd001dd9d0682665 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:36 +0400 Subject: [PATCH 056/174] Leave other camera flights running when Space Missions stops without a replay --- src/layers/launches/failedEntry.test.mjs | 18 ++++++++++++++++-- src/layers/launches/replay.js | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index b82663597..9284e4fd2 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -17,6 +17,7 @@ import { createIngestion } from './ingestion.js'; import { createPanel } from './panel.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createReplay } from './replay.js'; const noop = () => {}; @@ -81,6 +82,7 @@ function fakeCamera() { camera.flight = null; flight?.cancel?.(); }, + lookAtTransform: noop, setView({ destination, orientation }) { camera.position = Cesium.Cartesian3.clone(destination); camera.heading = orientation.heading; @@ -144,8 +146,8 @@ async function fixture(t) { clearMissionOverlaySources: noop, hideReplayVehicleOverlay: noop, destroyReplayVehicleOverlay: noop, + syncMissionOverlayEntries: noop, }, - replay: { stopMissionReplay: noop, presentReplay: () => null }, selection: { focusFullGlobe(target, flight) { target.camera.flyTo({ @@ -182,6 +184,7 @@ async function fixture(t) { parts.policyHelpers = createPolicyHelpers(context); parts.model = createModel(context); parts.panel = createPanel(context); + parts.replay = createReplay(context); parts.ingestion = createIngestion(context); parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); @@ -372,13 +375,24 @@ test('a navigation during a failed first load is not cancelled by the rollback', await untilFlown(f); // Search or Home: navigation releases mission camera ownership, then flies. f.missions.releaseCameraOwnership(); - f.camera.flyTo({ destination: new Cesium.Cartesian3(5, 5, 5) }); + let navigationCancelled = false; + f.camera.flyTo({ + destination: new Cesium.Cartesian3(5, 5, 5), + cancel: () => { + navigationCancelled = true; + }, + }); const cancelled = f.camera.cancelled; const navigated = f.view(); release(); assert.equal(await entering, false); await f.settle(); + assert.equal( + navigationCancelled, + false, + 'the rollback does not cancel the Search/Home flight', + ); assert.equal(f.camera.cancelled, cancelled); assert.deepEqual(f.view(), navigated); }); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 86b6a08f0..e74c636f9 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,9 +314,11 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - layerState._viewer?.camera?.cancelFlight(); - if (layerState._viewer?.camera) + // Without a replay the camera is not ours: leave other flights running. + if (stoppedLaunchId && layerState._viewer?.camera) { + layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } layerState._replayCameraLaunchId = null; layerState._replayPaused = false; layerState._replayPausedAtMs = null; From 697b6b59e66291c6c690b6a77fd2030396233e03 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:39 +0400 Subject: [PATCH 057/174] Repaint coverage rows after loads, nudges and cleared failures --- src/data/lifecycle.js | 19 +- src/data/manager.js | 4 +- src/data/manager.test.mjs | 25 ++ src/layers/bikeshare/controls.js | 15 +- src/layers/bikeshare/rowRepaint.test.mjs | 295 ++++++++++++++++++ src/layers/bikeshare/state.js | 2 +- src/layers/bikeshare/viewport.js | 13 +- .../transit/refreshErrorRepaint.test.mjs | 98 ++++++ src/layers/transit/state.js | 2 +- src/layers/transit/viewport.js | 2 +- 10 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 src/layers/bikeshare/rowRepaint.test.mjs create mode 100644 src/layers/transit/refreshErrorRepaint.test.mjs diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c2290c72..c01926451 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2385,11 +2385,28 @@ export class LayerLifecycle { /** * Ask presentation to repaint layer rows now, for data that lands outside * the manager tick (Transit's proximity polls, Directions' route steps). + * @param {string} [layerId] - The layer whose row changed. */ - refreshLayerStats() { + refreshLayerStats(layerId) { + this._dropRecoveredRefreshError(layerId); this._publishActivity({ type: 'status' }); } + /** + * A tick's failure belongs to the feed it polled. Once the layer's own stats + * stop reporting one (the camera left that feed), the row stops showing it + * instead of waiting for the next tick. + */ + _dropRecoveredRefreshError(layerId) { + const entry = layerId ? this.layers.get(layerId) : null; + if (!entry?.managerRefreshError || entry.refreshing) return; + const failure = refreshFailureFromStats( + this._moduleStats(entry), + entry.module.name || layerId, + ); + if (!failure) entry.managerRefreshError = null; + } + _publishActivity(change) { for (const callback of this._activityListeners) { try { diff --git a/src/data/manager.js b/src/data/manager.js index cf7b31fae..688fb94d2 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -20,8 +20,10 @@ export class DataLayerManager extends LayerLifecycle { * manager tick (camera-driven loads such as Transit's proximity polls), so a * row shows its count when the data lands instead of at the next interval. * One DOM pass; skipped while the document is hidden. + * @param {string} [layerId] - The layer whose row changed. */ - refreshLayerStats() { + refreshLayerStats(layerId) { + this._dropRecoveredRefreshError(layerId); this._refreshTogglePanel(); } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 70f705ec6..de3502979 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async assert.deepEqual(changes, ['status']); }); +test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => { + const { LayerLifecycle } = await import('./lifecycle.js'); + const lifecycle = new LayerLifecycle({}); + let stats = { count: 0, error: 'Feed unavailable' }; + lifecycle.register({ + id: 'feed', + name: 'Feed', + getStats: () => stats, + async update() {}, + }); + const entry = lifecycle.layers.get('feed'); + entry.enabled = true; + entry.initialized = true; + entry.managerRefreshError = 'Feed unavailable'; + + lifecycle.refreshLayerStats('feed'); + assert.equal(entry.managerRefreshError, 'Feed unavailable'); + + stats = { count: 0 }; + lifecycle.refreshLayerStats(); + assert.equal(entry.managerRefreshError, 'Feed unavailable'); + lifecycle.refreshLayerStats('feed'); + assert.equal(entry.managerRefreshError, null); +}); + test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { const listeners = new Set(); const doc = { diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 78edd907b..03bb544ab 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -70,17 +70,26 @@ export function createControls({ state: layerState, services, parts, source }) { * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }} */ getStats() { + // A city in range without points is loading even before its fetch + // starts: the row repaints on entry, ahead of beginLoading(). + const loading = + layerState._loading || + [...layerState._activeCityIds].some( + (cityId) => + !layerState._cityRuntime.has(cityId) && + !layerState._failedCityIds.has(cityId), + ); const stats = { count: layerState._count, lastUpdate: layerState._lastUpdate, - loading: layerState._loading, + loading, }; // No system in range is guidance, not an empty feed: the camera is too // high, or no catalogued system is near the view. A city that failed // leaves range empty too, and reports its failure below instead. if ( layerState._enabled && - !layerState._loading && + !loading && layerState._activeCityIds.size === 0 && layerState._failedCityIds.size === 0 ) { @@ -92,7 +101,7 @@ export function createControls({ state: layerState, services, parts, source }) { : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; return { ...stats, status: 'zoom-in', statusMessage: note }; } - if (layerState._loading) { + if (loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 ? t('syncing {count} city feeds...', { diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs new file mode 100644 index 000000000..144ff5502 --- /dev/null +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -0,0 +1,295 @@ +// Bike Share row repaints: entering a city, leaving it or nudging the camera +// mid-load, and leaving a failed city. +// Bike Share: after the camera settles, the last row the panel painted must +// match what the layer reports now. Each case drives the real viewport, +// ingestion, rendering and controls; the manager is a spy that records the +// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { CITY_BY_ID } from './registry.js'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; +import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js'; + +const BOSTON_ID = 'boston-bluebikes'; +const TORONTO_ID = 'toronto-bike-share'; +const BUFFALO_ID = 'buffalo-reddy'; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; +const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 }; +const BETWEEN = { lat: 43.25, lon: -79.1 }; +const TORONTO_ONLY = { lat: 43.7, lon: -79.6 }; +const LOW_M = 10_000; + +globalThis.document ??= { + addEventListener() {}, + removeEventListener() {}, +}; + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function makeSource() { + const cityOf = (url) => { + for (const [id, city] of CITY_BY_ID) { + if (url === city.stationInformationUrl) return { id, kind: 'info' }; + if (url === city.stationStatusUrl) return { id, kind: 'status' }; + } + throw new Error(`unexpected GBFS url ${url}`); + }; + const source = { + dead: new Set(), + /** `${cityId}:${kind}` → deferred that gates the answer. */ + gates: new Map(), + async getStations(url, { signal } = {}) { + const { id, kind } = cityOf(url); + const gate = source.gates.get(`${id}:${kind}`); + if (gate) { + await new Promise((resolve, reject) => { + gate.promise.then(resolve); + signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }); + } else { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + signal?.throwIfAborted(); + if (source.dead.has(id)) throw new Error('GBFS HTTP 503'); + const city = CITY_BY_ID.get(id); + if (kind === 'info') { + return { + data: { + stations: [ + { + station_id: `${id}-1`, + name: `${id} dock`, + lat: city.centerLat, + lon: city.centerLon, + capacity: 10, + }, + ], + }, + }; + } + return { + data: { + stations: [ + { + station_id: `${id}-1`, + num_bikes_available: 3, + num_docks_available: 7, + is_installed: true, + is_renting: true, + is_returning: true, + }, + ], + }, + }; + }, + }; + return source; +} + +/** The row as the panel draws it: state chip, count, meta text. */ +const rowText = (raw) => { + const stats = { + count: 0, + lastUpdate: null, + ...raw, + loading: raw.loading === true, + }; + const meta = LayerPanel.prototype._buildMetaText({ + id: 'bikeshare', + source: 'GBFS', + enabled: true, + lifecycleState: 'enabled', + stats, + }); + return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`; +}; + +function makeLiveLayer() { + const source = makeSource(); + const services = { + render: { governorRequestRender() {} }, + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + }; + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.ingestion = createIngestion(context); + parts.model = createModel(context); + parts.viewport = createViewport(context); + parts.rendering = createRendering(context); + parts.selection = { + _installClickHandler() {}, + _clearSelection() {}, + _onKeyDown() {}, + }; + parts.queries = { collectDetectableStations: () => [] }; + parts.lifecycle = createLifecycle(context); + const { methods: controls } = createControls(context); + + const camera = { ...LONDON, height: LOW_M }; + const viewer = { + camera: { + get positionCartographic() { + return Cesium.Cartographic.fromDegrees( + camera.lon, + camera.lat, + camera.height, + ); + }, + computeViewRectangle: () => undefined, + changed: { addEventListener() {}, removeEventListener() {} }, + }, + scene: { globe: {}, primitives: { add() {}, remove() {} } }, + }; + parts.lifecycle.methods.init(viewer); + + const painted = []; + controls.attachDataManager({ + refreshLayerStats() { + painted.push(rowText(controls.getStats())); + }, + }); + + return { + state, + source, + painted, + lastPainted: () => painted.at(-1), + live: () => rowText(controls.getStats()), + moveTo(place, height = LOW_M) { + Object.assign(camera, place, { height }); + return parts.viewport.runProximityCheck(); + }, + async enable() { + parts.lifecycle.methods.enable(viewer); + await settle(); + }, + }; +} + +async function settle() { + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setTimeout(resolve, 2)); +} + +test('entering a city paints the loading row, not "never"', async () => { + const layer = makeLiveLayer(); + const info = deferred(); + layer.source.gates.set(`${BOSTON_ID}:info`, info); + await layer.enable(); + const before = layer.painted.length; + const entering = layer.moveTo(BOSTON); + assert.ok(layer.painted.length > before, 'entering repainted'); + const atEntry = layer.lastPainted(); + const liveAfter = layer.live(); + info.resolve(); + await entering; + await settle(); + assert.match(liveAfter, /syncing 1 city feeds/); + assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading'); + assert.doesNotMatch(atEntry, /never/); +}); +test('re-entering a city with cached station info paints loading', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + await layer.moveTo(LONDON); + await settle(); + const status = deferred(); + layer.source.gates.set(`${BOSTON_ID}:status`, status); + const entering = layer.moveTo(BOSTON); + const atEntry = layer.lastPainted(); + await Promise.resolve(); + await Promise.resolve(); + const liveAfter = layer.live(); + status.resolve(); + await entering; + await settle(); + assert.match(liveAfter, /syncing 1 city feeds/); + assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading'); + assert.doesNotMatch(atEntry, /never/); +}); + +test('leaving a city mid-load does not leave the row on LOADING', async () => { + const layer = makeLiveLayer(); + layer.source.gates.set(`${BOSTON_ID}:info`, deferred()); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + await settle(); + assert.equal(layer.state._loading, true); + + await layer.moveTo(LONDON); + await entering; + await settle(); + assert.equal(layer.state._loading, false); + assert.match(layer.live(), /No bike share here yet/); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('a camera nudge during the first load still repaints the drawn stations', async () => { + const layer = makeLiveLayer(); + const status = deferred(); + layer.source.gates.set(`${BOSTON_ID}:status`, status); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + await settle(); + assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn'); + + await layer.moveTo(BOSTON_NUDGED); + status.resolve(); + await entering; + await settle(); + assert.equal(layer.state._count, 1, 'one station on the map'); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('leaving a city whose load failed repaints the error away', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BOSTON_ID); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + assert.match(layer.lastPainted(), /GBFS fetch error/i); + + await layer.moveTo(LONDON); + await settle(); + assert.match(layer.live(), /No bike share here yet/); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('leaving the failed city of two repaints DEGRADED away', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO_ID); + await layer.enable(); + await layer.moveTo(BETWEEN); + await settle(); + assert.match(layer.lastPainted(), /^degraded/); + + await layer.moveTo(TORONTO_ONLY); + await settle(); + assert.doesNotMatch(layer.live(), /^degraded/); + assert.equal(layer.lastPainted(), layer.live()); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 518c2397a..199c9b5ca 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,7 +19,7 @@ export function createState({ services }) { layerState._viewer = null; - /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */ layerState._dataManager = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index dbc328f61..008c01333 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -121,7 +121,7 @@ export function createViewport({ state: layerState, services, parts, source }) { * prompt, station count), and the manager tick is 60 s. */ function refreshRow() { - layerState._dataManager?.refreshLayerStats?.(); + layerState._dataManager?.refreshLayerStats?.('bikeshare'); } /** Deactivate all currently active cities and clear the active set. */ @@ -232,12 +232,17 @@ export function createViewport({ state: layerState, services, parts, source }) { } } // A failure never outlives its city's range. + let failureCleared = false; for (const cityId of layerState._failedCityIds) { - if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); + if (!nextActive.has(cityId)) { + layerState._failedCityIds.delete(cityId); + failureCleared = true; + } } const changed = !wasGateEnabled || + failureCleared || nextActive.size !== layerState._activeCityIds.size || [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; @@ -259,7 +264,9 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); - if (generation === layerState._proximityGeneration) refreshRow(); + // Superseded loads repaint too: a newer check may have painted the row + // while this one was still loading, or found nothing to load itself. + if (layerState._enabled) refreshRow(); } /** Schedule a debounced proximity check after camera movement. */ diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs new file mode 100644 index 000000000..d1a5c16b8 --- /dev/null +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -0,0 +1,98 @@ +// Transit: a manager tick that failed over Boston must not keep its error on +// the row the coverage repaint draws after the camera leaves for London. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createViewport } from './viewport.js'; +import { createQueries } from './queries.js'; +import { LayerLifecycle } from '../../data/lifecycle.js'; +import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js'; + +const rad = Math.PI / 180; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +function viewer(at, height) { + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +test('leaving a feed whose tick failed repaints its error away', async () => { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const feedStatus = (feedId) => { + if (!state._feedStatus.has(feedId)) + state._feedStatus.set(feedId, { count: 0, error: null, loading: false }); + return state._feedStatus.get(feedId); + }; + const parts = { + ingestion: { + feedStatus, + // As pollFeed's catch does when the proxy answers 503. + pollFeed(feed) { + Object.assign(feedStatus(feed.id), { + error: `${feed.name} feed unavailable`, + loading: false, + }); + return Promise.resolve(); + }, + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createViewport({ state, services, parts }); + const queries = createQueries({ state, parts }).methods; + state._enabled = true; + + const lifecycle = new LayerLifecycle(null); + lifecycle.register({ + id: 'transit', + name: 'Transit', + source: 'GTFS-RT', + getStats: () => queries.getStats(), + async update() {}, + }); + const entry = lifecycle.layers.get('transit'); + entry.enabled = true; + entry.lifecycleState = 'enabled'; + entry.initialized = true; + + const row = () => { + const layer = lifecycle.getAll()[0]; + return `${layerFeedState(layer.stats)} | ${LayerPanel.prototype._buildMetaText(layer)}`; + }; + const painted = []; + state._dataManager = { + refreshLayerStats(layerId) { + lifecycle.refreshLayerStats(layerId); + painted.push(row()); + }, + }; + + state._viewer = viewer(BOSTON, 4_000); + parts.viewport.runProximityCheck(); + await lifecycle.refreshLayer('transit'); + assert.match(row(), /feed unavailable/, 'the tick recorded the failure'); + + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.doesNotMatch(painted.at(-1), /feed unavailable/); +}); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..569298e6e 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -112,7 +112,7 @@ export function createState({ services }) { state._cockpitVision = false; /** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */ state._styleListeners = []; - /** @type {{ refreshLayerStats?: () => void }|null} */ + /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */ state._dataManager = null; /** @type {number|null} */ state._lastUpdate = null; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 0e3b317d4..a62d49009 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -126,7 +126,7 @@ export function createViewport({ state, services, parts }) { governorRequestRender('transit-proximity'); // The row's text follows the camera (coverage prompt, feed list), so it // repaints now instead of at the next 15 s manager tick. - if (changed) state._dataManager?.refreshLayerStats?.(); + if (changed) state._dataManager?.refreshLayerStats?.('transit'); } function onCameraChanged() { From 579acf50810f3d554898335970de723ffde04047 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 01:03:41 +0000 Subject: [PATCH 058/174] Street Level: without a Mapillary key, light a keyless source instead of opening on KEY REQUIRED --- src/layers/streetLevel/index.js | 23 ++++++- src/layers/streetLevel/index.test.mjs | 61 +++++++++++++++++++ .../streetLevel/providers/mapillary/index.js | 3 + src/layers/streetLevel/registry.js | 2 + 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 1f7b0f7f0..8d332c227 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -126,8 +126,17 @@ export function createStreetLevelLayer({ }); } - // One source at a time: the first provider that defaults on. - const startsOn = defs.find((def) => def.defaultOn !== false); + /** `def`, or the first keyless provider not refused when `def` has no key. */ + function keylessInstead(def, refused = () => false) { + if (def.keyReady?.() !== false) return def; + return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def; + } + + // One source at a time: the first provider that defaults on. Without its + // key, the first keyless source instead, so a fresh keyless install opens + // on coverage rather than KEY REQUIRED. + const preferred = defs.find((def) => def.defaultOn !== false); + const startsOn = preferred && keylessInstead(preferred); for (const def of defs) { const entry = { def, @@ -585,7 +594,15 @@ export function createStreetLevelLayer({ providerIds: state.providers.keys(), filter: state.filter, }); - const lit = litByParams(decoded.providers); + // Links and stored state omit Mapillary when it is on (the codec + // default), so a keyless install would restore a source it cannot draw. + const asked = litByParams(decoded.providers); + const lit = + asked && + keylessInstead( + state.providers.get(asked).def, + (id) => decoded.providers.get(id) === false, + ).id; const before = litIds(); for (const [id, entry] of state.providers) if (id !== lit) switchProvider(entry, false); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 6c79a52ca..93fc96d23 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1127,3 +1127,64 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( const view = presentStreetLevelPanel(layer.getUIState()); assert.match(view.meta, /Google Street View needs the Google 3D map/); }); + +test('without a Mapillary key the first keyless source is lit instead', async (t) => { + const kartaViewSource = { + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => [], + getPhoto: async () => { + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async () => ({ photos: [], hasMore: false }), + probeImage: async () => true, + }; + const lit = (hasToken) => { + const layer = createStreetLevelLayer({ + providers: [ + createMapillaryProvider({ + source: fakeMapillarySource({ hasToken: () => hasToken }), + photoViewer: fakePhotoViewer(), + }), + createKartaViewProvider({ source: kartaViewSource }), + ], + }); + t.after(() => layer.destroy()); + return layer + .getUIState() + .providers.filter((provider) => provider.on) + .map((provider) => provider.id); + }; + assert.deepEqual(lit(false), ['kartaview']); + assert.deepEqual(lit(true), ['mapillary']); +}); + +test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => { + const layer = createStreetLevelLayer({ + providers: [ + createMapillaryProvider({ + source: fakeMapillarySource({ hasToken: () => false }), + photoViewer: fakePhotoViewer(), + }), + createKartaViewProvider({ + source: { + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => [], + getPhoto: async () => null, + getSequencePhotos: async () => ({ photos: [], hasMore: false }), + probeImage: async () => true, + }, + }), + ], + }); + t.after(() => layer.destroy()); + const litIds = () => + layer + .getUIState() + .providers.filter((provider) => provider.on) + .map((provider) => provider.id); + layer.setParams({ mapillary: true, providerSwitches: '' }); + assert.deepEqual(litIds(), ['kartaview']); + // A link that switches KartaView off keeps its word. + layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); + assert.deepEqual(litIds(), ['mapillary']); +}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 928922195..c2e9e7850 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) { name: MAPILLARY_NAME, label: MAPILLARY_LABEL, requiresKeyId: MAPILLARY_KEY_ID, + /** A browser token or a token-adding Graph proxy; else KEY REQUIRED. */ + keyReady: () => + source.hasToken?.() !== false || Boolean(source.graphEndpoint), pickPrefix: PICK_PREFIX.root, colors: COLORS, credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }), diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 5e51929a6..4ba1763ab 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -20,6 +20,8 @@ export function filtersOf(provider) { * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. * @property {boolean} [defaultOn] Default true; the first that defaults on is lit. + * @property {() => boolean} [keyReady] False when its key is missing now; the + * first keyless provider is lit instead of a default that cannot draw. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). From dda5ac6c000503b5f317d7b1df4132e071dd2833 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 11:59:31 +0000 Subject: [PATCH 059/174] Changelog: 1601 fragment --- changelog.d/1601-street-level-keyless-default.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1601-street-level-keyless-default.md diff --git a/changelog.d/1601-street-level-keyless-default.md b/changelog.d/1601-street-level-keyless-default.md new file mode 100644 index 000000000..04d96b1bd --- /dev/null +++ b/changelog.d/1601-street-level-keyless-default.md @@ -0,0 +1,4 @@ +- Without a Mapillary token, Street Level starts on the first keyless source + (KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on + the globe. A share link that turns a source off is respected + (asturksever, #1601). From 4e518821b514e688d6e2663eded607395ea4e45f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 19:43:04 +0400 Subject: [PATCH 060/174] fix(cyclones): name the classification and mark a stale cache in shared context - Publish classificationName next to the raw NHC code; unknown codes stay unexpanded. - Publish staleCacheFetchedAt only while the proxy serves its cache. - Touch the shared store on clear only after this layer published, so a feed failure no longer evicts another layer's dangling selection. - Drop the unreachable no-storm branch and the unused return value. - Tests: split the heading fixture into due north and stationary, cover the unavailable and failure paths with the real store, and check that clear/disable leave another layer's selection alone; fold the fake-store test into the real-store one. --- docs/CURRENT-STATE.md | 12 +- src/layers/cyclones/index.js | 33 ++- src/layers/cyclones/index.test.mjs | 349 ++++++++++++++++++----------- 3 files changed, 247 insertions(+), 147 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa535c295..a1b0874c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -743,12 +743,12 @@ NHC movement speed is converted from miles per hour to rounded knots by the proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Explicit cyclone selections publish one shared `weather-cyclones` record -with official advisory provenance and source position. Switching storms removes -the prior record; automatic row selection never claims global context, and -refreshes do not re-announce selection. Clearing or losing an explicitly selected -storm removes shared context rather than assigning a fallback. -Deliberate clear/disable reports `deliberate`; feed disappearance reports -`evicted`. Switching storms selects the replacement before pruning the old record. +with official advisory provenance, source position, the English classification +name and, while the proxy serves its cache, the cache fetch time. Switching +storms selects the replacement before pruning the old record; automatic row +selection never claims global context, and refreshes do not re-announce +selection. Deliberate clear/disable reports `deliberate`; losing the selected +storm from the feed reports `evicted` and assigns no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index ef8bbad27..8741ad8a3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -96,19 +96,19 @@ export function createCyclonesLayer({ const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; function clearSelectedContext({ evicted = false } = {}) { - if (!evicted) - context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); - context?.removeEntityContextsForLayer?.('weather-cyclones'); + if (selectedContextStormId) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); + context?.removeEntityContextsForLayer?.('weather-cyclones'); + } selectedContextStormId = null; selectedContextEntity = null; } + // Callers publish only while selectedId names a storm in the snapshot. function publishSelectedContext({ announce = false } = {}) { const storm = selected(); - if (!storm || !context) { - if (!storm) clearSelectedContext({ evicted: true }); - return null; - } + if (!context) return; selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; const record = context.registerEntityContext?.(selectedContextEntity, { @@ -116,13 +116,20 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - // Layer identity is already carried separately. Keep source names literal - // rather than adding an untranslated user-facing prefix. + // Keep official storm names verbatim; layerName identifies the layer. label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, + // At most 12 properties reach the voice summary; keep the list short. properties: { classification: storm.classification, + // English source meaning; unknown codes stay unexpanded. + classificationName: Object.hasOwn( + CLASSIFICATION_NAMES, + storm.classification, + ) + ? CLASSIFICATION_NAMES[storm.classification] + : undefined, basin: storm.basin, advisoryNumber: storm.advisoryNumber, issuedAt: storm.issuedAt, @@ -133,15 +140,19 @@ export function createCyclonesLayer({ movementDirectionDegrees: storm.movement?.directionDegrees, movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, + // Present only while the server serves its cache with NHC unreachable. + staleCacheFetchedAt: + snapshot.stale && Number.isFinite(snapshot.fetchedAt) + ? new Date(snapshot.fetchedAt).toISOString() + : undefined, }, }); - if (!record) return null; + if (!record) return; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); context.removeEntityContextsForLayer?.('weather-cyclones', { retainIds: new Set([contextId]), }); - return record; } function select(id) { diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f70f4f6a8..a3e40fc95 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -405,96 +405,19 @@ function harness( }, }; } -test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { - let next = snapshot([storm(), storm('ep162026')]); - const records = new Map(); - let selectedContext = null; - let selectedEvents = 0; - const context = { - registerEntityContext(entity, metadata) { - entity.__gevContextId = metadata.id; - const record = { ...metadata, entity }; - records.set(metadata.id, record); - return record; - }, - selectEntityContext(entity) { - selectedContext = records.get(entity.__gevContextId) || null; - selectedEvents += 1; - return selectedContext; - }, - removeEntityContextsForLayer(layerId, { retainIds } = {}) { - for (const [id, record] of records) { - if (record.layerId === layerId && !retainIds?.has(id)) - records.delete(id); - } - if (selectedContext && !records.has(selectedContext.id)) - selectedContext = null; - }, - }; - const h = harness({ getSnapshot: async () => next }, { context }); - h.layer.enable(); - await h.layer.update(); - - assert.equal( - selectedEvents, - 0, - 'the automatic first-storm row selection must not claim shared context', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep162026' }); - assert.equal(selectedEvents, 1); - assert.equal(selectedContext?.layerId, 'weather-cyclones'); - assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); - assert.equal(selectedContext?.label, 'Another system'); - assert.equal(selectedContext?.latitude, 15.5); - assert.equal(selectedContext?.longitude, -125.8); - assert.equal(selectedContext?.properties.advisoryNumber, '10'); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep152026']); - assert.equal(selectedContext?.id, 'cyclone:ep152026'); - h.layer.setParams({ stormId: 'ep162026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep162026']); - assert.equal(selectedContext?.id, 'cyclone:ep162026'); - assert.equal(selectedEvents, 3, 'each explicit storm switch announces once'); - - await h.layer.update(); - assert.equal( - selectedEvents, - 3, - 'refresh updates the selected record without re-announcing the click', - ); - - next = snapshot([storm()]); - await h.layer.update(); - assert.equal( - selectedContext, - null, - 'a vanished user-selected storm must clear instead of transferring context to the fallback row', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.equal(selectedEvents, 4); - h.layer.setParams({ clear: true }); - assert.equal(selectedContext, null); - assert.equal(records.size, 0); - - h.layer.destroy(); -}); - -test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { +// Runs a test body against the real shared-context store on a stand-in window, +// with the voice runner reading the same store. +async function withSharedContext(t, body) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const host = new EventTarget(); Object.defineProperty(globalThis, 'window', { configurable: true, value: host, }); - let h; + const layers = []; t.after(() => { try { - h?.layer.destroy(); + for (const layer of layers) layer.destroy(); } finally { if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); @@ -506,9 +429,9 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed events.push(['selected', detail.id]), ); host.addEventListener('gev:entity-selection-cleared', ({ detail }) => - events.push(['cleared', detail.reason]), + events.push(['cleared', detail.layerId, detail.reason]), ); - // The voice runner reads the same store and bounds its basemap wait. + // The voice runner bounds its basemap wait with the window timers. Object.assign(host, { setTimeout, clearTimeout }); const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); const runner = createGevActionRunner({ @@ -535,55 +458,221 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed assert.equal(result.scope, 'selected'); return result.selected.properties; }; - let next = snapshot([ - { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, - { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, - ]); - h = harness({ getSnapshot: async () => next }, { context: contextStore }); - h.layer.enable(); - await h.layer.update(); - h.layer.setParams({ stormId: 'ep152026' }); - let properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '315', - 'selected storm movement should survive voice summarization', - ); - assert.equal(properties.movementSpeedKt, '12'); - h.layer.setParams({ stormId: 'ep162026' }); - properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '0', - 'a due-north heading is a value, not a missing one', - ); - assert.equal(properties.movementSpeedKt, '0', 'stationary is a value'); - assert.deepEqual(events, [ - ['selected', 'cyclone:ep152026'], - ['selected', 'cyclone:ep162026'], - ]); - assert.deepEqual( - [...contextStore.getContextStore().entities.keys()], - ['cyclone:ep162026'], - ); - await h.layer.update(); - assert.equal(events.length, 2, 'refresh must not announce selection'); + const store = contextStore.getContextStore(); + const open = (feed) => { + const h = harness(feed, { context: contextStore }); + layers.push(h.layer); + return h; + }; + await body({ events, selectedProperties, store, open }); +} - h.layer.setParams({ clear: true }); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { + ...storm('ep162026'), + movement: { directionDegrees: 0, speedKt: 10 }, + }, + ]); + const h = open({ getSnapshot: async () => next }); + h.layer.enable(); + await h.layer.update(); + assert.deepEqual( + events, + [], + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(store.entities.size, 0); - h.layer.setParams({ stormId: 'ep162026' }); - next = snapshot([storm()]); - await h.layer.update(); - assert.deepEqual(events.at(-1), ['cleared', 'evicted']); - assert.equal(contextStore.getContextStore().entities.size, 0); - assert.equal(contextStore.getContextStore().selectedEntityId, null); + h.layer.setParams({ stormId: 'ep152026' }); + const record = store.entities.get('cyclone:ep152026'); + assert.equal(record.layerId, 'weather-cyclones'); + assert.equal(record.source, 'NOAA NHC / CPHC'); + assert.equal(record.label, 'Fifteen-E'); + assert.equal(record.latitude, 15.5); + assert.equal(record.longitude, -125.8); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'PTC'); + assert.equal(properties.classificationName, 'Post-tropical cyclone'); + assert.equal(properties.advisoryNumber, '10'); + assert.equal( + properties.movementDirectionDegrees, + '315', + 'selected storm movement should survive voice summarization', + ); + assert.equal(properties.movementSpeedKt, '12'); + assert.equal( + properties.advisoryUrl, + 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', + ); + assert.equal(properties.staleCacheFetchedAt, undefined); - h.layer.setParams({ stormId: 'ep152026' }); - h.layer.disable(); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); + h.layer.setParams({ stormId: 'ep162026' }); + properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '0', + 'a due-north heading is a value, not a missing one', + ); + assert.equal(properties.movementSpeedKt, '10'); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']); + + next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, + ]); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + properties = await selectedProperties(); + assert.equal( + properties.movementSpeedKt, + '0', + 'stationary is a value, refreshed into the selected record', + ); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual( + events.at(-1), + ['cleared', 'weather-cyclones', 'evicted'], + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + }, + ); +}); + +test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([{ ...storm(), classification: 'XX' }]); + let fail = false; + const h = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return next; + }, + }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'XX'); + assert.equal( + properties.classificationName, + undefined, + 'an unknown code is not given a guessed meaning', + ); + + next = { ...snapshot(), stale: true }; + await h.layer.update(); + properties = await selectedProperties(); + assert.equal(properties.staleCacheFetchedAt, time); + assert.equal(events.length, 1, 'refresh must not announce selection'); + + next = { + ...snapshot([]), + unavailable: true, + reason: 'Cyclone data unavailable', + }; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + + next = snapshot(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(store.selectedEntityId, 'cyclone:ep152026'); + fail = true; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + }, + ); +}); + +test('cyclone clears leave another layer selection and its records alone', async (t) => { + await withSharedContext(t, async ({ events, store, open }) => { + const h = open({ getSnapshot: async () => snapshot() }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + const foreign = {}; + contextStore.registerEntityContext(foreign, { + id: 'flight:abc123', + layerId: 'flights', + label: 'Foreign', + }); + contextStore.selectEntityContext(foreign); + const before = events.length; + + h.layer.setParams({ clear: true }); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.ok(store.entities.has('flight:abc123')); + assert.equal(store.entities.has('cyclone:ep152026'), false); + assert.equal( + events.length, + before, + 'no clear is reported for a foreign selection', + ); + + h.layer.disable(); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.equal(events.length, before); + + // A foreign record removed without clearing its selection: a cyclone + // feed failure must not report that selection as evicted by cyclones. + store.entities.delete('flight:abc123'); + let fail = false; + const other = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return snapshot(); + }, + }); + other.layer.enable(); + await other.layer.update(); + fail = true; + await other.layer.update(); + assert.equal(events.length, before); + }); }); test('refresh preserves selection intent, including explicit clears and missing storms', async () => { From 5e64e599e1c2079d08a7ddf153a4ad66d7c07c37 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:07:56 +0400 Subject: [PATCH 061/174] docs(cyclones): tighten shared-context comments, changelog and notes --- changelog.d/847-cyclone-shared-selection.md | 7 +++---- docs/CURRENT-STATE.md | 13 ++++++------- src/app/layers/cyclones.js | 2 +- src/layers/cyclones/index.js | 4 +--- src/layers/cyclones/index.test.mjs | 6 ++---- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md index 24edf2ebb..150295823 100644 --- a/changelog.d/847-cyclone-shared-selection.md +++ b/changelog.d/847-cyclone-shared-selection.md @@ -1,4 +1,3 @@ -- Explicit cyclone selections publish only the currently selected NHC/CPHC - storm into shared context. Switching storms removes the previous identity - without a false eviction event. Deliberate clears remain distinct from feed - disappearance (kvnloo, #847; reviewed by quanticsoul4772). +- Let the voice assistant read the storm you select in Cyclone advisories: + its official advisory, position, intensity and movement. Clearing the + selection and the storm leaving the feed are reported apart (kvnloo, #847). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6df75c27e..f3b3d3396 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -742,13 +742,12 @@ geographic seams and releases on disable; forecast animation is not implied. NHC movement speed is converted from miles per hour to rounded knots by the proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. -Explicit cyclone selections publish one shared `weather-cyclones` record -with official advisory provenance, source position, the English classification -name and, while the proxy serves its cache, the cache fetch time. Switching -storms selects the replacement before pruning the old record; automatic row -selection never claims global context, and refreshes do not re-announce -selection. Deliberate clear/disable reports `deliberate`; losing the selected -storm from the feed reports `evicted` and assigns no fallback. +Explicitly selecting a storm publishes one shared `weather-cyclones` record: +advisory provenance, source position, the English classification name and, +while the proxy serves its cache, the cache fetch time. The automatic row +selection does not claim shared context; refreshes do not re-announce it. +Clear or disable reports `deliberate`; a storm leaving the feed reports +`evicted`, with no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index d671a11eb..bed75c76e 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -1,7 +1,7 @@ import { createCyclonesLayer } from '../../layers/cyclones/index.js'; import * as context from '../../data/contextStore.js'; -/** Wire cyclone selection into the application shared-context owner. */ +/** Wire cyclone selection to the application shared context. */ export function createApplicationCyclones(options) { const browserContext = typeof window !== 'undefined' ? { context } : {}; return createCyclonesLayer({ diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 8741ad8a3..95655d5d0 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -116,14 +116,12 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - // Keep official storm names verbatim; layerName identifies the layer. label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, // At most 12 properties reach the voice summary; keep the list short. properties: { classification: storm.classification, - // English source meaning; unknown codes stay unexpanded. classificationName: Object.hasOwn( CLASSIFICATION_NAMES, storm.classification, @@ -140,7 +138,7 @@ export function createCyclonesLayer({ movementDirectionDegrees: storm.movement?.directionDegrees, movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, - // Present only while the server serves its cache with NHC unreachable. + // Only while the proxy serves its cache because NHC is unreachable. staleCacheFetchedAt: snapshot.stale && Number.isFinite(snapshot.fetchedAt) ? new Date(snapshot.fetchedAt).toISOString() diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index a3e40fc95..41ecaf3d1 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -405,8 +405,7 @@ function harness( }, }; } -// Runs a test body against the real shared-context store on a stand-in window, -// with the voice runner reading the same store. +// Real shared-context store on a stand-in window; the voice runner reads it. async function withSharedContext(t, body) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const host = new EventTarget(); @@ -657,8 +656,7 @@ test('cyclone clears leave another layer selection and its records alone', async assert.equal(store.selectedEntityId, 'flight:abc123'); assert.equal(events.length, before); - // A foreign record removed without clearing its selection: a cyclone - // feed failure must not report that selection as evicted by cyclones. + // A cyclone feed failure does not evict a dangling foreign selection. store.entities.delete('flight:abc123'); let fail = false; const other = open({ From 5bab5ec26d9f4b13510e50660f06a1895340a4e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:15:27 +0400 Subject: [PATCH 062/174] refactor(cyclones): drop defensive shared-context state and checks --- src/app/layers/cyclones.js | 3 +-- src/layers/cyclones/index.js | 15 ++++++--------- src/layers/cyclones/index.test.mjs | 24 +++--------------------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index bed75c76e..e0cd508b0 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -3,9 +3,8 @@ import * as context from '../../data/contextStore.js'; /** Wire cyclone selection to the application shared context. */ export function createApplicationCyclones(options) { - const browserContext = typeof window !== 'undefined' ? { context } : {}; return createCyclonesLayer({ - ...browserContext, + context: typeof window !== 'undefined' ? context : null, ...options, }); } diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 95655d5d0..90936edf3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -84,7 +84,6 @@ export function createCyclonesLayer({ selectionIntent = 'auto', navigationGeneration = 0, selectedContextStormId = null, - selectedContextEntity = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -98,20 +97,19 @@ export function createCyclonesLayer({ function clearSelectedContext({ evicted = false } = {}) { if (selectedContextStormId) { if (!evicted) - context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); - context?.removeEntityContextsForLayer?.('weather-cyclones'); + context.clearSelectedEntityContextForLayer('weather-cyclones'); + context.removeEntityContextsForLayer('weather-cyclones'); } selectedContextStormId = null; - selectedContextEntity = null; } // Callers publish only while selectedId names a storm in the snapshot. function publishSelectedContext({ announce = false } = {}) { const storm = selected(); if (!context) return; - selectedContextEntity ||= { show: true }; + const entity = {}; const contextId = `cyclone:${storm.id}`; - const record = context.registerEntityContext?.(selectedContextEntity, { + context.registerEntityContext(entity, { id: contextId, layerId: 'weather-cyclones', layerName: 'Cyclone advisories', @@ -145,10 +143,9 @@ export function createCyclonesLayer({ : undefined, }, }); - if (!record) return; selectedContextStormId = storm.id; - if (announce) context.selectEntityContext?.(selectedContextEntity); - context.removeEntityContextsForLayer?.('weather-cyclones', { + if (announce) context.selectEntityContext(entity); + context.removeEntityContextsForLayer('weather-cyclones', { retainIds: new Set([contextId]), }); } diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 41ecaf3d1..e826f392e 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -488,12 +488,7 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed assert.equal(store.entities.size, 0); h.layer.setParams({ stormId: 'ep152026' }); - const record = store.entities.get('cyclone:ep152026'); - assert.equal(record.layerId, 'weather-cyclones'); - assert.equal(record.source, 'NOAA NHC / CPHC'); - assert.equal(record.label, 'Fifteen-E'); - assert.equal(record.latitude, 15.5); - assert.equal(record.longitude, -125.8); + assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E'); let properties = await selectedProperties(); assert.equal(properties.classification, 'PTC'); assert.equal(properties.classificationName, 'Post-tropical cyclone'); @@ -652,24 +647,11 @@ test('cyclone clears leave another layer selection and its records alone', async 'no clear is reported for a foreign selection', ); + // A cyclone clear does not report a dangling foreign selection as evicted. + store.entities.delete('flight:abc123'); h.layer.disable(); assert.equal(store.selectedEntityId, 'flight:abc123'); assert.equal(events.length, before); - - // A cyclone feed failure does not evict a dangling foreign selection. - store.entities.delete('flight:abc123'); - let fail = false; - const other = open({ - getSnapshot: async () => { - if (fail) throw new Error('Network unavailable'); - return snapshot(); - }, - }); - other.layer.enable(); - await other.layer.update(); - fail = true; - await other.layer.update(); - assert.equal(events.length, before); }); }); From 8917eebe1254aa0f7413f3d31370fda01103d106 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 063/174] Tighten failed-entry comments and docs --- docs/CURRENT-STATE.md | 2 +- src/layers/launches/controls.js | 2 +- src/layers/launches/failedEntry.test.mjs | 13 ++++--------- src/layers/launches/ingestion.js | 5 ++--- src/layers/launches/lifecycle.js | 5 +---- src/layers/launches/panel.js | 5 +---- src/layers/launches/replay.js | 2 +- 7 files changed, 11 insertions(+), 23 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57c1be594..456c8d8dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5329,7 +5329,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 95a212a73..019154269 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,7 +12,7 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { - // A failed entry no longer gives back the view it replaced. + // Navigation takes the camera; a failed entry must not restore the old view. layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 9284e4fd2..7878a8e2d 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -1,8 +1,5 @@ -// A Space Missions entry whose mission index never loads must not commit the -// mode. The real manager, Context coordinator and launches ingestion run -// against a local /api/launches that answers 429, so the test follows the -// same failure the browser sees: isolation clears the other layers, the first -// update fails, and the entry rolls back to the layers and view it replaced. +// The real manager, Context coordinator and launches ingestion run against a +// local /api/launches, so a failed first load takes the browser's path. import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; @@ -137,8 +134,7 @@ async function fixture(t) { dataSources: new Cesium.DataSourceCollection(), scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, }; - // Scene presentation is inert; source, ingestion, lifecycle, panel views - // and the satellites dependency run unchanged. + // Only scene presentation is stubbed. const parts = { overlays: { shortMissionLabel: (name) => name, @@ -281,7 +277,6 @@ test('a direct Space Missions entry that gets HTTP 429 rolls back instead of iso assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); - // Nothing is left isolating the workspace. assert.equal( await f.manager.setEnabled('wind', true, { origin: 'user' }), true, @@ -454,7 +449,7 @@ test('a refresh failure after a loaded index keeps the mode and its records', as assert.equal(f.missions.getStats().error, 'HTTP 429'); assert.equal(f.rosters.at(-1).entries.length, 1); - // Leaving a mode that loaded keeps today's camera behaviour. + // Leaving a loaded mode does not restore the pre-entry view. await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); await f.settle(); assert.deepEqual(f.view(), framed); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 171c95a63..9994acb86 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -229,9 +229,8 @@ export function createIngestion({ if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; console.warn('[Data:RocketLaunches] Fetch error:', error); - // Without one usable index there is no mode to enter: report the - // failure so the manager fails the enable closed and Context rolls - // back. A refresh after a loaded index keeps its records instead. + // No index yet: fail the enable so Context rolls back. + // After a loaded index, a failed refresh keeps its records. if (layerState._lastUpdate === null) { parts.panel.renderMissionRoster(); throw error; diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6ef0ce4df..6d87b47d8 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -139,10 +139,7 @@ function sameCameraView(camera, view) { ); } -/** - * Return to the pose before entry while the entry's full-globe flight still - * holds the camera: in flight, or landed with nothing moved since. - */ +/** Restore the pre-entry view if the entry flight still holds the camera. */ function restoreCameraView(viewer, view, landedView) { const camera = viewer?.camera; if (!camera) return; diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index d45d4364c..b93fcb2e5 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -263,10 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) { publishPanel(); } - /** - * The roster entries as the list shows them; null while the first index - * is still loading, and an empty failed roster once that load failed. - */ + /** The roster as listed; null until the first index loads or fails. */ function presentRoster() { if (layerState._lastUpdate === null && !layerState._launches.length) { if (!layerState._lastError) return null; diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index e74c636f9..bf6ad84de 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,7 +314,7 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - // Without a replay the camera is not ours: leave other flights running. + // Cancel only a replay's flight; any other flight runs on. if (stoppedLaunchId && layerState._viewer?.camera) { layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); From 5c4918affb792bbfe4ac863031b3dcd55734a30c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 064/174] Tighten coverage prompt comments and docs --- .../1576-transit-bikeshare-coverage-prompt.md | 2 +- docs/CURRENT-STATE.md | 22 +++++++++---------- src/data/lifecycle.js | 5 ++--- src/layers/bikeshare/controls.js | 13 +++++------ src/layers/bikeshare/coverageStatus.test.mjs | 7 ++---- src/layers/bikeshare/rowRepaint.test.mjs | 2 -- src/layers/bikeshare/viewport.js | 5 +---- src/layers/promptRepaint.test.mjs | 6 ++--- src/layers/transit/gatePrompt.test.mjs | 6 ++--- src/layers/transit/queries.js | 5 ++--- src/layers/transit/viewport.js | 6 ++--- src/ui/statusText.js | 6 ++--- 12 files changed, 31 insertions(+), 54 deletions(-) diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index c3dcbe82a..660c1f30b 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +1,6 @@ - Say why Transit and Bike Share are empty outside coverage. Over a city with no feed or system (London, Tokyo) their Layers rows read - `No feed here yet · 16 regions available` and + `No feed here yet · 17 regions available` and `No bike share here yet · 61 systems available`, or how low to fly, instead of `never`; leaving coverage clears the last-update time (German, @gekh, #1576). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 69d83d759..13cce0050 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1975,18 +1975,16 @@ Mapped-installation zoom guidance appears in the layer row without counting as a global loading failure. Guidance statuses do not suppress independent refresh errors. -Transit and Bike Share report the same kind of guidance when no feed or system -covers the view, instead of an undated `never`. Above their altitude gates the -rows read `GTFS-RT · Fly below 2,850 km to a covered region` and -`GBFS · Fly below 48 km to a covered city`; lower down over a city neither -covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions -available` and `GBFS · No bike share here yet · N systems available`, N being -the enabled feeds and the catalogued systems. The -chip stays ON. A Bike Share city that is loading or failed keeps its own -loading or error text. Leaving every covered feed or system clears the layer's -last-update time, so the row never dates data it no longer draws. The same -English notes are the voice analyst's empty-result notes; both surfaces show -them translated through `translateStatusMessage` (`src/ui/statusText.js`). +Transit and Bike Share say the same when no feed or system covers the view, +instead of an undated `never`. Above their altitude gates the rows read +`GTFS-RT · Fly below 2,850 km to a covered region` and +`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers +(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and +`GBFS · No bike share here yet · N systems available` (covered regions, +catalogued systems). The chip stays ON. A loading or failed Bike Share city +shows its own text. Leaving coverage clears the last-update time. The voice +analyst gets the same English notes; both translate them through +`translateStatusMessage` (`src/ui/statusText.js`). ## Places and CCTV request bounds diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c01926451..3ea02eca9 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2393,9 +2393,8 @@ export class LayerLifecycle { } /** - * A tick's failure belongs to the feed it polled. Once the layer's own stats - * stop reporting one (the camera left that feed), the row stops showing it - * instead of waiting for the next tick. + * Drop a tick failure the layer's stats no longer report (the camera left + * that feed) instead of showing it until the next tick. */ _dropRecoveredRefreshError(layerId) { const entry = layerId ? this.layers.get(layerId) : null; diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 03bb544ab..d90a62b5b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -15,8 +15,7 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, /** - * Keep the manager so camera-driven coverage changes can repaint the row - * before the next status poll. + * Kept so camera moves can repaint the row before the next status poll. * @param {object} dataManager DataLayerManager instance. */ attachDataManager(dataManager) { @@ -84,18 +83,16 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, }; - // No system in range is guidance, not an empty feed: the camera is too - // high, or no catalogued system is near the view. A city that failed - // leaves range empty too, and reports its failure below instead. + // No system in range: the camera is too high or no system is near. A + // failed city also leaves range empty and reports its failure below. if ( layerState._enabled && !loading && layerState._activeCityIds.size === 0 && layerState._failedCityIds.size === 0 ) { - // English data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // English: the analyst's empty note and the row's prompt, both shown + // through translateStatusMessage (ui/statusText.js). const note = layerState._altitudeGateEnabled ? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available` : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs index a9f17e73e..004c75c94 100644 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -1,10 +1,7 @@ // BIKESHARE COVERAGE STATUS — a view with no system says so. // -// Over a city with no catalogued system (London, Tokyo) the layer drew nothing -// and reported nothing, so its row read "ON · — · GBFS · never": the same as a -// feed that had never answered. These cases pin the rule that replaced it: -// with no system in range the layer reports a guidance status that says why, -// and a view it left keeps no date for data it no longer draws. +// With no system in range (London, Tokyo) the layer says why instead of +// "never", and keeps no date for data it no longer draws. // // Run with: npm test import { test } from 'node:test'; diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 144ff5502..5f5538168 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,5 +1,3 @@ -// Bike Share row repaints: entering a city, leaving it or nudging the camera -// mid-load, and leaving a failed city. // Bike Share: after the camera settles, the last row the panel painted must // match what the layer reports now. Each case drives the real viewport, // ingestion, rendering and controls; the manager is a spy that records the diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 008c01333..d4cc44cb6 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,10 +116,7 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } - /** - * Repaint the layer's row now. Its text follows the camera (coverage - * prompt, station count), and the manager tick is 60 s. - */ + /** Repaint the row now: it follows the camera, the manager tick is 60 s. */ function refreshRow() { layerState._dataManager?.refreshLayerStats?.('bikeshare'); } diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs index 563aaeac7..38193ca72 100644 --- a/src/layers/promptRepaint.test.mjs +++ b/src/layers/promptRepaint.test.mjs @@ -1,7 +1,5 @@ -// When the camera changes which coverage prompt a Transit or -// Bike Share row shows, the layer asks the panel to repaint at once, instead -// of leaving the old prompt up until the manager's next tick (Transit 15 s, -// Bike Share 60 s). +// A camera move that changes a Transit or Bike Share coverage prompt repaints +// the row at once, not at the next manager tick (15 s, 60 s). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState as createBikeState } from './bikeshare/state.js'; diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs index 7f439fcdc..dc4c3e863 100644 --- a/src/layers/transit/gatePrompt.test.mjs +++ b/src/layers/transit/gatePrompt.test.mjs @@ -1,7 +1,5 @@ -// The Transit coverage prompt names the altitude the closed gate -// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above -// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" -// asks for something the camera has already done. +// The Transit prompt names the altitude the gate opens at: at 2,900 km over +// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState } from './state.js'; diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 2efe928d5..7a9f2e074 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -245,9 +245,8 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { - // English data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // English: the analyst's empty note and the row's prompt, both shown + // through translateStatusMessage (ui/statusText.js). const note = state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available` : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index a62d49009..38315e4ae 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -111,8 +111,7 @@ export function createViewport({ state, services, parts }) { changed = true; } } - // Leaving coverage leaves nothing on the map to date: a row that read - // "26s ago" over an empty view would claim a feed that is no longer here. + // Nothing is left on the map to date. if (desired.size === 0) state._lastUpdate = null; for (const [feedId, feed] of desired) { if (!state._activeFeeds.has(feedId)) { @@ -124,8 +123,7 @@ export function createViewport({ state, services, parts }) { parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); governorRequestRender('transit-proximity'); - // The row's text follows the camera (coverage prompt, feed list), so it - // repaints now instead of at the next 15 s manager tick. + // Repaint now: the row follows the camera, the manager tick is 15 s. if (changed) state._dataManager?.refreshLayerStats?.('transit'); } diff --git a/src/ui/statusText.js b/src/ui/statusText.js index f4cbb59a0..d13faff7e 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -269,10 +269,8 @@ export function translateStatusLine(text) { /** Thousands separators out of an English number ("3,000" → 3000). */ const englishNumber = (text) => Number(text.replace(/,/g, '')); -// Coverage notes of a layer whose view has no feed, whole lines with a number -// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). -// Their " · " is part of one sentence, so they are matched before the line is -// split into pieces. Keep these in step with the producers. +// Coverage notes (transit/queries.js, bikeshare/controls.js). Their " · " is +// inside one sentence, so they match whole before the line is split. const COVERAGE_NOTE_PATTERNS = [ [ /^Fly below ([\d,]+) km to a covered region$/, From c4298c44501a0e5c21216648f91e6bf9a2eeafd7 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 17:25:01 +0100 Subject: [PATCH 065/174] fix(ghostmaps): keep age-window changes consistent and make the age choice a switch Review fixes on top of #1528: - Narrowing the age window only removes markers; a product loaded with a narrower window is reloaded when the window widens again. Fixes markers missing after 7d -> 1d -> 7d and avoids reparsing every KMZ on narrowing. - A wider window over the same edition keeps the open report card. - Date-only event dates cover their whole UTC day, so 1d still shows yesterday's reports. - The age options are one labelled "Event age" radio switch (row `segmented` control, as FIRMS confidence) instead of toggle chips mixed with the product chips. - Un-nest the per-product age test; QA compares data-source counts with the live defaults instead of a hard-coded 2. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 61 ++++----- scripts/qa-ghostmaps.mjs | 33 +++-- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/ghostMaps/index.js | 86 +++++++----- src/layers/ghostMaps/lifecycle.test.mjs | 175 +++++++++++++++++------- src/layers/ghostMaps/records.js | 6 +- src/layers/ghostMaps/records.test.mjs | 10 +- 17 files changed, 247 insertions(+), 135 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1a7205096..fe837e421 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -250,7 +250,6 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering, selection and card placement. Deterministic operation checks run in `npm test`; wall-clock budgets run in `npm run test:perf`. All 11 locales are supported. - ## Prohibited & Restricted Airspace — FAA special-use airspace The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, @@ -4342,35 +4341,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra requests use Play; a qualified Play-shaped tool call is normalized to Select so its criteria cannot be silently ignored. -| Layer | Source | File | Proxy | Update Interval | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | -| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | -| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | -| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | -| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | -| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | -| 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 | -| 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) | -| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | -| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) | +| Layer | Source | File | Proxy | Update Interval | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | +| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | +| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | +| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | +| 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 | +| 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) | +| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | +| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; @@ -6008,7 +6007,7 @@ request viewbox and continues searching without that geographic preference. - **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. - **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. -- **Event age filter.** The same filter applies to every selected S2 product, including Border Crisis, Iran Picture, Iran Kinetic, Yemen FLOT and Latest Drop. S2 products default to seven days, with row buttons for 1, 3, 7, 14 or 30 days and **All markers**. The choice persists independently of product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage uses seven days. Only the placemark's `Date of Event` (or `EventDate`/`Date`) determines its age, never report dates, dates in prose or KMZ edition/upload dates. Undated, blank or unparseable event dates and future dates stay visible. ArcGIS `M/D/YYYY` dates and optional AM/PM times, plus ISO dates/timestamps, are supported; values without a zone use UTC and date-only values use midnight UTC. The exact cutoff is included. Events age out every minute and during catalog updates, even without a new file or a successful catalog fetch. Widening the window or choosing All markers restores omitted events from the cached KMZ. Removed entities and selected cards disappear together; surviving pick ids stay stable. Product defaults and the update stream are unchanged. +- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All markers**. The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs index 24edcc60b..3c3f0b880 100644 --- a/scripts/qa-ghostmaps.mjs +++ b/scripts/qa-ghostmaps.mjs @@ -203,15 +203,12 @@ async function main() { ); record( 'LIVE: one chip per product, defaults pressed', - chips.filter((chip) => !chip.id.startsWith('age-')).length === - catalog.products.length && - chips - .filter((chip) => !chip.id.startsWith('age-')) - .every( - (chip) => - chip.active === - catalog.products.find((p) => p.id === chip.id)?.defaultOn, - ), + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), ); const stream = await page @@ -229,7 +226,18 @@ async function main() { record('LIVE: the /api/ghostmaps/events stream is open', stream); await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); - const ageChips = chips.filter((chip) => chip.id.startsWith('age-')); + const ageChips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`, + ), + ].map((option) => ({ + id: option.dataset.chipId, + active: option.getAttribute('aria-checked') === 'true', + })), + LAYER_ID, + ); record( 'AGE: seven days is the default with an All markers option', live.ageDays === 7 && @@ -262,7 +270,8 @@ async function main() { const all = await selectAge(null); record( 'AGE: All markers restores old events without duplicate data sources', - all.count > live.count && (await sources(page)).length === 2, + all.count > live.count && + (await sources(page)).length === live.products.length, `${live.count} → ${all.count}`, ); await page.reload({ waitUntil: 'domcontentloaded' }); @@ -279,7 +288,7 @@ async function main() { const one = await selectAge(1); const seven = await selectAge(7); record( - 'AGE: shorter and longer windows change the count and seven days restores the default', + 'AGE: counts follow the window order and seven days restores the default', one.count <= live.count && thirty.count >= live.count && thirty.count <= all.count && diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 456917068..99826e32c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3471,5 +3471,6 @@ "All markers": "كل العلامات", "Show markers of every age": "عرض العلامات من جميع الأعمار", "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", + "Event age": "عمر الحدث", "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d38cc685f..826091ee8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3171,5 +3171,6 @@ "All markers": "Alle Markierungen", "Show markers of every age": "Markierungen jeden Alters anzeigen", "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", + "Event age": "Ereignisalter", "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 45fd1961c..2f1cc02f1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3246,5 +3246,6 @@ "All markers": "Todos los marcadores", "Show markers of every age": "Mostrar marcadores de cualquier antigüedad", "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", + "Event age": "Antigüedad", "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 528d01d9c..ff0b02c01 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3246,5 +3246,6 @@ "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "Event age": "Ancienneté", "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b8204a980..4a26811fa 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3246,5 +3246,6 @@ "All markers": "Tutti gli indicatori", "Show markers of every age": "Mostra gli indicatori di qualsiasi età", "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", + "Event age": "Età degli eventi", "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6f88bd36d..9f111d91d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3096,5 +3096,6 @@ "All markers": "すべてのマーカー", "Show markers of every age": "日付にかかわらずすべてのマーカーを表示", "Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持", + "Event age": "経過日数", "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0f328d23b..ed83191d9 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3246,5 +3246,6 @@ "All markers": "Todos os marcadores", "Show markers of every age": "Mostrar marcadores de qualquer idade", "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", + "Event age": "Idade do evento", "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f3d050aba..82aa91f06 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3321,5 +3321,6 @@ "All markers": "Все метки", "Show markers of every age": "Показать метки любого возраста", "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", + "Event age": "Возраст события", "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index e340f2e12..4d8ff9711 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3321,5 +3321,6 @@ "All markers": "Усі позначки", "Show markers of every age": "Показати позначки будь-якого віку", "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", + "Event age": "Вік події", "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9e088ac3e..a7cb60910 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3096,5 +3096,6 @@ "All markers": "所有标记", "Show markers of every age": "显示所有时间的标记", "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", + "Event age": "事件时间", "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 578fbcf40..ebbf0680c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3096,5 +3096,6 @@ "All markers": "所有標記", "Show markers of every age": "顯示所有時間的標記", "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", + "Event age": "事件時間", "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記" } diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 7899c26b5..7d8716483 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -208,13 +208,13 @@ export function createGhostMapsLayer({ return (_catalog?.products || []).filter(wanted).map(({ id }) => id); } - function unload(id) { + function unload(id, keepSelection = false) { const item = _loaded.get(id); if (!item) return; _loaded.delete(id); _viewer?.dataSources?.remove(item.dataSource, true); item.urls.forEach((url) => URL.revokeObjectURL(url)); - if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection(); } // Remove expired entities, rather than hiding them: horizon culling must @@ -423,8 +423,9 @@ export function createGhostMapsLayer({ // Cull before the first frame so far-side pins never flash. _horizon.update(next.pins); await _viewer.dataSources.add(next.dataSource); - // The new edition is on the globe before the old one leaves. - unload(product.id); + // The new edition is on the globe before the old one leaves. A wider + // window over the same edition keeps indices, so the card stays. + unload(product.id, loaded?.version === next.version); _loaded.set(product.id, next); } catch (error) { if (signal.aborted) return; @@ -473,9 +474,16 @@ export function createGhostMapsLayer({ /* private window or blocked storage: keep the session choice */ } expireEvents(); + // Narrowing only removes markers. A product loaded with a narrower window + // keeps its old ageDays, so sync() restores omitted placemarks from the + // cached KMZ even though its upstream version has not changed. + for (const item of _loaded.values()) + if ( + item.ageDays === null || + (ageDays !== null && item.ageDays >= ageDays) + ) + item.ageDays = ageDays; notifyRow(); - // A wider window must restore previously omitted placemarks from the - // cached KMZ, even though its upstream version has not changed. return sync(); } @@ -730,8 +738,10 @@ export function createGhostMapsLayer({ }) : t('Connecting to the S2 GhostMaps repository…'); return { - chips: [ - ...GHOSTMAPS_AGE_OPTIONS.map((days) => ({ + segmented: { + id: 'age', + label: t('Event age'), + options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({ id: `age-${days ?? 'all'}`, label: days === null @@ -746,36 +756,36 @@ export function createGhostMapsLayer({ active: days === _ageDays, onClick: () => setAgeDays(days), })), - ...products.map((product) => { - const loaded = _loaded.get(product.id); - const failed = _failed.get(product.id); - return { - id: product.id, - label: t(PRODUCT_LABELS[product.id] || product.title), - active: wanted(product), - busy: _loading.has(product.id), - state: failed ? 'error' : undefined, - color: PRODUCT_COLORS[product.id], - title: [ - product.fileTitle, - loaded - ? tn( - loaded.features.size, - '{count} placemark', - '{count} placemarks', - { - count: formatNumber(loaded.features.size), - }, - ) - : null, - failed ? t(failed) : null, - ] - .filter(Boolean) - .join(' · '), - onClick: () => toggleProduct(product.id), - }; - }), - ], + }, + chips: products.map((product) => { + const loaded = _loaded.get(product.id); + const failed = _failed.get(product.id); + return { + id: product.id, + label: t(PRODUCT_LABELS[product.id] || product.title), + active: wanted(product), + busy: _loading.has(product.id), + state: failed ? 'error' : undefined, + color: PRODUCT_COLORS[product.id], + title: [ + product.fileTitle, + loaded + ? tn( + loaded.features.size, + '{count} placemark', + '{count} placemarks', + { + count: formatNumber(loaded.features.size), + }, + ) + : null, + failed ? t(failed) : null, + ] + .filter(Boolean) + .join(' · '), + onClick: () => toggleProduct(product.id), + }; + }), legend: [], info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`, infoTitle: t( diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index 0b3a580e6..7d1a067e6 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -203,7 +203,9 @@ const dated = (name, value, label = 'Date') => ({ }); const ageChip = (layer, days) => - layer.getRowControls().chips.find(({ id }) => id === `age-${days ?? 'all'}`); + layer + .getRowControls() + .segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`); test('age controls default to seven days and widening restores filtered and expired reports', async (t) => { let at = Date.UTC(2026, 10, 3, 12); @@ -233,10 +235,16 @@ test('age controls default to seven days and widening restores filtered and expi await ageChip(layer, null).onClick(); assert.equal(layer.getStats().count, 4); assert.equal(mounted.size, 1, 'filter changes never duplicate data sources'); - const ageChips = layer - .getRowControls() - .chips.filter(({ id }) => id.startsWith('age-')); - assert.equal(ageChips.filter(({ active }) => active).length, 1); + const controls = layer.getRowControls(); + assert.equal( + controls.segmented.options.filter(({ active }) => active).length, + 1, + ); + assert.equal( + controls.chips.some(({ id }) => id.startsWith('age-')), + false, + 'age options are one switch, not product chips', + ); assert.equal(ageChip(layer, null).active, true); }); @@ -309,49 +317,49 @@ test('a filter changed during a pending load never installs the superseded filte created.length - 1, 'discarded load releases icons', ); +}); - test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => { - for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { - await t.test(product.id, async (t) => { - const { layer, viewer, mounted } = fixture(t, { - now: () => Date.UTC(2026, 10, 3, 12), - products: [{ ...product, version: 'a'.repeat(40) }], - reports: [ - dated( - 'Old event', - '10/1/2026', - product.id === 'cip' ? 'Date of Event' : 'Date', - ), - dated('Recent event', '11/2/2026'), - { name: 'Undated reference feature' }, - ], - }); - await layer.update(viewer); - if (!product.defaultOn) { - layer - .getRowControls() - .chips.find(({ id }) => id === product.id) - .onClick(); - await layer.update(viewer); - } - assert.deepEqual(layer.getStats().products, [product.id]); - assert.equal(layer.getStats().count, 2); - assert.deepEqual( - layer.getAnalystRecords().map(({ name }) => name), - ['Recent event', 'Undated reference feature'], - ); - await ageChip(layer, null).onClick(); - assert.equal( - layer.getStats().count, - 3, - "All markers restores this product's old events", - ); - await ageChip(layer, 7).onClick(); - assert.equal(layer.getStats().count, 2); - assert.equal(mounted.size, 1); +test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => { + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + await t.test(product.id, async (t) => { + const { layer, viewer, mounted } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + products: [{ ...product, version: 'a'.repeat(40) }], + reports: [ + dated( + 'Old event', + '10/1/2026', + product.id === 'cip' ? 'Date of Event' : 'Date', + ), + dated('Recent event', '11/2/2026'), + { name: 'Undated reference feature' }, + ], }); - } - }); + await layer.update(viewer); + if (!product.defaultOn) { + layer + .getRowControls() + .chips.find(({ id }) => id === product.id) + .onClick(); + await layer.update(viewer); + } + assert.deepEqual(layer.getStats().products, [product.id]); + assert.equal(layer.getStats().count, 2); + assert.deepEqual( + layer.getAnalystRecords().map(({ name }) => name), + ['Recent event', 'Undated reference feature'], + ); + await ageChip(layer, null).onClick(); + assert.equal( + layer.getStats().count, + 3, + "All markers restores this product's old events", + ); + await ageChip(layer, 7).onClick(); + assert.equal(layer.getStats().count, 2); + assert.equal(mounted.size, 1); + }); + } }); test('default products filter old event dates while keeping recent, future and undated markers', async (t) => { @@ -448,7 +456,8 @@ test('unchanged cached products age out on the timer, clear selected cards and k test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); - let at = Date.UTC(2026, 10, 3); + // 10/27 is a whole UTC day: at 7d it expires when 11/3 ends. + let at = Date.UTC(2026, 10, 4) - 30_000; const polygon = element('Polygon', [ element('outerBoundaryIs', [ element('LinearRing', [ @@ -477,3 +486,75 @@ test('expiry removes every geometry and polygon outline belonging to an old repo assert.equal(ds.entities.values.length, 0); assert.equal(layer.getStats().count, 0); }); + +test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => { + const { layer, viewer, mounted } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')], + }); + await layer.update(viewer); + assert.equal(layer.getStats().count, 2); + await ageChip(layer, 1).onClick(); + assert.equal(layer.getStats().count, 1); + await ageChip(layer, 7).onClick(); + await layer.update(viewer); + assert.equal(layer.getStats().count, 2, '7d must show both reports again'); + assert.equal(mounted.size, 1); +}); + +test('two quick clicks, 1d then 7d, restore the report', async (t) => { + const at = Date.UTC(2026, 10, 3, 12); + const { layer, viewer } = fixture(t, { + now: () => at, + reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')], + }); + await layer.update(viewer); + const a = ageChip(layer, 1).onClick(); + const b = ageChip(layer, 7).onClick(); + await a; + await b; + await layer.update(viewer); + assert.equal(layer.getStats().count, 2); +}); + +test('narrowing the window keeps the open card of a surviving marker', async (t) => { + let click; + const cards = []; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')], + controls: { + overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction: (fn) => { + click = fn; + }, + destroy() {}, + }), + picking: { resolvePickId: (picked) => picked }, + }, + }); + await layer.update(viewer); + await ageChip(layer, 30).onClick(); + assert.equal(layer.getStats().count, 2); + viewer.scene.pick = () => 'ghostmaps:cip:0:0'; + click({ position: { x: 0, y: 0 } }); + assert.equal(cards.at(-1).length, 1); + await ageChip(layer, 3).onClick(); + assert.equal(layer.getStats().count, 1); + assert.equal( + cards.at(-1).length, + 1, + 'card of the surviving marker stays open', + ); +}); + +test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => { + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 6, 1), + reports: [dated('Yesterday', '11/5/2026')], + }); + await layer.update(viewer); + await ageChip(layer, 1).onClick(); + assert.equal(layer.getStats().count, 1); +}); diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js index 3c307fa63..62e48d3b3 100644 --- a/src/layers/ghostMaps/records.js +++ b/src/layers/ghostMaps/records.js @@ -73,7 +73,9 @@ export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7; export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]); /** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC - * when no zone is supplied. Reject calendar rollovers instead of guessing. */ + * when no zone is supplied. A date without a time means the whole UTC day, so + * it resolves to the day's last millisecond: "1d" still shows yesterday's + * reports. Reject calendar rollovers instead of guessing. */ export function parseGhostMapsEventDate(value) { const input = String(value ?? '').trim(); const us = @@ -113,7 +115,7 @@ export function parseGhostMapsEventDate(value) { const parsed = Date.parse(zoned); return Number.isFinite(parsed) ? parsed : null; } - return at; + return us?.[4] ? at : at + 86400_000 - 1; } /** The event's date, never its report/upload date or dates mentioned in prose. */ diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs index a28d1f36d..f674fb1cb 100644 --- a/src/layers/ghostMaps/records.test.mjs +++ b/src/layers/ghostMaps/records.test.mjs @@ -14,15 +14,15 @@ import { test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => { for (const [value, iso] of [ - ['10/28/2026', '2026-10-28T00:00:00Z'], + ['10/28/2026', '2026-10-28T23:59:59.999Z'], ['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'], ['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'], ['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'], ['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'], - ['2026-10-28', '2026-10-28T00:00:00Z'], + ['2026-10-28', '2026-10-28T23:59:59.999Z'], ['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'], ['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'], - ['2/29/2024', '2024-02-29T00:00:00Z'], + ['2/29/2024', '2024-02-29T23:59:59.999Z'], ]) assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value); for (const value of [ @@ -52,7 +52,7 @@ test('event date fields take precedence over report dates and prose, even after row('Date', '10/30/2026') + row('Date of Event', '10/28/2026') }`; - assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 28)); + assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1); assert.equal( ghostMapsEventDate( `${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}
`, @@ -61,7 +61,7 @@ test('event date fields take precedence over report dates and prose, even after ); assert.equal( ghostMapsEventDate(`${row('Date_Of_Event', '10/28/2026')}
`), - Date.UTC(2026, 9, 28), + Date.UTC(2026, 9, 29) - 1, ); assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null); assert.equal( From 91ab9b5811c93cb66fbd292fff66f051e1854ed7 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 17:37:29 +0100 Subject: [PATCH 066/174] fix(ghostmaps): follow-up review fixes for the age switch - Keep the open card across a widened reload only when its index still holds the same report; the server may answer a pinned version with newer bytes. - Narrowing to a window the loaded data already covers clears a failed wider reload, so the row no longer stays in error. - Label the every-age option "All" (contextual) so the six-option switch fits the row in every locale; tooltip and info line keep "All markers". Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/ghostMaps/index.js | 24 +++++++++--- src/layers/ghostMaps/lifecycle.test.mjs | 51 +++++++++++++++++++++++++ 14 files changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fe837e421..0e1cd7889 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6007,7 +6007,7 @@ request viewbox and continues searching without that geographic preference. - **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. - **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. -- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All markers**. The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown. +- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 99826e32c..f1d9903ed 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3471,6 +3471,7 @@ "All markers": "كل العلامات", "Show markers of every age": "عرض العلامات من جميع الأعمار", "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", + "event age filter|All": "الكل", "Event age": "عمر الحدث", "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 826091ee8..50715c99f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3171,6 +3171,7 @@ "All markers": "Alle Markierungen", "Show markers of every age": "Markierungen jeden Alters anzeigen", "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", + "event age filter|All": "Alle", "Event age": "Ereignisalter", "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2f1cc02f1..6adb40da9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3246,6 +3246,7 @@ "All markers": "Todos los marcadores", "Show markers of every age": "Mostrar marcadores de cualquier antigüedad", "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", + "event age filter|All": "Todos", "Event age": "Antigüedad", "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ff0b02c01..788210f89 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3246,6 +3246,7 @@ "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "event age filter|All": "Tous", "Event age": "Ancienneté", "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4a26811fa..567c3c6ed 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3246,6 +3246,7 @@ "All markers": "Tutti gli indicatori", "Show markers of every age": "Mostra gli indicatori di qualsiasi età", "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", + "event age filter|All": "Tutti", "Event age": "Età degli eventi", "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9f111d91d..5b96cb3a8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3096,6 +3096,7 @@ "All markers": "すべてのマーカー", "Show markers of every age": "日付にかかわらずすべてのマーカーを表示", "Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持", + "event age filter|All": "すべて", "Event age": "経過日数", "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ed83191d9..20bbea5aa 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3246,6 +3246,7 @@ "All markers": "Todos os marcadores", "Show markers of every age": "Mostrar marcadores de qualquer idade", "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", + "event age filter|All": "Todos", "Event age": "Idade do evento", "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 82aa91f06..ca4813a5a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3321,6 +3321,7 @@ "All markers": "Все метки", "Show markers of every age": "Показать метки любого возраста", "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", + "event age filter|All": "Все", "Event age": "Возраст события", "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4d8ff9711..9b7ae1f61 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3321,6 +3321,7 @@ "All markers": "Усі позначки", "Show markers of every age": "Показати позначки будь-якого віку", "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", + "event age filter|All": "Усі", "Event age": "Вік події", "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a7cb60910..9b17dc66f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3096,6 +3096,7 @@ "All markers": "所有标记", "Show markers of every age": "显示所有时间的标记", "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", + "event age filter|All": "全部", "Event age": "事件时间", "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index ebbf0680c..e3d9b716c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3096,6 +3096,7 @@ "All markers": "所有標記", "Show markers of every age": "顯示所有時間的標記", "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", + "event age filter|All": "全部", "Event age": "事件時間", "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記" } diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 7d8716483..79971c715 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -423,9 +423,18 @@ export function createGhostMapsLayer({ // Cull before the first frame so far-side pins never flash. _horizon.update(next.pins); await _viewer.dataSources.add(next.dataSource); - // The new edition is on the globe before the old one leaves. A wider - // window over the same edition keeps indices, so the card stays. - unload(product.id, loaded?.version === next.version); + // The new edition is on the globe before the old one leaves. The card + // stays only if its index still holds the same report: the server + // may answer a pinned version with newer bytes. + const index = Number(_selectedKey?.split(':')[1]); + const before = loaded?.features.get(index); + const after = next.features.get(index); + unload( + product.id, + Boolean(before && after) && + before.name === after.name && + before.description === after.description, + ); _loaded.set(product.id, next); } catch (error) { if (signal.aborted) return; @@ -477,12 +486,15 @@ export function createGhostMapsLayer({ // Narrowing only removes markers. A product loaded with a narrower window // keeps its old ageDays, so sync() restores omitted placemarks from the // cached KMZ even though its upstream version has not changed. - for (const item of _loaded.values()) + for (const [id, item] of _loaded) if ( item.ageDays === null || (ageDays !== null && item.ageDays >= ageDays) - ) + ) { item.ageDays = ageDays; + // A failed wider reload no longer matters; a stale edition retries. + _failed.delete(id); + } notifyRow(); return sync(); } @@ -745,7 +757,7 @@ export function createGhostMapsLayer({ id: `age-${days ?? 'all'}`, label: days === null - ? t('All markers') + ? t('All', { context: 'event age filter' }) : t('{days}d', { days: formatNumber(days) }), title: days === null diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index 7d1a067e6..ae9f63911 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -558,3 +558,54 @@ test('a report dated yesterday is shown by 1d one hour into today (UTC)', async await ageChip(layer, 1).onClick(); assert.equal(layer.getStats().count, 1); }); + +test('a widened reload that returns a different report at the selected index closes its card', async (t) => { + let click; + const cards = []; + const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')]; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports, + controls: { + overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction: (fn) => { + click = fn; + }, + destroy() {}, + }), + picking: { resolvePickId: (picked) => picked }, + }, + }); + await layer.update(viewer); + viewer.scene.pick = () => 'ghostmaps:cip:0:0'; + click({ position: { x: 0, y: 0 } }); + assert.equal(cards.at(-1).length, 1); + // The server answers the pinned version with a newer edition's bytes. + reports[0] = dated('Replacement', '11/2/2026'); + await ageChip(layer, 30).onClick(); + assert.equal(layer.getStats().count, 2); + assert.equal( + cards.at(-1).length, + 0, + 'a different report must not keep the old card', + ); +}); + +test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => { + let fail = false; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')], + beforeFileReturns: () => { + if (fail) throw new Error('network down'); + }, + }); + await layer.update(viewer); + fail = true; + await ageChip(layer, 30).onClick(); + assert.ok(layer.getStats().error, 'the wider reload failed'); + await ageChip(layer, 3).onClick(); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().count, 1); +}); From 8a7402d80c4f1e14d48918e38a75e9306263e089 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 18:14:16 +0100 Subject: [PATCH 067/174] fix(street-level): keep explicit provider choices on filter-only updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on top of #1601: - Only redirect a provider the params switch on to a keyless source, so a filter-only setParams (360°, SINCE) no longer moves an explicitly chosen keyless Mapillary to KartaView. - Keyless QA gate: assert the new KartaView default and that the Mapillary chip explains its key instead of switching (it used to expect KEY REQUIRED). - Tests: Graph-proxy keyReady, filter-only updates; shared test helper. - README and CURRENT-STATE describe the keyless default. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/CURRENT-STATE.md | 8 +- scripts/qa-street-level.mjs | 30 +++++- src/layers/streetLevel/index.js | 18 ++-- src/layers/streetLevel/index.test.mjs | 91 +++++++++---------- .../streetLevel/providers/mapillary/index.js | 2 +- 6 files changed, 89 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c9eb6e52f..32981b337 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED > Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key). -**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. +**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3540c3096..7bfb74470 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4429,8 +4429,10 @@ provider draws in one colour (`PROVIDER_COLORS` in sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id, e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google Street View, KartaView and Panoramax are registered. Without -`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are -disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 +`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first +keyless provider (KartaView), including a restored "Mapillary on" unless the link +switches the keyless providers off; filter-only updates never change the source. +With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. @@ -4444,7 +4446,7 @@ the camera, nearest first, at most 9 (the halves of a view across the date line count a shared tile once). Lines are draped `GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain and Google 3D tiles alike; cones and the photo marker are ground-clamped -billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel +billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, says which key to add and disables the filters; a rejected key reads KEY REJECTED and stops tile requests until the layer goes off; a 429 pauses them for its Retry-After. diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 0dd841acf..4b5a0d958 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -296,6 +296,22 @@ async function expandPanel(page) { ); } +/** A keyless bundle opens on the first keyless source with live controls. */ +async function assertKeylessDefault(page) { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('street-level', true, { + origin: 'user', + }), + ); + await uiUntil(page, litAlone, 'kartaview'); + const shown = await page.evaluate(() => ({ + status: document.getElementById('sl-status').textContent, + disabled: document.getElementById('sl-controls').disabled, + })); + assert.notEqual(shown.status, 'KEY REQUIRED'); + assert.equal(shown.disabled, false, 'controls stay live'); +} + async function assertKeyRequired(page) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { @@ -529,8 +545,16 @@ async function main() { await expandPanel(page); if (!configured) { - await step('keyless server: KEY REQUIRED gates the controls', () => - assertKeyRequired(page), + await step('keyless server: opens on KartaView, not KEY REQUIRED', () => + assertKeylessDefault(page), + ); + await step( + 'keyless server: the MAPILLARY chip explains its key instead of switching', + async () => { + await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(litAlone(await getUI(page), 'kartaview')); + }, ); skip('no Mapillary key', 'the keyed steps'); } else { @@ -751,6 +775,8 @@ async function main() { await park(page); await fixtureStep( + // The fixture bundle carries a token, so this covers a keyless + // server behind a keyed bundle; the keyless default is checked above. 'keyless server (second page): KEY REQUIRED gates the controls', async () => { const keyless = await open( diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 8d332c227..9c22b95a5 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -126,7 +126,7 @@ export function createStreetLevelLayer({ }); } - /** `def`, or the first keyless provider not refused when `def` has no key. */ + /** `def` if its key is ready, else the first keyless provider not refused. */ function keylessInstead(def, refused = () => false) { if (def.keyReady?.() !== false) return def; return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def; @@ -594,15 +594,17 @@ export function createStreetLevelLayer({ providerIds: state.providers.keys(), filter: state.filter, }); - // Links and stored state omit Mapillary when it is on (the codec - // default), so a keyless install would restore a source it cannot draw. + // A restored "Mapillary on" (the codec default) would light a source a + // keyless install cannot draw. Only redirect a provider the params switch + // on: a filter-only update keeps whatever the user chose. const asked = litByParams(decoded.providers); const lit = - asked && - keylessInstead( - state.providers.get(asked).def, - (id) => decoded.providers.get(id) === false, - ).id; + asked && decoded.providers.get(asked) === true + ? keylessInstead( + state.providers.get(asked).def, + (id) => decoded.providers.get(id) === false, + ).id + : asked; const before = litIds(); for (const [id, entry] of state.providers) if (id !== lit) switchProvider(entry, false); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 93fc96d23..0a26df1f1 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1128,63 +1128,58 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( assert.match(view.meta, /Google Street View needs the Google 3D map/); }); -test('without a Mapillary key the first keyless source is lit instead', async (t) => { - const kartaViewSource = { - getTile: async () => ({ lines: [] }), - nearestPhotos: async () => [], - getPhoto: async () => { - throw new Error('KartaView has no such photo'); - }, - getSequencePhotos: async () => ({ photos: [], hasMore: false }), - probeImage: async () => true, - }; - const lit = (hasToken) => { - const layer = createStreetLevelLayer({ - providers: [ - createMapillaryProvider({ - source: fakeMapillarySource({ hasToken: () => hasToken }), - photoViewer: fakePhotoViewer(), - }), - createKartaViewProvider({ source: kartaViewSource }), - ], - }); - t.after(() => layer.destroy()); - return layer - .getUIState() - .providers.filter((provider) => provider.on) - .map((provider) => provider.id); - }; - assert.deepEqual(lit(false), ['kartaview']); - assert.deepEqual(lit(true), ['mapillary']); -}); - -test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => { +/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */ +function keylessLayer(t, mapillary = { hasToken: () => false }) { const layer = createStreetLevelLayer({ providers: [ createMapillaryProvider({ - source: fakeMapillarySource({ hasToken: () => false }), + source: fakeMapillarySource(mapillary), photoViewer: fakePhotoViewer(), }), - createKartaViewProvider({ - source: { - getTile: async () => ({ lines: [] }), - nearestPhotos: async () => [], - getPhoto: async () => null, - getSequencePhotos: async () => ({ photos: [], hasMore: false }), - probeImage: async () => true, - }, - }), + createKartaViewProvider(), ], }); t.after(() => layer.destroy()); - const litIds = () => - layer - .getUIState() - .providers.filter((provider) => provider.on) - .map((provider) => provider.id); + return layer; +} +const litProviders = (layer) => + layer + .getUIState() + .providers.filter((provider) => provider.on) + .map((provider) => provider.id); + +test('without a Mapillary key the first keyless source is lit instead', (t) => { + assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']); + assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']); + // A token-adding Graph proxy counts as a key. + const proxied = keylessLayer(t, { + hasToken: () => false, + graphEndpoint: '/api/mapillary/graph', + }); + assert.deepEqual(litProviders(proxied), ['mapillary']); +}); + +test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => { + const layer = keylessLayer(t); layer.setParams({ mapillary: true, providerSwitches: '' }); - assert.deepEqual(litIds(), ['kartaview']); + assert.deepEqual(litProviders(layer), ['kartaview']); // A link that switches KartaView off keeps its word. layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); - assert.deepEqual(litIds(), ['mapillary']); + assert.deepEqual(litProviders(layer), ['mapillary']); +}); + +test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => { + const layer = keylessLayer(t); + layer.setParams({ pano: 'pano' }); + assert.deepEqual(litProviders(layer), ['kartaview']); + // What the MAPILLARY chip sends, and what stored state restores. + layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' }); + for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) { + layer.setParams(params); + assert.deepEqual( + litProviders(layer), + ['mapillary'], + JSON.stringify(params), + ); + } }); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index c2e9e7850..b930405f1 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -74,7 +74,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) { name: MAPILLARY_NAME, label: MAPILLARY_LABEL, requiresKeyId: MAPILLARY_KEY_ID, - /** A browser token or a token-adding Graph proxy; else KEY REQUIRED. */ + /** A browser token, or a Graph proxy that adds one server-side. */ keyReady: () => source.hasToken?.() !== false || Boolean(source.graphEndpoint), pickPrefix: PICK_PREFIX.root, From f7b9a2e5af07fc285af4f1273346c307fd605a1c Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 18:20:47 +0100 Subject: [PATCH 068/174] test(street-level): keyless QA branch handles a keyed bundle behind a keyless server Co-Authored-By: Claude Opus 5.5 --- scripts/qa-street-level.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 4b5a0d958..982c4e581 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -544,7 +544,13 @@ async function main() { ); await expandPanel(page); - if (!configured) { + if (!configured && litAlone(await getUI(page), 'mapillary')) { + // A keyed bundle behind a server without the token keeps Mapillary. + await step('keyless server: KEY REQUIRED gates the controls', () => + assertKeyRequired(page), + ); + skip('no Mapillary key', 'the keyed steps'); + } else if (!configured) { await step('keyless server: opens on KartaView, not KEY REQUIRED', () => assertKeylessDefault(page), ); From 096b891099e15cefd9c08ea982fcb8e323fff80f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 069/174] Simplify failed-entry camera handling and tests --- .../1578-space-missions-failed-entry.md | 6 +- docs/CURRENT-STATE.md | 2 +- src/layers/launches/controls.js | 2 - src/layers/launches/failedEntry.test.mjs | 107 +++++++----------- src/layers/launches/ingestion.js | 1 - src/layers/launches/lifecycle.js | 9 +- 6 files changed, 49 insertions(+), 78 deletions(-) diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md index 9ad1630db..cb910d30e 100644 --- a/changelog.d/1578-space-missions-failed-entry.md +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -1,5 +1,3 @@ - Space Missions no longer locks the workspace when its mission index fails to - load. A 429, timeout or bad payload on `/api/launches` now rolls the entry - back to the previous layers and camera view instead of leaving an empty, - isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` - rather than loading forever (German, @gekh, #1578). + load: the entry rolls back to the previous layers and view, and the roster + reports the index as unavailable (German, @gekh, #1578). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 456c8d8dc..98b61b3b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5329,7 +5329,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 019154269..b0dc035cb 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,8 +12,6 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { - // Navigation takes the camera; a failed entry must not restore the old view. - layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 7878a8e2d..0b5a20e04 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -1,8 +1,7 @@ // The real manager, Context coordinator and launches ingestion run against a -// local /api/launches, so a failed first load takes the browser's path. +// stubbed /api/launches, so a failed first load takes the browser's path. import test from 'node:test'; import assert from 'node:assert/strict'; -import http from 'node:http'; import * as Cesium from 'cesium'; import { DataLayerManager } from '../../data/manager.js'; import { ContextControls } from '../../ui/contextControls.js'; @@ -92,24 +91,15 @@ function fakeCamera() { async function fixture(t) { const upstream = { status: 429, results: [launchRecord] }; - const server = http.createServer(async (req, res) => { - if (req.url === '/api/celestrak/active') { - res.end(''); - return; - } - if (upstream.hold) await upstream.hold; - if (upstream.status !== 200) { - res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); - return; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ results: upstream.results })); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const origin = `http://127.0.0.1:${server.address().port}`; const source = createLaunchSource({ - fetchImpl: (url, options) => fetch(new URL(url, origin), options), + async fetchImpl(url) { + if (url === '/api/celestrak/active') return new Response(''); + if (upstream.hold) await upstream.hold; + return Response.json( + upstream.status === 200 ? { results: upstream.results } : {}, + { status: upstream.status }, + ); + }, }); const services = { @@ -200,14 +190,13 @@ async function fixture(t) { manager.register(missions); missions.attachDataManager(manager); - const toasts = []; const controls = new ContextControls({ elements: {}, installations: {}, actions: { getCockpit: () => null, claimVisualAuthority: noop, - showToast: (message) => toasts.push(message), + showToast: noop, setClearBusy: noop, syncDetection: noop, scheduleLayout: noop, @@ -223,8 +212,6 @@ async function fixture(t) { for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind']) await manager.setEnabled(id, false); await missions.destroy(viewer); - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); }); const enabledIds = () => [...manager.getEnabledLayerIds()].sort(); @@ -249,56 +236,46 @@ async function fixture(t) { state, camera, rosters, - toasts, enabledIds, view, settle, }; } -test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => { - const f = await fixture(t); - assert.equal( - await f.manager.setEnabled('earthquakes', true, { origin: 'user' }), - true, - ); - const before = f.view(); +const entries = { + direct: (f) => + f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + 'Context-panel': async (f) => + ( + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }) + ).ok, +}; - const entered = await f.manager.setEnabled('rocket-launches', true, { - origin: 'user', +for (const [path, enter] of Object.entries(entries)) + test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + + const entered = await enter(f); + await f.settle(); + + assert.equal(entered, false, 'a failed mission index fails the enable'); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.equal(f.controls.getContextModeState().active, false); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); + assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); + + assert.equal( + await f.manager.setEnabled('wind', true, { origin: 'user' }), + true, + 'an unrelated layer is not blocked after the failed entry', + ); }); - await f.settle(); - - assert.equal(entered, false, 'a failed mission index fails the enable'); - assert.deepEqual(f.enabledIds(), ['earthquakes']); - assert.equal(f.controls.getContextModeState().mode, null); - assert.equal(f.controls.getContextModeState().active, false); - assert.equal(f.missions.getStats().error, 'HTTP 429'); - assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); - assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); - - assert.equal( - await f.manager.setEnabled('wind', true, { origin: 'user' }), - true, - 'an unrelated layer is not blocked after the failed entry', - ); -}); - -test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => { - const f = await fixture(t); - await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); - const before = f.view(); - - const result = await f.controls.setContextMode('space-missions', { - claimVisualAuthority: false, - }); - await f.settle(); - - assert.equal(result.ok, false); - assert.deepEqual(f.enabledIds(), ['earthquakes']); - assert.equal(f.controls.getContextModeState().mode, null); - assert.deepEqual(f.view(), before); -}); // Holds the /api/launches reply until the returned release is called. function holdReply(f) { diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 9994acb86..e0366a1e7 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -235,7 +235,6 @@ export function createIngestion({ parts.panel.renderMissionRoster(); throw error; } - layerState._entryCameraView = null; } } } diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6d87b47d8..a8de16d06 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -241,12 +241,12 @@ export function createLifecycle({ // A new attempt at an index that never loaded starts from loading. layerState._lastError = null; parts.panel.renderMissionRoster(); + // Kept until the mission index loads, so an entry that never does + // gives the view back. A newer flight takes the camera and drops it. + layerState._entryCameraView = captureCameraView(layerState._viewer); + layerState._entryLandedView = null; } - // Kept until the mission index loads, so an entry that never does - // gives the view back. A newer flight takes the camera and drops it. const token = layerState._lifecycleToken; - layerState._entryCameraView = captureCameraView(layerState._viewer); - layerState._entryLandedView = null; const flying = parts.selection.focusFullGlobe(layerState._viewer, { complete: () => { if (token === layerState._lifecycleToken) @@ -304,7 +304,6 @@ export function createLifecycle({ layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; - layerState._entryCameraView = null; layerState._lifecycleToken++; layerState._updateDirty = false; await parts.ingestion.restoreSatelliteDependency(); From ea76fc4bc07327b9d8297aa6b2663660ca0d5947 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 070/174] Fold duplicate coverage tests into their neighbours --- .../1576-transit-bikeshare-coverage-prompt.md | 8 +- src/data/manager.js | 12 -- src/layers/bikeshare/coverageStatus.test.mjs | 121 ------------------ src/layers/bikeshare/rowRepaint.test.mjs | 79 +++++++++++- src/layers/bikeshare/viewport.js | 4 +- src/layers/promptRepaint.test.mjs | 120 ----------------- src/layers/transit/gatePrompt.test.mjs | 64 --------- src/layers/transit/lifecycle.test.mjs | 12 ++ .../transit/refreshErrorRepaint.test.mjs | 2 + 9 files changed, 94 insertions(+), 328 deletions(-) delete mode 100644 src/layers/bikeshare/coverageStatus.test.mjs delete mode 100644 src/layers/promptRepaint.test.mjs delete mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index 660c1f30b..4d13d8d3e 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +1,4 @@ - Say why Transit and Bike Share are empty outside coverage. Over a city with - no feed or system (London, Tokyo) their Layers rows read - `No feed here yet · 17 regions available` and - `No bike share here yet · 61 systems available`, or how low to fly, instead - of `never`; leaving coverage clears the last-update time (German, @gekh, - #1576). + no feed or system (London, Tokyo) their Layers rows say so and count the + covered regions or systems, or say how low to fly, instead of `never`; + leaving coverage clears the last-update time (German, @gekh, #1576). diff --git a/src/data/manager.js b/src/data/manager.js index 688fb94d2..5de10f665 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -15,18 +15,6 @@ export class DataLayerManager extends LayerLifecycle { this._presentation.refresh(); } - /** - * Repaint the toggle panel now. For layers whose data arrives outside their - * manager tick (camera-driven loads such as Transit's proximity polls), so a - * row shows its count when the data lands instead of at the next interval. - * One DOM pass; skipped while the document is hidden. - * @param {string} [layerId] - The layer whose row changed. - */ - refreshLayerStats(layerId) { - this._dropRecoveredRefreshError(layerId); - this._refreshTogglePanel(); - } - _buildMetaText(layer) { return this._presentation.panel._buildMetaText(layer); } diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs deleted file mode 100644 index 004c75c94..000000000 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ /dev/null @@ -1,121 +0,0 @@ -// BIKESHARE COVERAGE STATUS — a view with no system says so. -// -// With no system in range (London, Tokyo) the layer says why instead of -// "never", and keeps no date for data it no longer draws. -// -// Run with: npm test -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState } from './state.js'; -import { createControls } from './controls.js'; -import { createModel } from './model.js'; -import { createViewport } from './viewport.js'; -import { GBFS_CITY_REGISTRY } from './registry.js'; - -const LONDON = { lat: 51.5072, lon: -0.1276 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createModel({ state, services, parts: {}, source: null }), - ingestion: { abortInFlight() {} }, - rendering: { removeCityPoints() {} }, - }; - parts.viewport = createViewport({ state, services, parts, source: null }); - const { methods } = createControls({ state, services, parts, source: {} }); - state._enabled = true; - return { state, methods, viewport: parts.viewport }; -} - -/** A viewer looking straight down at `at` from `height` metres. */ -function viewerAt(at, height) { - const rad = Math.PI / 180; - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -test('above the altitude gate the row says how low to fly', async () => { - const { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 2_000_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - assert.equal(stats.status, 'zoom-in'); - assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); - assert.equal(stats.error, undefined); -}); - -test('a city with no system says so and counts the ones there are', async () => { - const { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 8_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - assert.equal(stats.status, 'zoom-in'); - assert.equal( - stats.statusMessage, - `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, - ); - assert.equal(stats.count, 0); -}); - -test('a city with a system in range is not given the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._activeCityIds.add('boston-bluebikes'); - - assert.equal(methods.getStats().status, undefined); -}); - -test('a loading or failed city reports itself, not the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._loading = true; - assert.equal(methods.getStats().status, undefined); - - // A city that failed is dropped from range, and still owns the row. - state._loading = false; - state._failedCityIds.add('boston-bluebikes'); - const stats = methods.getStats(); - assert.equal(stats.status, undefined); - assert.equal(stats.error, 'GBFS fetch error'); -}); - -test('a disabled layer reports no prompt', () => { - const { state, methods } = makeLayer(); - state._enabled = false; - assert.equal(methods.getStats().status, undefined); -}); - -test('leaving every system behind drops the date of the data it drew', async () => { - for (const height of [8_000, 2_000_000]) { - const { state, methods, viewport } = makeLayer(); - state._lastUpdate = Date.now(); - // Boston's stations were drawn and dated; the camera moves to London. - state._activeCityIds.add('boston-bluebikes'); - state._cityRuntime.set('boston-bluebikes', {}); - state._viewer = viewerAt(LONDON, height); - await viewport.runProximityCheck(); - - assert.equal(state._activeCityIds.size, 0); - assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); - } -}); diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 5f5538168..4bfea999c 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,11 +1,13 @@ -// Bike Share: after the camera settles, the last row the panel painted must -// match what the layer reports now. Each case drives the real viewport, +// Bike Share's Layers row. With no system in range it says why instead of +// "never" and keeps no date for data it no longer draws. After the camera +// settles, the last row the panel painted must match what the layer reports +// now. Each case drives the real viewport, // ingestion, rendering and controls; the manager is a spy that records the // row text (LayerPanel._buildMetaText) at every refreshLayerStats() call. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { CITY_BY_ID } from './registry.js'; +import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js'; import { createState } from './state.js'; import { createControls } from './controls.js'; import { createIngestion } from './ingestion.js'; @@ -175,6 +177,7 @@ function makeLiveLayer() { source, painted, lastPainted: () => painted.at(-1), + stats: () => controls.getStats(), live: () => rowText(controls.getStats()), moveTo(place, height = LOW_M) { Object.assign(camera, place, { height }); @@ -291,3 +294,73 @@ test('leaving the failed city of two repaints DEGRADED away', async () => { assert.doesNotMatch(layer.live(), /^degraded/); assert.equal(layer.lastPainted(), layer.live()); }); + +test('above the altitude gate the row says how low to fly', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(LONDON, 2_000_000); + const stats = layer.stats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); + assert.equal(stats.error, undefined); +}); + +test('a city with no system says so and counts the ones there are', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + const stats = layer.stats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.statusMessage, + `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, + ); + assert.equal(stats.count, 0); +}); + +test('a city in range, loading, failed or a disabled layer gets no prompt', async () => { + const layer = makeLiveLayer(); + assert.equal(layer.stats().status, undefined, 'disabled'); + + const info = deferred(); + layer.source.gates.set(`${BOSTON_ID}:info`, info); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + assert.equal(layer.stats().status, undefined, 'loading'); + info.resolve(); + await entering; + await settle(); + assert.equal(layer.stats().status, undefined, 'loaded'); + + // Left mid-load: range is empty, but the aborted load still owns the row. + const leaving = makeLiveLayer(); + leaving.source.gates.set(`${BOSTON_ID}:info`, deferred()); + await leaving.enable(); + const loading = leaving.moveTo(BOSTON); + await settle(); + const left = leaving.moveTo(LONDON); + assert.equal(leaving.state._activeCityIds.size, 0); + assert.equal(leaving.stats().loading, true); + assert.equal(leaving.stats().status, undefined, 'leaving mid-load'); + await Promise.all([loading, left]); + + const failed = makeLiveLayer(); + failed.source.dead.add(BOSTON_ID); + await failed.enable(); + await failed.moveTo(BOSTON); + await settle(); + assert.equal(failed.stats().status, undefined, 'failed'); + assert.equal(failed.stats().error, 'GBFS fetch error'); +}); + +test('leaving every system behind drops the date of the data it drew', async () => { + for (const height of [LOW_M, 2_000_000]) { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + assert.ok(layer.stats().lastUpdate, 'Boston is dated'); + + await layer.moveTo(LONDON, height); + assert.equal(layer.stats().lastUpdate, null, `at ${height} m`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index d4cc44cb6..46b6b7d12 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -243,11 +243,9 @@ export function createViewport({ state: layerState, services, parts, source }) { nextActive.size !== layerState._activeCityIds.size || [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; - if (layerState._activeCityIds.size === 0) { + if (nextActive.size === 0) { layerState._count = 0; layerState._lastUpdate = null; - if (changed) refreshRow(); - return; } if (changed) refreshRow(); diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs deleted file mode 100644 index 38193ca72..000000000 --- a/src/layers/promptRepaint.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// A camera move that changes a Transit or Bike Share coverage prompt repaints -// the row at once, not at the next manager tick (15 s, 60 s). -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState as createBikeState } from './bikeshare/state.js'; -import { createControls as createBikeControls } from './bikeshare/controls.js'; -import { createModel as createBikeModel } from './bikeshare/model.js'; -import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; -import { createState as createTransitState } from './transit/state.js'; -import { createViewport as createTransitViewport } from './transit/viewport.js'; -import { createQueries as createTransitQueries } from './transit/queries.js'; - -const rad = Math.PI / 180; -const LONDON = { lat: 51.5072, lon: -0.1276 }; -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -const services = () => ({ - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, -}); - -function viewer(at, height) { - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -function spyManager() { - const dm = { repaints: 0 }; - dm.refreshLayerStats = () => { - dm.repaints++; - }; - return dm; -} - -test('Bike Share: flying from London into Boston repaints the prompt row', async () => { - const svc = services(); - const state = createBikeState({ services: svc }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createBikeModel({ state, services: svc, parts: {}, source: null }), - ingestion: { - abortInFlight() {}, - loadCityStationInfo: async () => new Map(), - loadCityStationStatus: async () => new Map(), - }, - rendering: { - removeCityPoints() {}, - ensureCityPoints() {}, - applyStatusToPoints() {}, - }, - }; - parts.viewport = createBikeViewport({ - state, - services: svc, - parts, - source: null, - }); - const { methods } = createBikeControls({ - state, - services: svc, - parts, - source: {}, - }); - state._enabled = true; - const dm = spyManager(); - methods.attachDataManager(dm); - - state._viewer = viewer(LONDON, 8_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, 'zoom-in'); - - const before = dm.repaints; - state._viewer = viewer(BOSTON, 3_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, undefined, 'Boston is covered'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); - -test('Transit: leaving Boston for London repaints the row', () => { - const svc = services(); - const state = createTransitState({ services: svc }); - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed() {}, - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createTransitViewport({ state, services: svc, parts }); - const queries = createTransitQueries({ state, parts }).methods; - state._enabled = true; - const dm = spyManager(); - state._dataManager = dm; - - state._viewer = viewer(BOSTON, 4_000); - parts.viewport.runProximityCheck(); - assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); - state._lastUpdate = Date.now(); - - const before = dm.repaints; - state._viewer = viewer(LONDON, 4_000); - parts.viewport.runProximityCheck(); - assert.equal(queries.getStats().status, 'zoom-in'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs deleted file mode 100644 index dc4c3e863..000000000 --- a/src/layers/transit/gatePrompt.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -// The Transit prompt names the altitude the gate opens at: at 2,900 km over -// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState } from './state.js'; -import { createViewport } from './viewport.js'; -import { createQueries } from './queries.js'; -import { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; - -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const polled = []; - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed: (feed) => polled.push(feed.id), - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createViewport({ state, services, parts }); - const queries = createQueries({ state, parts }).methods; - state._enabled = true; - const rad = Math.PI / 180; - state._viewer = { - camera: { - positionCartographic: { - height: 0, - latitude: BOSTON.lat * rad, - longitude: BOSTON.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; - return { state, queries, viewport: parts.viewport, polled }; -} - -test('below the prompted altitude but above the gate, the prompt names the gate', () => { - const { state, queries, viewport, polled } = makeLayer(); - state._viewer.camera.positionCartographic.height = 2_900_000; - viewport.runProximityCheck(); - - // Nothing loads at 2,900 km: the gate opens at the enter threshold. - assert.equal(state._altitudeGateOpen, false); - assert.deepEqual(polled, []); - - const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( - 'en-US', - ); - const { statusMessage } = queries.getStats(); - assert.equal(statusMessage, `Fly below ${km} km to a covered region`); -}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index dc18fd6f0..4711eebdf 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4021,3 +4021,15 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async 'Fly below 2,850 km to a covered region', ); }); + +test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) => { + // 2,900 km is under ACTIVATION_ALTITUDE_M but above the enter threshold. + const app = harness(t, { altitude: 2_900_000 }); + app.layer.enable(app.viewer); + await app.layer.update(); + assert.deepEqual(app.requested, [], 'nothing loads over Boston'); + assert.equal( + app.layer.getStats().statusMessage, + 'Fly below 2,850 km to a covered region', + ); +}); diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs index d1a5c16b8..d85a4a375 100644 --- a/src/layers/transit/refreshErrorRepaint.test.mjs +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -91,8 +91,10 @@ test('leaving a feed whose tick failed repaints its error away', async () => { await lifecycle.refreshLayer('transit'); assert.match(row(), /feed unavailable/, 'the tick recorded the failure'); + const before = painted.length; state._viewer = viewer(LONDON, 4_000); parts.viewport.runProximityCheck(); assert.equal(queries.getStats().status, 'zoom-in'); + assert.ok(painted.length > before, 'leaving Boston repainted the row'); assert.doesNotMatch(painted.at(-1), /feed unavailable/); }); From 13219828e379494ff56d842d031aeae608fb424d Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 19:20:11 +0100 Subject: [PATCH 071/174] fix(launches): cancel mission flights on exit and name a failed index load Review fixes on top of #1578: - Exiting Space Missions cancels the layer's own camera flights again (entry, roster preview, mission focus); only a flight navigation started after releaseCameraOwnership() runs on. - A failed first index load says so: "Space Missions unavailable: the mission index could not load. Try again later." on both the Context and Data Layers routes, instead of a generic transition toast. Translated. - Regression tests for both; docs and changelog describe the real feedback. Co-Authored-By: Claude Opus 5.5 --- .../1578-space-missions-failed-entry.md | 7 ++-- docs/CURRENT-STATE.md | 2 +- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/launches/controls.js | 1 + src/layers/launches/failedEntry.test.mjs | 39 ++++++++++++++++++- src/layers/launches/policyHelpers.js | 1 + src/layers/launches/replay.js | 8 +++- src/layers/launches/selection.js | 3 ++ src/layers/launches/state.js | 3 ++ src/ui/contextActions.js | 17 +++++++- src/ui/contextBindings.js | 9 +++-- src/ui/contextLayerChanges.js | 13 +++++-- 22 files changed, 98 insertions(+), 16 deletions(-) diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md index 9ad1630db..5d8d67735 100644 --- a/changelog.d/1578-space-missions-failed-entry.md +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -1,5 +1,4 @@ - Space Missions no longer locks the workspace when its mission index fails to - load. A 429, timeout or bad payload on `/api/launches` now rolls the entry - back to the previous layers and camera view instead of leaving an empty, - isolating mode, and the roster reads `30-DAY MISSION INDEX UNAVAILABLE` - rather than loading forever (German, @gekh, #1578). + load (429, timeout or bad payload): entry rolls back to the previous layers + and camera view and a toast says the index could not load (German, @gekh, + #1578). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c261c6205..76deeb9a0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5336,7 +5336,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless it moved after the entry flight. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 10d7ab268..1cd3834d4 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -301,6 +301,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", + "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.", "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ca4df37f7..d7e73583e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -760,6 +760,7 @@ "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.", "30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "Internet radio and local SDR": "Internetradio und lokales SDR", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5f43c2a35..a9ce5da9f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -192,6 +192,7 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.", "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE", "RADIO": "RADIO", "ENABLE": "ACTIVAR", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a610754ed..2d64b3e2a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2805,6 +2805,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4ab1283fc..d349fe1e9 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2805,6 +2805,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.", "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b278db058..5d0585cf7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2663,6 +2663,7 @@ "AVAILABLE MISSIONS": "利用可能なミッション", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", + "Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。", "30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "Internet radio and local SDR": "インターネットラジオとローカルSDR", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bd0ca7299..6ddf3238e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2807,6 +2807,7 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.", "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e5374f3c0..861e4245d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -722,6 +722,7 @@ "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.", "30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН", "Radio playback": "Воспроизведение радио", "Radio volume": "Громкость радио", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index b94a91dd5..67efb91bb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2875,6 +2875,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.", "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0875775b2..8832de1fe 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2562,6 +2562,7 @@ "AVAILABLE MISSIONS": "可用任务", "SELECT A MISSION TO INSPECT": "选择一项任务以查看", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "Internet radio and local SDR": "网络电台与本地 SDR", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c6bff6cb6..dce0b2e75 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2550,6 +2550,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 019154269..d8b4c6d6a 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -17,6 +17,7 @@ export function createControls({ state: layerState, services, parts, source }) { parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); + layerState._cameraReleased = true; }, /** diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 7878a8e2d..52adcb792 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -134,7 +134,7 @@ async function fixture(t) { dataSources: new Cesium.DataSourceCollection(), scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, }; - // Only scene presentation is stubbed. + // Scene presentation is stubbed; ingestion, lifecycle and panel are real. const parts = { overlays: { shortMissionLabel: (name) => name, @@ -454,3 +454,40 @@ test('a refresh failure after a loaded index keeps the mode and its records', as await f.settle(); assert.deepEqual(f.view(), framed); }); +// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). +test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress'); + + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.equal( + f.camera.flight, + null, + 'leaving Space Missions stops the flight it started (main cancels it)', + ); +}); +// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). +// Encodes the "surface the reason where the user acted" fix: the roster that +// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the +// lasting feedback (the toast) has to name the cause. +test('a failed first load tells the user the mission index is unavailable', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, null, 'rolled back'); + assert.ok( + f.toasts.some((message) => /mission index/i.test(message)), + `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, + ); +}); diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index d4539289a..b0ec18373 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -229,6 +229,7 @@ export function createPolicyHelpers({ layerState._viewer.camera.positionCartographic?.height, ); const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(position, 0), { diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index bf6ad84de..d2fe51bb4 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,8 +314,12 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - // Cancel only a replay's flight; any other flight runs on. - if (stoppedLaunchId && layerState._viewer?.camera) { + // Cancel the layer's own flights, never one navigation started after + // taking the camera. + if ( + (stoppedLaunchId || !layerState._cameraReleased) && + layerState._viewer?.camera + ) { layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 0f7955551..6d7ae40cb 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -33,6 +33,7 @@ export function createSelection({ ); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + layerState._cameraReleased = false; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromRadians( cartographic.longitude, @@ -191,6 +192,7 @@ export function createSelection({ Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3(), ); + layerState._cameraReleased = false; layerState._viewer.camera.flyTo({ destination, orientation: { direction, up }, @@ -213,6 +215,7 @@ export function createSelection({ layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( `rocket-launch:${launch.id}`, ); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(launchPosition, 0), { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 9ef571428..beb8d1bf7 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -23,6 +23,9 @@ export function createState({ services }) { state._entryLandedView = null; + /** Navigation took the camera; set until the layer flies again. */ + state._cameraReleased = false; + state._stale = false; state._orbitMatches = 0; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index a621137ad..702130073 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,6 +4,18 @@ import { contextModeWord, } from '../contextModePolicy.js'; +/** Why a Space Missions entry failed, when its mission index never loaded. */ +export function spaceMissionsIndexFailure(dataManager) { + const stats = dataManager?.layers + ?.get('rocket-launches') + ?.module?.getStats?.(); + return stats?.error && stats.lastUpdate == null + ? t( + 'Space Missions unavailable: the mission index could not load. Try again later.', + ) + : null; +} + export async function _runUserFacingContextAction( operation, message = t('Context could not restore every layer; try again'), @@ -19,7 +31,10 @@ export async function _runUserFacingContextAction( onFailure: (error) => { if (this.destroyed) return; console.warn('[Context] user-facing transition failed', error); - this.showToast(localizedMessage || message); + this.showToast( + localizedMessage || + (typeof message === 'function' ? message() : message), + ); }, }); } finally { diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index cc09c49bf..c5e4fc52e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -1,4 +1,5 @@ import { t } from '../i18n/index.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; export function _initGlobalContextPanel() { @@ -62,9 +63,11 @@ export function _initGlobalContextPanel() { void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), - t( - 'Space Missions could not complete the requested transition; try again', - ), + () => + (nextMode && spaceMissionsIndexFailure(this._dataManager)) || + t( + 'Space Missions could not complete the requested transition; try again', + ), ).then((succeeded) => { if ( !this.destroyed && diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index d4ddd6cd5..295242239 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -9,6 +9,7 @@ import { spaceMissionEntryCancellationDisposition, } from '../contextModePolicy.js'; import { localizeLayerName } from './layerNames.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; /** * A blocked-visibility reason in the UI language. Reasons stay English (voice @@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) { return; } if (change?.type === 'visibility-failed') { - const failureMessage = t('{layerId} could not {action} cleanly', { - layerId: change.layerId, - action: change.enabled ? t('start') : t('stop'), - }); + const failureMessage = + (change.layerId === 'rocket-launches' && + change.enabled && + spaceMissionsIndexFailure(this._dataManager)) || + t('{layerId} could not {action} cleanly', { + layerId: change.layerId, + action: change.enabled ? t('start') : t('stop'), + }); // A failed direct Context-shell START has already had its siblings // cleared by the visibility guard. Wait outside the synchronous manager // notification for this queue to settle, then reconcile the complete From efa4374644a8558b900aa927ea57d6beae6dbb52 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 19:28:56 +0100 Subject: [PATCH 072/174] fix(launches): hand the camera over before stopping, and only blame a fresh index error - releaseCameraOwnership marks the camera released before stopping, so a flight navigation asked to preserve survives; exit also releases it, so an inactive layer never cancels another owner's flight. - The failure toast names the mission index only when its error is from the current Context attempt (stats.errorAt), not an earlier one. Co-Authored-By: Claude Opus 5.5 --- src/layers/launches/controls.js | 3 ++- src/layers/launches/failedEntry.test.mjs | 33 ++++++++++++++++++++---- src/layers/launches/ingestion.js | 1 + src/layers/launches/lifecycle.js | 2 ++ src/layers/launches/state.js | 2 ++ src/ui/contextActions.js | 7 ++--- src/ui/contextBindings.js | 3 ++- 7 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d8b4c6d6a..9adc88922 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -14,10 +14,10 @@ export function createControls({ state: layerState, services, parts, source }) { releaseCameraOwnership() { // Navigation takes the camera; a failed entry must not restore the old view. layerState._entryCameraView = null; + layerState._cameraReleased = true; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); - layerState._cameraReleased = true; }, /** @@ -60,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + errorAt: layerState._lastErrorAt, stale: layerState._stale, }; }, diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 52adcb792..4330cf0d8 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -6,6 +6,7 @@ import http from 'node:http'; import * as Cesium from 'cesium'; import { DataLayerManager } from '../../data/manager.js'; import { ContextControls } from '../../ui/contextControls.js'; +import { spaceMissionsIndexFailure } from '../../ui/contextActions.js'; import { createLaunchSource } from './source.js'; import { createState } from './state.js'; import { createModel } from './model.js'; @@ -454,7 +455,6 @@ test('a refresh failure after a loaded index keeps the mode and its records', as await f.settle(); assert.deepEqual(f.view(), framed); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { const f = await fixture(t); f.upstream.status = 200; @@ -473,10 +473,7 @@ test('exiting during a successful entry flight cancels the mission-owned flight' 'leaving Space Missions stops the flight it started (main cancels it)', ); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). -// Encodes the "surface the reason where the user acted" fix: the roster that -// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the -// lasting feedback (the toast) has to name the cause. +// The rollback hides the roster, so the toast has to name the cause. test('a failed first load tells the user the mission index is unavailable', async (t) => { const f = await fixture(t); await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); @@ -491,3 +488,29 @@ test('a failed first load tells the user the mission index is unavailable', asyn `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, ); }); + +test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const flight = f.camera.flight; + assert.ok(flight, 'the entry flight is in progress'); + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, flight, 'navigation decides about the flight'); + // After exit an inactive layer never cancels another owner's flight. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) }); + const other = f.camera.flight; + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, other); +}); + +test("an earlier attempt's index error is not blamed for a later failure", async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + assert.match(spaceMissionsIndexFailure(f.manager), /mission index/); + assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 9994acb86..be6190815 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -228,6 +228,7 @@ export function createIngestion({ } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; + layerState._lastErrorAt = Date.now(); console.warn('[Data:RocketLaunches] Fetch error:', error); // No index yet: fail the enable so Context rolls back. // After a loaded index, a failed refresh keeps its records. diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6d87b47d8..13150e805 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -285,6 +285,7 @@ export function createLifecycle({ parts.ingestion.clearPostTleRetry(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); if (layerState._dataSource) layerState._dataSource.show = false; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); @@ -310,6 +311,7 @@ export function createLifecycle({ await parts.ingestion.restoreSatelliteDependency(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); parts.ingestion.clearPostTleRetry(); if (layerState._clickHandler) layerState._clickHandler.destroy(); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index beb8d1bf7..fd2ae7176 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._lastErrorAt = null; + state._entryCameraView = null; state._entryLandedView = null; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index 702130073..eeb22fefe 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,12 +4,13 @@ import { contextModeWord, } from '../contextModePolicy.js'; -/** Why a Space Missions entry failed, when its mission index never loaded. */ -export function spaceMissionsIndexFailure(dataManager) { +/** Why a Space Missions entry failed: its mission index never loaded, and + * the error is no older than `since` (an earlier attempt's is not the cause). */ +export function spaceMissionsIndexFailure(dataManager, since = 0) { const stats = dataManager?.layers ?.get('rocket-launches') ?.module?.getStats?.(); - return stats?.error && stats.lastUpdate == null + return stats?.error && stats.lastUpdate == null && stats.errorAt >= since ? t( 'Space Missions unavailable: the mission index could not load. Try again later.', ) diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index c5e4fc52e..8c6b8760e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -60,11 +60,12 @@ export function _initGlobalContextPanel() { const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this._claimContextVisualAuthority(); + const startedAt = Date.now(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), () => - (nextMode && spaceMissionsIndexFailure(this._dataManager)) || + (nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) || t( 'Space Missions could not complete the requested transition; try again', ), From 8da8c05eaf5b065fdb547480f18a5342e00d4f42 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 6 Nov 2026 20:10:34 +0100 Subject: [PATCH 073/174] test(launches): keep the fixture's toast recorder for the failure-message test Co-Authored-By: Claude Opus 5.5 --- src/layers/launches/failedEntry.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index d43a5c6e9..a2bccad7d 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -191,13 +191,14 @@ async function fixture(t) { manager.register(missions); missions.attachDataManager(manager); + const toasts = []; const controls = new ContextControls({ elements: {}, installations: {}, actions: { getCockpit: () => null, claimVisualAuthority: noop, - showToast: noop, + showToast: (message) => toasts.push(message), setClearBusy: noop, syncDetection: noop, scheduleLayout: noop, @@ -234,6 +235,7 @@ async function fixture(t) { manager, controls, missions, + toasts, state, camera, rosters, From 9c8bb206ef9175bf36b13bbf0bbba69d4207b4cb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 11:37:57 -0800 Subject: [PATCH 074/174] feat(traffic): Lines and Dots toggles; hosts choose which road modes they offer createTrafficSource({ roadModes }) lets a host offer a subset of the TomTom, OSM and Hybrid road modes (all three by default). Without OSM the layer never draws simulated roads, and a single mode shows no road chip. While flow is live the Street Traffic row offers Lines (every road with flow as a steady green/amber/red line, from the heat-line renderer) and Dots (the moving dots), saved in layer state and share links. Defaults: Lines off, Dots on. --- docs/CURRENT-STATE.md | 23 +- src/data/layerState.js | 3 + src/i18n/locales/ar.json | 5 + src/i18n/locales/de.json | 5 + src/i18n/locales/es.json | 5 + src/i18n/locales/fr.json | 5 + src/i18n/locales/it.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/pt.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/uk.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/i18n/locales/zh-Hant.json | 5 + src/layers/traffic/animation.js | 19 +- src/layers/traffic/controls.js | 115 +++++-- src/layers/traffic/hostModes.test.mjs | 326 ++++++++++++++++++++ src/layers/traffic/index.js | 2 +- src/layers/traffic/ingestion.js | 42 ++- src/layers/traffic/lifecycle.js | 10 +- src/layers/traffic/policy.js | 17 + src/layers/traffic/rendering.js | 191 ++++++++---- src/layers/traffic/roadModes.js | 45 ++- src/layers/traffic/roadStatus.i18n.test.mjs | 43 ++- src/layers/traffic/source.js | 57 +++- src/layers/traffic/state.js | 23 +- src/layers/traffic/style.js | 8 +- src/share/viewCodec.js | 2 +- 27 files changed, 868 insertions(+), 113 deletions(-) create mode 100644 src/layers/traffic/hostModes.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 76deeb9a0..c91f1c439 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. +Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is @@ -6437,7 +6437,26 @@ easier to meet (detection is now on more often), but does not create it. - Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid` - overrides restored state until the user picks a chip. With no stored choice + overrides restored state until the user picks a chip. A host may offer + fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset, + default all three): only offered modes get chips (none for a single mode), + and other choices from chips, restores, share links or the query are + ignored. Without `osm` the layer never simulates: keyless or with the + status probe unreachable, loads fail with "TomTom roads need a TomTom key" + or "TomTom roads unavailable while the traffic service is unreachable" and + no roads are drawn. +- Row toggles (live mode only): after the road-source chips, Lines draws + every road with flow as a steady draped line in its bucket color (green + free, amber slow, red jam; jam first, then slow, then free, longest first, + capped at 1,500; on 3D tiles under photoreal, on terrain or tiles + otherwise), and Dots shows or hides the moving dots (hidden dots are kept, + are not offered to detection overlays, and hold no render frames). + Without live flow only a hidden Dots toggle is offered, so dots hidden by a + live choice, a restore or a share link can always be shown again. + Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the + layer state `lo` field (`l`, `d`). Toggling either restyles in place, with + no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines + is off. With no stored choice the default is Hybrid with a TomTom key and OSM without one; share links without a road-source choice (including older ones) likewise use the installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key diff --git a/src/data/layerState.js b/src/data/layerState.js index 4c679f7af..ed53abeb1 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -335,6 +335,9 @@ const OPTION_GROUPS = Object.freeze({ osm: 'o', hybrid: 'h', }), + // Street Traffic row toggles: flow-colored road lines, moving dots. + booleanOption('lines', 'l', false), + booleanOption('dots', 'd', true), ]), 'weather-lightning': Object.freeze([ enumOption('opacity', 'o', 'strong', ['light', 'strong'], { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 1cd3834d4..5baf976f3 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1749,6 +1749,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)", "Hybrid": "هجين", + "Lines": "خطوط", + "Dots": "نقاط", + "Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر", + "Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر", + "Moving dots": "نقاط متحركة", "TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d7e73583e..2f1af958f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1667,6 +1667,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)", "Hybrid": "Hybrid", + "Lines": "Linien", + "Dots": "Punkte", + "Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot", + "Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt", + "Moving dots": "Bewegte Punkte", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a9ce5da9f..b7a09e4ee 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2699,6 +2699,11 @@ "Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales", "Camera coverage unavailable": "Cobertura de cámaras no disponible", "Hybrid": "Híbrido", + "Lines": "Líneas", + "Dots": "Puntos", + "Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo", + "Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo", + "Moving dots": "Puntos en movimiento", "TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo", "TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)", "OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2d64b3e2a..738800324 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1103,6 +1103,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)", "Hybrid": "Hybride", + "Lines": "Lignes", + "Dots": "Points", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", + "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", "TomTom daily budget reached": "Quota quotidien TomTom atteint", "Monthly traffic allowance used": "Quota mensuel de trafic épuisé", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d349fe1e9..afdc4fdc5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1103,6 +1103,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)", "Hybrid": "Ibrido", + "Lines": "Linee", + "Dots": "Punti", + "Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso", + "Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale", + "Moving dots": "Punti in movimento", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", "Monthly traffic allowance used": "Quota mensile del traffico esaurita", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5d0585cf7..10f183da9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1071,6 +1071,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)", "Hybrid": "ハイブリッド", + "Lines": "ライン", + "Dots": "ドット", + "Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)", + "Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット", + "Moving dots": "移動ドット", "TomTom key unavailable": "TomTomキーを利用できません", "TomTom daily budget reached": "TomTomの1日の上限に達しました", "Monthly traffic allowance used": "今月の交通情報の上限に達しました", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 6ddf3238e..6194cd31b 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1101,6 +1101,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)", "Hybrid": "Híbrido", + "Lines": "Linhas", + "Dots": "Pontos", + "Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho", + "Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo", + "Moving dots": "Pontos em movimento", "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 861e4245d..f0a52cf0a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2757,6 +2757,11 @@ "Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы", "Camera coverage unavailable": "Покрытие камер недоступно", "Hybrid": "Гибрид", + "Lines": "Линии", + "Dots": "Точки", + "Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному", + "Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн", + "Moving dots": "Движущиеся точки", "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", "TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)", "OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 67efb91bb..f01e84504 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1118,6 +1118,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)", "Hybrid": "Гібрид", + "Lines": "Лінії", + "Dots": "Точки", + "Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного", + "Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн", + "Moving dots": "Рухомі точки", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8832de1fe..3ddcb3b34 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1047,6 +1047,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)", "Hybrid": "混合", + "Lines": "线条", + "Dots": "圆点", + "Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红", + "Moving dots colored by live flow": "按实时路况着色的移动圆点", + "Moving dots": "移动圆点", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", "Monthly traffic allowance used": "本月交通流量额度已用完", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index dce0b2e75..590ea6e15 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1042,6 +1042,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", "Hybrid": "混合", + "Lines": "線條", + "Dots": "圓點", + "Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅", + "Moving dots colored by live flow": "依即時路況著色的移動圓點", + "Moving dots": "移動圓點", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", "Monthly traffic allowance used": "本月交通流量額度已用完", diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 6c58a57a4..de520587c 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -21,9 +21,20 @@ export function createAnimation({ source, }) { function syncRenderDemand() { + // Hidden dots (`dots` off) need no frames. Lines need them only while + // their asynchronous geometry builds (or old lines wait to be retired), + // and the heat-line prototype's jam batch while it pulses. + const lines = [ + layerState._heatJamPrim, + layerState._heatSlowPrim, + layerState._heatFreePrim, + ].filter(Boolean); const needed = Boolean( layerState._enabled && - (layerState._dots.length || layerState._heatJamPrim), + ((layerState._showDots !== false && layerState._dots.length) || + (layerState._heatJamPrim && !layerState._showLines) || + layerState._heatRetired?.length || + lines.some((primitive) => !primitive.ready)), ); // Height easing only runs in a held frame. Settle it before releasing, so // a population emptied mid-correction cannot report loading forever. @@ -224,6 +235,7 @@ export function createAnimation({ */ function animate() { + parts.rendering.retireHeatLines?.(); syncRenderDemand(); if (!layerState._renderHeld) return; const now = Date.now(); @@ -317,8 +329,9 @@ export function createAnimation({ } // Jam heat-lines throb (~1.6 s period) — one shared material uniform for - // the whole jam batch, no geometry rebuild. Null outside live heatline mode. - if (layerState._heatJamPrim?.appearance) { + // the whole jam batch, no geometry rebuild. Null outside live heatline + // mode; the steady flow lines (`lines` toggle) never pulse. + if (layerState._heatJamPrim?.appearance && !layerState._showLines) { layerState._heatJamPrim.appearance.material.uniforms.color.alpha = HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); } diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 6570c7958..403aaaa88 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -1,7 +1,7 @@ import { normalizeRoadMode, resolveRoadMode, - TRAFFIC_ROAD_MODES, + roadModesSimulate, } from './roadModes.js'; import { trafficBucketTier, @@ -19,19 +19,25 @@ export function createControls({ state: layerState, services, parts, source }) { const notes = [feed.loadingLabel]; if (!layerState._liveMode || feed.error) notes.push(t('Roads: {source}', { source })); - // An explicit TomTom or Hybrid choice without a key draws OpenStreetMap. + // An explicit TomTom or Hybrid choice without a key draws OpenStreetMap; + // a host without OpenStreetMap draws nothing. + const requested = roadModesSimulate(layerState._roadModes) + ? layerState._roadMode + : resolveRoadMode(layerState._roadMode, true, layerState._roadModes); + // A host without OpenStreetMap waits for the probe before naming a key. if ( !layerState._liveMode && - ['tomtom', 'hybrid'].includes(layerState._roadMode) + ['tomtom', 'hybrid'].includes(requested) && + (roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown) ) notes.push( layerState._flowStatusUnavailable - ? layerState._roadMode === 'tomtom' + ? requested === 'tomtom' ? t( 'TomTom roads unavailable while the traffic service is unreachable', ) : t('Hybrid unavailable while the traffic service is unreachable') - : layerState._roadMode === 'tomtom' + : requested === 'tomtom' ? t('TomTom roads need a TomTom key') : t('Hybrid needs a TomTom key'), ); @@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: 0, /** - * Update user-adjustable parameters (road source, density and speed scaling). + * Update user-adjustable parameters (road source, row toggles, density and + * speed scaling). * * @param {Object} [params] - * @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default. + * @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored. + * @param {boolean} [params.lines] - Draw flow-colored road lines (live mode). + * @param {boolean} [params.dots] - Draw the moving dots. * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). * @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`. @@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) { const previousSpeed = layerState._speedScale; if ( Object.hasOwn(params, 'roadMode') && - (params.roadMode === null || normalizeRoadMode(params.roadMode)) + (params.roadMode === null || + normalizeRoadMode(params.roadMode, layerState._roadModes)) ) { const explicit = ['user', 'voice', 'tool'].includes(origin); const mode = @@ -98,6 +108,31 @@ export function createControls({ state: layerState, services, parts, source }) { parts.viewport.onCameraChanged({ immediate: true }); } } + if ( + typeof params.lines === 'boolean' && + params.lines !== layerState._showLines + ) { + layerState._showLines = params.lines; + // Rebuild in place from the roads on screen: no refetch, no respawn. + if (layerState._enabled) + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + } + if ( + typeof params.dots === 'boolean' && + params.dots !== layerState._showDots + ) { + layerState._showDots = params.dots; + // Hidden dots are kept, so showing them again is instant. + if (layerState._enabled && layerState._pointCollection) + layerState._pointCollection.show = params.dots; + parts.animation.syncRenderDemand(); + layerState._viewer?.scene?.requestRender?.(); + } if (typeof params.densityScale === 'number') { layerState._densityScale = Math.max( 0.2, @@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) { /** * Return the current user-adjustable parameters. - * @returns {{densityScale:number, speedScale:number}} + * @returns {{roadMode:string|null, lines:boolean, dots:boolean, + * densityScale:number, speedScale:number}} */ getParams() { return { roadMode: layerState._roadMode, + lines: layerState._showLines, + dots: layerState._showDots, densityScale: layerState._densityScale, speedScale: layerState._speedScale, uncoveredRoads: layerState._uncoveredMode, @@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) { }; }, - /** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */ + /** + * Street Traffic row chips: a road-source chip per offered mode (none for + * a single mode), then Lines and Dots toggles while live flow colors them. + */ getRowControls() { + const modes = layerState._roadModes; const selected = - layerState._roadMode || resolveRoadMode(null, layerState._liveMode); + layerState._roadMode || + resolveRoadMode(null, layerState._liveMode, modes); const live = layerState._liveMode; const titles = { tomtom: live @@ -187,14 +230,36 @@ export function createControls({ state: layerState, services, parts, source }) { }; // TomTom and OSM are provider names and stay as they are. const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') }; + const lines = { + id: 'lines', + label: t('Lines'), + title: t('Road lines colored by live flow: green to red'), + active: layerState._showLines, + params: { lines: !layerState._showLines }, + }; + const dots = { + id: 'dots', + label: t('Dots'), + title: live ? t('Moving dots colored by live flow') : t('Moving dots'), + active: layerState._showDots, + params: { dots: !layerState._showDots }, + }; + // Lines need live flow. Dots, once hidden (a live choice, a restore or + // a share link), stay offered so they can always be shown again. + const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots]; return { - chips: TRAFFIC_ROAD_MODES.map((mode) => ({ - id: `roads-${mode}`, - label: labels[mode], - title: titles[mode], - active: mode === selected, - params: { roadMode: mode }, - })), + chips: [ + ...(modes.length > 1 + ? modes.map((mode) => ({ + id: `roads-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === selected, + params: { roadMode: mode }, + })) + : []), + ...toggles, + ], }; }, @@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) { * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} */ getDetectableObjects(options = {}) { - if (!layerState._enabled || layerState._dots.length === 0) return []; + // Hidden dots are not on screen to detect. + if ( + !layerState._enabled || + !layerState._showDots || + layerState._dots.length === 0 + ) + return []; const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) : layerState._dots.length; @@ -382,7 +453,11 @@ export function createControls({ state: layerState, services, parts, source }) { // in one had better be the honest one. This is also where LIVE vs // SIMULATED mode is surfaced, and it must never imply a live feed the // layer does not have. - roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode), + roadMode: resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ), roadModeRequested: layerState._roadMode, roadSource: layerState._roadSource, source: layerState._roadSource, diff --git a/src/layers/traffic/hostModes.test.mjs b/src/layers/traffic/hostModes.test.mjs new file mode 100644 index 000000000..630e38a37 --- /dev/null +++ b/src/layers/traffic/hostModes.test.mjs @@ -0,0 +1,326 @@ +// A host's road-source offer (createTrafficSource `roadModes`) and the +// Street Traffic Lines / Dots row toggles. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeRoadModes, + resolveRoadMode, + roadModesSimulate, + TRAFFIC_ROAD_MODES, +} from './roadModes.js'; +import { createTrafficSource } from './source.js'; +import { createTrafficLayer } from './index.js'; +import { + createDefaultLayerState, + decodeLayerStateParams, + encodeLayerStateParams, + parseStoredLayerState, + serializeStoredLayerState, +} from '../../data/layerState.js'; + +const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 }; +const flow = { + coords: [ + [-97.744, 30.267], + [-97.742, 30.267], + ], + trafficLevel: 0.3, + closure: false, + roadType: 'Major road', +}; + +function tomtomOnlySource() { + let requests = 0; + const source = createTrafficSource({ + roadModes: ['tomtom'], + mapTiles: { + async fetchBounds() { + requests++; + return { tiles: [], partial: false }; + }, + async getMetadata() { + requests++; + }, + clear() {}, + }, + }); + return { source, requests: () => requests }; +} + +test('host road modes are a non-empty subset in canonical order', () => { + assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES); + assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [ + 'tomtom', + 'hybrid', + ]); + assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom']))); + for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null]) + assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad)); + assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError); + assert.equal(roadModesSimulate(), true); + assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false); +}); + +test('a TomTom-only host resolves every request to TomTom, keyed or not', () => { + for (const value of [null, 'osm', 'hybrid', 'tomtom']) + for (const hasKey of [true, false]) + assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom'); + // Without Hybrid the default is the first offered mode. + assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom'); + assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm'); +}); + +test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => { + const f = tomtomOnlySource(); + assert.deepEqual(f.source.roadModes, ['tomtom']); + for (const roadMode of [null, 'osm', 'hybrid']) { + const data = await ( + await f.source.requestRoads(box, { + roadMode, + flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }), + }) + ).json(); + assert.equal(data.roadSource, 'TomTom', String(roadMode)); + assert.equal(data.roads.length, 1); + assert.equal(data.roads[0].directFlow, true); + } + assert.equal(f.requests(), 0); + await assert.rejects(f.source.requestRoads(box, {}), TypeError); +}); + +test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => { + const f = tomtomOnlySource(); + const failure = (snapshot) => + f.source + .requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) }) + .then( + () => assert.fail('a keyless load drew roads'), + (error) => error, + ); + const keyless = await failure({ hasKey: false, segments: [] }); + assert.equal(keyless.message, 'TomTom roads need a TomTom key'); + assert.equal(keyless.retryable, false); + const unreachable = await failure({ + hasKey: false, + segments: [], + statusUnavailable: true, + }); + assert.equal( + unreachable.message, + 'TomTom roads unavailable while the traffic service is unreachable', + ); + assert.equal(unreachable.retryable, true); + const late = await failure({ + hasKey: false, + segments: [], + error: 'TomTom flow timed out', + }); + assert.equal(late.message, 'TomTom flow timed out'); + assert.equal(f.requests(), 0); +}); + +test('a TomTom-only layer offers no road chips and ignores other road choices', () => { + const oldLocation = globalThis.location; + globalThis.location = { search: '?trafficRoads=osm' }; + try { + const { source } = tomtomOnlySource(); + const layer = createTrafficLayer({ + services: { credits: {}, render: {} }, + source, + }); + assert.equal(layer.getParams().roadMode, null); + for (const origin of ['user', 'local-restore', 'share-restore']) + layer.setParams({ roadMode: 'osm' }, { origin }); + assert.equal(layer.getParams().roadMode, null); + assert.deepEqual(layer.getRowControls().chips, []); + const stats = layer.getStats(); + assert.equal(stats.roadMode, 'tomtom'); + // No key verdict before the status probe settles. + assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/); + } finally { + if (oldLocation === undefined) delete globalThis.location; + else globalThis.location = oldLocation; + } +}); + +test('Lines and Dots are boolean params that only appear as chips with live flow', () => { + const layer = createTrafficLayer({ + services: { credits: {}, render: {} }, + source: tomtomOnlySource().source, + }); + assert.equal(layer.getParams().lines, false); + assert.equal(layer.getParams().dots, true); + // Keyless (the status probe has not reported a key): nothing to color. + assert.deepEqual(layer.getRowControls().chips, []); + layer.setParams({ lines: true }); + assert.equal(layer.getParams().lines, true); + layer.setParams({ lines: 'no', dots: 0 }); + assert.equal(layer.getParams().lines, true, 'non-boolean ignored'); + assert.equal(layer.getParams().dots, true, 'non-boolean ignored'); + layer.setParams({ lines: false, dots: false }); + assert.deepEqual( + [layer.getParams().lines, layer.getParams().dots], + [false, false], + ); + assert.deepEqual(layer.getDetectableObjects(), []); + // Hidden dots stay recoverable without live flow (restore, share link). + assert.deepEqual( + layer.getRowControls().chips.map((chip) => [chip.id, chip.title]), + [['dots', 'Moving dots']], + ); + layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' }); + assert.equal(layer.getParams().dots, true); + assert.deepEqual(layer.getRowControls().chips, []); +}); + +test('live chips toggle each control without touching the other', async () => { + const { createState } = await import('./state.js'); + const { createControls } = await import('./controls.js'); + const state = createState({ + services: {}, + source: { roadModes: ['tomtom'] }, + }); + state._liveMode = true; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: { + animation: { syncRenderDemand() {} }, + rendering: {}, + }, + }).methods; + const chip = (id) => + controls.getRowControls().chips.find((entry) => entry.id === id); + assert.deepEqual( + controls.getRowControls().chips.map((entry) => entry.id), + ['lines', 'dots'], + ); + assert.equal(chip('lines').active, false); + assert.equal(chip('dots').active, true); + controls.setParams(chip('lines').params, { origin: 'user' }); + assert.equal(chip('lines').active, true); + assert.equal(chip('dots').active, true); + controls.setParams(chip('dots').params, { origin: 'user' }); + assert.equal(chip('dots').active, false); + assert.equal(chip('lines').active, true); + // Every mode keeps its road chips beside the toggles. + const all = createState({ services: {}, source: {} }); + all._liveMode = true; + assert.deepEqual( + createControls({ + state: all, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: {}, + }) + .methods.getRowControls() + .chips.map((entry) => entry.id), + ['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'], + ); +}); + +test('hiding dots hides the point collection of an enabled layer', async () => { + const { createState } = await import('./state.js'); + const { createControls } = await import('./controls.js'); + const state = createState({ services: {}, source: {} }); + let demand = 0; + state._enabled = true; + state._pointCollection = { show: true }; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: { animation: { syncRenderDemand: () => demand++ } }, + }).methods; + controls.setParams({ dots: false }); + assert.equal(state._pointCollection.show, false); + controls.setParams({ dots: true }); + assert.equal(state._pointCollection.show, true); + assert.equal(demand, 2); +}); + +test('Lines and Dots persist in layer state and local storage', () => { + const state = createDefaultLayerState(); + assert.equal(state.options.traffic.lines, false); + assert.equal(state.options.traffic.dots, true); + state.enabledLayerIds = ['traffic']; + state.options.traffic.lines = true; + state.options.traffic.dots = false; + const params = new URLSearchParams('v=2'); + encodeLayerStateParams(params, state); + const decoded = decodeLayerStateParams(params).options.traffic; + assert.equal(decoded.lines, true); + assert.equal(decoded.dots, false); + const stored = parseStoredLayerState(serializeStoredLayerState(state)).options + .traffic; + assert.equal(stored.lines, true); + assert.equal(stored.dots, false); +}); + +test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => { + const { createRendering } = await import('./rendering.js'); + const removed = []; + const state = { + _viewer: { + scene: { groundPrimitives: { remove: (p) => removed.push(p) } }, + }, + _liveMode: false, + _heatFreePrim: { ready: true, id: 'old' }, + _heatRetired: [], + }; + const parts = { + style: { heatlineOn: () => true }, + animation: { syncRenderDemand() {} }, + }; + const rendering = createRendering({ state, services: {}, parts, source: {} }); + // Nothing new to draw (not live): the old line goes at once. + rendering.rebuildHeatLines([]); + assert.deepEqual( + removed.map((p) => p.id), + ['old'], + ); + // A pending successor holds the retired line until it is ready. + const next = { ready: false, id: 'new' }; + state._heatRetired = [{ id: 'older' }]; + state._heatJamPrim = next; + rendering.retireHeatLines(); + assert.equal(state._heatRetired.length, 1); + next.ready = true; + rendering.retireHeatLines(); + assert.deepEqual(state._heatRetired, []); + assert.deepEqual( + removed.map((p) => p.id), + ['old', 'older'], + ); +}); + +test('lines hold frames only while building; hidden dots hold none', async () => { + const { createAnimation } = await import('./animation.js'); + const calls = []; + const state = { + _enabled: true, + _dots: [{}], + _showDots: false, + _showLines: true, + _motion: { rebuilds: 0 }, + }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: () => calls.push('hold'), + releaseContinuousRender: () => calls.push('release'), + }, + }, + parts: {}, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._heatFreePrim = { ready: false }; + animation.syncRenderDemand(); + assert.deepEqual(calls, ['hold']); + state._heatFreePrim.ready = true; + animation.syncRenderDemand(); + assert.deepEqual(calls, ['hold', 'release']); +}); diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js index 1a7201281..4a5839f7d 100644 --- a/src/layers/traffic/index.js +++ b/src/layers/traffic/index.js @@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) { ].every((key) => typeof source?.[key] === 'function') ) throw new TypeError('A traffic source is required'); - const state = createState({ services }); + const state = createState({ services, source }); const parts = {}; const context = { state, services, parts, source }; parts.style = createStyle(context); diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index 8bb1da5f4..a5e09f543 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { requestWithDeadline } from '../../sources/requestDeadline.js'; -import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js'; +import { + normalizeRoadMode, + resolveRoadMode, + roadModesSimulate, + ROAD_SOURCE_LABELS, +} from './roadModes.js'; import { phaseTiming } from '../../sources/phaseTiming.js'; import { RoadRequestError, roadRequestError } from './source.js'; import { @@ -294,10 +299,14 @@ export function createIngestion({ // settled; before that (a session's first load) the cache is skipped // rather than waiting on the probe. Writes are decided by the snapshot. const cacheMode = - layerState._roadMode === 'osm' + normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm' ? 'osm' : layerState._flowStatusKnown - ? resolveRoadMode(layerState._roadMode, layerState._liveMode) + ? resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ) : null; // Cache key: fixed-precision bounding-box string for deterministic lookups const cacheKey = @@ -378,6 +387,7 @@ export function createIngestion({ (segments) => ({ segments, hasKey: layerState._liveMode, + statusUnavailable: layerState._flowStatusUnavailable, partial: source.getFlowSessionStats?.().partial, }), (error) => { @@ -388,6 +398,7 @@ export function createIngestion({ return { segments: [], hasKey: layerState._liveMode, + statusUnavailable: layerState._flowStatusUnavailable, error: reason, }; }, @@ -397,7 +408,9 @@ export function createIngestion({ // This uses the same shared XYZ cache and cap, with no extra ring. if ( altitude <= FAST_FETCH_ALTITUDE && - layerState._roadMode !== 'tomtom' + resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !== + 'tomtom' && + (roadModesSimulate(layerState._roadModes) || layerState._liveMode) ) { source .requestOsmRoads?.(clamped, { @@ -411,8 +424,19 @@ export function createIngestion({ .catch(() => {}); } requestSignal.throwIfAborted(); - // Until the probe settles only OpenStreetMap roads can be drawn. - layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm']; + // Until the probe settles only OpenStreetMap roads can be drawn (or, + // for a host without them, the TomTom roads still to come). + layerState._roadSource = + ROAD_SOURCE_LABELS[ + cacheMode || + (roadModesSimulate(layerState._roadModes) + ? 'osm' + : resolveRoadMode( + layerState._roadMode, + true, + layerState._roadModes, + )) + ]; let cache = cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null; if (cache) { @@ -581,7 +605,11 @@ export function createIngestion({ if (generation === layerState._loadGeneration && !renderedSomething) layerState._roadSource = ROAD_SOURCE_LABELS[ - resolveRoadMode(layerState._roadMode, layerState._liveMode) + resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ) ]; retryable = !isUnavailableCapability(e); layerState._roadRetryStopped = !retryable; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 1c53e3ebe..b89077cf7 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -1,6 +1,7 @@ import { observeTrafficSurface } from './surface.js'; import * as Cesium from 'cesium'; import { TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { resolveRoadMode } from './roadModes.js'; import { claimCameraSensitivity, releaseCameraSensitivity, @@ -81,10 +82,15 @@ export function createLifecycle({ if (layerState._enabled) return; layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); - if (layerState._roadMode !== 'tomtom') source.prefetch?.(); + // OpenFreeMap metadata, unless only TomTom roads can be drawn. + if ( + resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !== + 'tomtom' + ) + source.prefetch?.(); parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; - layerState._pointCollection.show = true; + layerState._pointCollection.show = layerState._showDots; layerState._preRenderRemover = viewer.scene.preRender.addEventListener( parts.animation.animate, diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index c20b15e41..13f5b101c 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -117,6 +117,23 @@ export const FLOW_BUCKET_COLORS = { jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), }; +// ─── Flow road lines (`lines` row toggle, live mode only) ──────────────── +/** + * @const {number} Max flow-colored road lines per render. Jam first, then + * slow, then free-flow, longest first within a bucket, so a dense city keeps + * its congestion when the cap trims the free roads. + */ + +export const FLOW_LINE_CAP = 1500; + +/** @const {number} Px — flow road-line width (every bucket). */ + +export const FLOW_LINE_WIDTH = 4; + +/** @const {number} Flow road-line opacity: the bucket colors, steady. */ + +export const FLOW_LINE_ALPHA = 0.8; + // ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── /** @const {number} Max congestion heat-line polylines per render (jam first). */ diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index bf0132b98..402cd08b6 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -2,6 +2,10 @@ import * as Cesium from 'cesium'; import { flowBucket } from '../../data/trafficFlowStyle.js'; import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; import { + FLOW_BUCKET_COLORS, + FLOW_LINE_ALPHA, + FLOW_LINE_CAP, + FLOW_LINE_WIDTH, HEAT_LINE_CAP, HEAT_JAM_COLOR, HEAT_SLOW_COLOR, @@ -34,45 +38,81 @@ export function createRendering({ : roads; } - /** Remove both heat-line ground primitives from the scene. */ + const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim']; - function removeHeatLines() { - if (layerState._heatJamPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatJamPrim, - ); - layerState._heatJamPrim = null; - } - if (layerState._heatSlowPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatSlowPrim, - ); - layerState._heatSlowPrim = null; + /** Detach the current line primitives, leaving them in the scene. */ + function takeHeatLines() { + const taken = []; + for (const key of LINE_KEYS) { + if (layerState[key]) taken.push(layerState[key]); + layerState[key] = null; } layerState._heatLineCount = 0; + return taken; + } + + function removePrimitives(primitives) { + for (const primitive of primitives) + layerState._viewer?.scene.groundPrimitives.remove(primitive); + } + + /** Remove the heat-line / flow-line ground primitives from the scene. */ + + function removeHeatLines() { + removePrimitives(takeHeatLines()); + removePrimitives(layerState._heatRetired || []); + layerState._heatRetired = []; parts.animation.syncRenderDemand?.(); } /** - * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): - * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing - * pulsing red for jam, faint flat amber for slow — with the dots animating - * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam - * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam - * first, longest first); overflow is logged. No-op in sim mode, when the - * heatline mode is off, or without ground-primitive support. + * Replaced lines stay drawn until their successors finish building + * (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never + * blinks. Called every held frame. + */ + + function retireHeatLines() { + if (!layerState._heatRetired?.length) return; + if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready)) + return; + removePrimitives(layerState._heatRetired); + layerState._heatRetired = []; + } + + /** + * Rebuild the road-line underlay drawn from live flow, in one of two looks: + * + * - Flow lines (the `lines` row toggle): every road with flow drapes a + * steady line in its bucket color — green free-flow, amber slow, red jam. + * Capped at FLOW_LINE_CAP. + * - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a + * glowing pulsing red for jam and a faint flat amber for slow. Capped at + * HEAT_LINE_CAP. + * + * One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch + * pulses through one shared material. Kept roads are jam first, then slow, + * then free, longest first; overflow is logged. Lines drape on 3D tiles when + * the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in + * sim mode, when both looks are off, or without ground-primitive support. * * @param {Array} roads - Road objects visible in the current render. */ function rebuildHeatLines(roads) { - removeHeatLines(); + const previous = takeHeatLines(); + const finish = () => { + // Nothing new to wait for: the old lines go now. + if (LINE_KEYS.some((key) => layerState[key])) + (layerState._heatRetired ||= []).push(...previous); + else removePrimitives(previous); + parts.animation.syncRenderDemand?.(); + }; if ( !layerState._viewer || !layerState._liveMode || !parts.style.heatlineOn() ) - return; + return finish(); if (layerState._heatSupported === null) { layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( layerState._viewer.scene, @@ -82,22 +122,25 @@ export function createRendering({ '[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled', ); } - if (!layerState._heatSupported) return; + if (!layerState._heatSupported) return finish(); + const flowLines = layerState._showLines; + const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP; + const rank = { jam: 0, slow: 1, free: 2 }; const candidates = []; for (const road of roads) { const flow = road.flow; if (!flow || flow.closure) continue; const bucket = flowBucket(flow.level); - if (bucket === 'free') continue; + if (bucket === 'free' && !flowLines) continue; let len = 0; for (const d of road.segmentDist) len += d; candidates.push({ road, bucket, len }); } candidates.sort((a, b) => - a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1, + a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket], ); - const kept = candidates.slice(0, HEAT_LINE_CAP); + const kept = candidates.slice(0, cap); const instancesFor = (bucket, width) => kept @@ -112,51 +155,72 @@ export function createRendering({ }), ); - // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in - // luminance like the dots: jam = white glow, slow = faint gray. - const monoHeat = + // Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in + // luminance like the dots: jam = white, slow = gray, free = dim gray. + const mono = layerState._presetDots === 'on' && trafficStyleProfile(layerState._stylePreset) === 'mono'; - const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; - const slowLineColor = monoHeat - ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) - : HEAT_SLOW_COLOR; - - const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); - if (jamInstances.length) { - layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: jamInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), - glowPower: 0.25, + const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha); + const classificationType = + layerState._viewer.scene.globe?.show === false + ? Cesium.ClassificationType.CESIUM_3D_TILE + : flowLines + ? Cesium.ClassificationType.BOTH + : Cesium.ClassificationType.CESIUM_3D_TILE; + const add = (instances, material) => + instances.length + ? layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + classificationType, + appearance: new Cesium.PolylineMaterialAppearance({ material }), }), + ) + : null; + + // Lines take the dots' active (preset-aware) colors. + const dotColor = (bucket) => + layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket]; + if (flowLines) { + const lineColor = { + jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'), + slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'), + free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'), + }; + for (const [bucket, key] of [ + ['free', '_heatFreePrim'], + ['slow', '_heatSlowPrim'], + ['jam', '_heatJamPrim'], + ]) + layerState[key] = add( + instancesFor(bucket, FLOW_LINE_WIDTH), + Cesium.Material.fromType('Color', { + color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA), }), + ); + } else { + const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR; + const slowLineColor = mono + ? gray(0.7, HEAT_SLOW_COLOR.alpha) + : HEAT_SLOW_COLOR; + layerState._heatJamPrim = add( + instancesFor('jam', HEAT_LINE_JAM_WIDTH), + Cesium.Material.fromType('PolylineGlow', { + color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), + glowPower: 0.25, }), ); - } - const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); - if (slowInstances.length) { - layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: slowInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('Color', { - color: slowLineColor, - }), - }), - }), + layerState._heatSlowPrim = add( + instancesFor('slow', HEAT_LINE_SLOW_WIDTH), + Cesium.Material.fromType('Color', { color: slowLineColor }), ); } layerState._heatLineCount = kept.length; - parts.animation.syncRenderDemand?.(); + finish(); if (candidates.length > kept.length) { console.log( - `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, + `[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`, ); } } @@ -270,7 +334,13 @@ export function createRendering({ renderMetrics, ) : null; - if (!label.includes('tile')) rebuildHeatLines(filteredRoads); + // The flow recolor above already rebuilt the lines from every retained + // road; without dots to recolor, rebuild them here. + if ( + !label.includes('tile') && + !(layerState._liveMode && layerState._dots.length) + ) + rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude)); if (state) { const heatEnd = parts.timing.trafficTimingMark( state, @@ -314,6 +384,7 @@ export function createRendering({ return { visibleRoadsForAltitude, removeHeatLines, + retireHeatLines, rebuildHeatLines, renderRoadsForAltitude, }; diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index ce62f5e1f..785390d93 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -46,24 +46,59 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75]; const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']); const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']); +/** + * Validate a host's road-source list (`createTrafficSource({ roadModes })`): + * a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order. + * @param {*} value - Candidate list; undefined means every mode. + * @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>} + */ +export function normalizeRoadModes(value) { + if (value === undefined) return TRAFFIC_ROAD_MODES; + const modes = Array.isArray(value) + ? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode)) + : []; + if (!modes.length || modes.length !== value.length) + throw new TypeError( + `Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`, + ); + return Object.freeze(modes); +} + +/** + * Whether a host's modes allow simulated roads. Without OpenStreetMap the + * layer never simulates: keyless, it draws no roads and says why. + * @param {ReadonlyArray} [modes] + * @returns {boolean} + */ +export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) { + return modes.includes('osm'); +} + /** * Validate an explicit choice. * @param {*} value - Candidate mode. + * @param {ReadonlyArray} [modes] - The host's offered modes. * @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default". */ -export function normalizeRoadMode(value) { - return TRAFFIC_ROAD_MODES.includes(value) ? value : null; +export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) { + return modes.includes(value) ? value : null; } /** * Resolve the mode actually drawn. Without a TomTom key every choice draws - * OpenStreetMap roads; with one, an explicit choice wins over Hybrid. + * OpenStreetMap roads when the host offers them; with one, an explicit + * choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps + * its TomTom choice keyless (nothing is drawn; the status names the reason), + * and a host without Hybrid defaults to its first offered mode. * @param {*} value - Requested mode (null for the default). * @param {boolean} hasKey - The server reported a TomTom key. + * @param {ReadonlyArray} [modes] - The host's offered modes. * @returns {'tomtom'|'osm'|'hybrid'} */ -export function resolveRoadMode(value, hasKey) { - return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm'; +export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) { + const chosen = normalizeRoadMode(value, modes); + if (!hasKey && roadModesSimulate(modes)) return 'osm'; + return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]); } const FLOW_ROAD_TYPES = Object.freeze({ diff --git a/src/layers/traffic/roadStatus.i18n.test.mjs b/src/layers/traffic/roadStatus.i18n.test.mjs index 655ae6557..17dc074a3 100644 --- a/src/layers/traffic/roadStatus.i18n.test.mjs +++ b/src/layers/traffic/roadStatus.i18n.test.mjs @@ -74,7 +74,23 @@ function roadChips(liveMode) { source: { getFlowSessionStats: () => ({}) }, parts: {}, }); - return controls.methods.getRowControls().chips; + return controls.methods + .getRowControls() + .chips.filter((chip) => chip.id.startsWith('roads-')); +} + +function toggleChips() { + const state = createState({ services: {} }); + state._liveMode = true; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: {}, + }); + return controls.methods + .getRowControls() + .chips.filter((chip) => !chip.id.startsWith('roads-')); } test('en: road-source chips keep their English labels and titles', () => { @@ -124,3 +140,28 @@ for (const [locale, hybrid] of [ } }); } + +test('en: the Lines and Dots toggles keep their English labels and titles', () => { + setLocale('en'); + assert.deepEqual( + toggleChips().map((chip) => [chip.id, chip.label, chip.title]), + [ + ['lines', 'Lines', 'Road lines colored by live flow: green to red'], + ['dots', 'Dots', 'Moving dots colored by live flow'], + ], + ); +}); + +for (const locale of ['ru', 'es', 'ja']) { + test(`${locale}: the Lines and Dots toggles are translated`, () => { + setLocale(locale); + try { + for (const chip of toggleChips()) { + assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale); + assert.doesNotMatch(chip.title, /colored by live flow/, locale); + } + } finally { + setLocale('en'); + } + }); +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index e23dac27c..7327a3f98 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -1,7 +1,10 @@ import { createHybridFill, flowSegmentsToRoads, + normalizeRoadMode, + normalizeRoadModes, resolveRoadMode, + roadModesSimulate, ROAD_SOURCE_LABELS, } from './roadModes.js'; import { phaseTiming } from '../../sources/phaseTiming.js'; @@ -80,7 +83,15 @@ export function roadRequestError(status, cause) { }); } -/** Supply tile-derived road geometry and flow availability without Overpass queries. */ +/** + * Supply tile-derived road geometry and flow availability without Overpass queries. + * + * `roadModes` is the host's offer of road sources (default: all three). The + * layer shows a chip per offered mode (none for a single mode) and never + * draws one outside it. Without `osm` nothing is simulated: keyless, or with + * the traffic service unreachable, requests fail with the reason instead of + * drawing white OpenStreetMap dots. + */ export function createTrafficSource({ fetchImpl = (...args) => globalThis.fetch(...args), tileFetchImpl = (...args) => globalThis.fetch(...args), @@ -88,10 +99,13 @@ export function createTrafficSource({ fetchImpl: tileFetchImpl, selectDecoded: ({ roads }) => ({ roads }), }), + roadModes, } = {}) { + const modes = normalizeRoadModes(roadModes); const flow = createFlowTileSource({ fetchImpl }); const api = { ...flow, + roadModes: modes, prefetch: () => mapTiles.getMetadata().catch(() => {}), resetFlowTileCache() { flow.resetFlowTileCache(); @@ -247,8 +261,8 @@ export function createTrafficSource({ * * @param {{south:number, west:number, north:number, east:number}} box * @param {Object} [options] - * @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default). - * @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot] + * @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default). + * @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot] * @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline. * @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait. * @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets. @@ -269,7 +283,16 @@ export function createTrafficSource({ box.east - box.west > 10 ) throw new TypeError('A bounded road viewport is required'); - if (roadMode === 'osm' || !flowSnapshot) + roadMode = normalizeRoadMode(roadMode, modes); + if (!roadModesSimulate(modes) && !flowSnapshot) + throw new TypeError( + 'Road selection requires a flow availability snapshot', + ); + if ( + roadMode === 'osm' || + !flowSnapshot || + (modes.length === 1 && modes[0] === 'osm') + ) return api.requestOsmRoads(box, { ...options, onTile }); const signal = options.signal; const osm = []; @@ -332,7 +355,13 @@ export function createTrafficSource({ osmError = error; } }; - const osmJob = roadMode === 'tomtom' ? null : loadOsm(); + // A host without OpenStreetMap waits for the key before fetching fill. + const osmJob = + roadMode === 'tomtom' || + modes.every((entry) => entry === 'tomtom') || + !roadModesSimulate(modes) + ? null + : loadOsm(); osmJob?.catch(() => {}); // The snapshot waits on the status probe and flow tiles; never let it // hold the road pass past its own deadline. A late snapshot counts as a @@ -357,7 +386,21 @@ export function createTrafficSource({ throw new TypeError( 'Road selection requires a flow availability snapshot', ); - mode = resolveRoadMode(roadMode, live.hasKey); + mode = resolveRoadMode(roadMode, live.hasKey, modes); + // A host without OpenStreetMap never simulates: name why no roads draw. + if (!live.hasKey && !roadModesSimulate(modes)) + throw Object.assign( + new RoadRequestError( + live.error || + (live.statusUnavailable + ? msg( + 'TomTom roads unavailable while the traffic service is unreachable', + ) + : msg('TomTom roads need a TomTom key')), + ), + // A known missing key cannot recover this session; the rest can. + { retryable: Boolean(live.error || live.statusUnavailable) }, + ); if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error); if (mode !== 'osm') { tomtom = flowSegmentsToRoads(live.segments); @@ -365,7 +408,7 @@ export function createTrafficSource({ publish(composed(), true); } if (osmJob) await osmJob; - else if (mode === 'osm') await loadOsm(); + else if (mode !== 'tomtom') await loadOsm(); signal?.throwIfAborted(); if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError; const data = snapshot(composed(), true); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index ecd34c9ee..8c2fd9f94 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,14 +1,17 @@ -import { normalizeRoadMode } from './roadModes.js'; +import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js'; import * as Cesium from 'cesium'; import { FLOW_BUCKET_COLORS } from './policy.js'; -export function createState({ services }) { +export function createState({ services, source }) { const layerState = {}; + /** The host's offered road sources (createTrafficSource `roadModes`). */ + layerState._roadModes = normalizeRoadModes(source?.roadModes); layerState._roadMode = typeof location === 'undefined' ? null : normalizeRoadMode( new URLSearchParams(location.search).get('trafficRoads'), + layerState._roadModes, ); // The query override is consumed at construction and shields passive restoration. layerState._roadModeUrlOverride = layerState._roadMode; @@ -167,6 +170,14 @@ export function createState({ services }) { layerState._jamViz = 'density'; + /** + * Row toggles (`lines` / `dots` params): flow-colored road lines draped on + * the surface, and the moving dots. Live mode only for lines — the keyless + * simulation has no flow to color them with. + */ + layerState._showLines = false; + layerState._showDots = true; + /** * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One @@ -181,6 +192,14 @@ export function createState({ services }) { layerState._heatSlowPrim = null; + /** @type {Cesium.GroundPolylinePrimitive|null} Free-flow road-line batch (`lines` only). */ + + layerState._heatFreePrim = null; + + /** @type {Cesium.GroundPolylinePrimitive[]} Replaced lines kept until successors are ready. */ + + layerState._heatRetired = []; + /** @type {number} Heat-lines in the current render (stats). */ layerState._heatLineCount = 0; diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js index 6d236da92..f303e69a9 100644 --- a/src/layers/traffic/style.js +++ b/src/layers/traffic/style.js @@ -137,10 +137,14 @@ export function createStyle({ state: layerState, services, parts, source }) { return layerState._jamViz === 'density' || layerState._jamViz === 'both'; } - /** @returns {boolean} Heat-line prototype active. */ + /** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */ function heatlineOn() { - return layerState._jamViz === 'heatline' || layerState._jamViz === 'both'; + return ( + layerState._showLines || + layerState._jamViz === 'heatline' || + layerState._jamViz === 'both' + ); } return { presetProfileActive, diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index f54ff23d8..809e0a57b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -253,7 +253,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), - traffic: Object.freeze({ roadMode: null }), + traffic: Object.freeze({ roadMode: null, lines: false, dots: true }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), 'weather-satellite': Object.freeze({ From 574430fb1d00526b1bbdbc28b5d1da9554e1ccd4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 11:44:17 -0800 Subject: [PATCH 075/174] docs(changelog): Street Traffic Lines and Dots toggles (#1609) --- changelog.d/1609-traffic-lines-dots-toggles.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1609-traffic-lines-dots-toggles.md diff --git a/changelog.d/1609-traffic-lines-dots-toggles.md b/changelog.d/1609-traffic-lines-dots-toggles.md new file mode 100644 index 000000000..be5060b45 --- /dev/null +++ b/changelog.d/1609-traffic-lines-dots-toggles.md @@ -0,0 +1,4 @@ +- Street Traffic offers separate Lines and Dots toggles while live flow is on: + Lines draws each road with flow in green, amber or red, and Dots shows or + hides the moving dots. A host can choose which road modes it offers; without + OpenStreetMap the layer never draws simulated roads (#1609). From b86c650428db01c0b09fadda54eabbf523ca506b Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:03 -0500 Subject: [PATCH 076/174] i18n(fr): typography, terminology and wording fixes --- src/i18n/locales/fr.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 738800324..485d3a93a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1018,11 +1018,11 @@ "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", "Box must enclose an area": "La zone doit délimiter une surface", - "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", - "Loading the Starlink shell…": "Chargement de la constellation Starlink…", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la couche de la constellation Starlink (des milliers de points en plus)", + "Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", "load failed": "échec du chargement", - "Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement", + "Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement", "DENSE ···": "DENSE ···", "DENSE ✕": "DENSE ✕", "DENSE": "DENSE", @@ -1105,7 +1105,7 @@ "Hybrid": "Hybride", "Lines": "Lignes", "Dots": "Points", - "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", @@ -1121,7 +1121,7 @@ "LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue", "LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %", - "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %", @@ -1218,7 +1218,7 @@ "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", - "Color: {units}": "Couleur : {units}", + "Color: {units}": "Couleur : {units}", "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", @@ -1350,7 +1350,7 @@ "{label} is unavailable": "{label} est indisponible", "This map stack": "Ce fond de carte", "{label} unavailable: {hint}": "{label} indisponible : {hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau", "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion", "Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra", "Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu", @@ -1489,7 +1489,7 @@ "Apply details": "Appliquer les détails", "Select a scene first": "Sélectionnez d'abord une scène", "Share selected scene": "Partager la scène sélectionnée", - "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", "Download scene JSON": "Télécharger le JSON de scène", "Choose data-pack files": "Choisir des fichiers de paquet de données", "Or choose a data-pack folder": "Ou choisir un dossier de paquet de données", @@ -2574,7 +2574,7 @@ "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée sur le flux {source}, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but its aircraft feed is unavailable": "La couche {layer} est activée, mais son flux d'aéronefs est indisponible", - "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais son actualisation à destination ne s'est pas terminée", + "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais l'actualisation de ses destinations ne s'est pas terminée", "{layer} is enabled, but {source} is unavailable": "La couche {layer} est activée, mais {source} est indisponible", "Could not arrive at {place}": "Impossible d'atteindre {place}", "{name} is no longer loaded": "{name} n'est plus chargé", @@ -2810,7 +2810,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", @@ -2831,7 +2831,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous demandez sa lecture à la voix. Votre IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", @@ -3194,7 +3194,7 @@ "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", "SIGHTING": "OBSERVATION", - "Show {name} sighting": "Afficher l’observation de {name}", + "Show {name} sighting": "Afficher l'observation de {name}", "seen {age} ago": "vu il y a {age}", "observed {age} ago": "observé il y a {age}", "Be Whale Wise": "Respectez les cétacés", @@ -3232,7 +3232,7 @@ "Cetacean": "Cétacé", "Wildlife Sightings": "Observations naturalistes", "Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation", - "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation", + "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation", "Wildlife": "Faune et flore", "Zoom in for local sightings": "Zoomez pour les observations locales", "Loading photo...": "Chargement de la photo…", @@ -3242,18 +3242,18 @@ "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "{count} observations dans la vue", - "Close sighting": "Fermer l’observation", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", + "Close sighting": "Fermer l'observation", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", - "{days}d": "{days}j", + "{days}d": "{days} j", "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", - "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "event age filter|All": "Tous", "Event age": "Ancienneté", - "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } From 36c29d6ab49ada6ae6b89f912a10d6640e96ded5 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:15 -0500 Subject: [PATCH 077/174] changelog: french catalog review --- changelog.d/1611-fr-catalog-review.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1611-fr-catalog-review.md diff --git a/changelog.d/1611-fr-catalog-review.md b/changelog.d/1611-fr-catalog-review.md new file mode 100644 index 000000000..dad302092 --- /dev/null +++ b/changelog.d/1611-fr-catalog-review.md @@ -0,0 +1,2 @@ +- French catalog review: missing narrow no-break spaces, apostrophes, one + mistranslated message and the Starlink shell wording (abiyeenzo, #1611). From 5e42a83d88227daea88c1b9434d6af76f92040a2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 21:51:46 -0800 Subject: [PATCH 078/174] fix(chat): send OpenAI chat tool schemas Chat Completions accepts OpenAI refused every typed chat request with 400 invalid_function_parameters: resolve_area's area-id pattern uses Unicode property classes (\p{L}), which Chat Completions does not accept as a regex. OpenAI chat now leaves such patterns out of the tool schemas it sends; each action still checks its arguments against its own contract, and realtime voice keeps the patterns. --- server/providers/openai/chat.js | 31 ++++++++++++++++++++++++++++++- src/textChatServer.test.mjs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/server/providers/openai/chat.js b/server/providers/openai/chat.js index 413d69ba9..6ca4648d6 100644 --- a/server/providers/openai/chat.js +++ b/server/providers/openai/chat.js @@ -64,6 +64,30 @@ function validMessages(messages) { return !pending.length; } +/** + * A tool schema without the `pattern`s Chat Completions refuses: it answers + * 400 for a whole request whose tool uses Unicode property classes (`\p{L}`), + * as `resolve_area`'s area ids do. Every argument is still checked against + * the action's own contract before the tool runs, so the model loses a hint, + * not a rule. Realtime voice accepts these patterns and keeps them. + */ +export function chatSchema(schema) { + if (Array.isArray(schema)) return schema.map(chatSchema); + if (!object(schema)) return schema; + return Object.fromEntries( + Object.entries(schema) + .filter( + ([key, value]) => + !( + key === 'pattern' && + typeof value === 'string' && + /\\[pP]\{/.test(value) + ), + ) + .map(([key, value]) => [key, chatSchema(value)]), + ); +} + export function createOpenAiChatHandler({ annotationGuidance, tools = GEV_REALTIME_TOOLS, @@ -126,7 +150,12 @@ export function createOpenAiChatHandler({ tools: offeredTools(tools, { overpass }).map( ({ name, description, parameters }) => ({ type: 'function', - function: { name, description, parameters, strict: false }, + function: { + name, + description, + parameters: chatSchema(parameters), + strict: false, + }, }), ), }, diff --git a/src/textChatServer.test.mjs b/src/textChatServer.test.mjs index 61b09e987..b7137bbdd 100644 --- a/src/textChatServer.test.mjs +++ b/src/textChatServer.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import { once } from 'node:events'; import { createGeminiChatHandler } from '../server/providers/gemini/chat.js'; -import { createOpenAiChatHandler } from '../server/providers/openai/chat.js'; +import { + chatSchema, + createOpenAiChatHandler, +} from '../server/providers/openai/chat.js'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import { realtimeDirectives, @@ -534,6 +537,11 @@ for (const [name, provider] of Object.entries(providers)) { assert.equal(offered.includes('osm_query'), overpass); assert.equal(prompt.includes('osm_query'), overpass); assert.ok(offered.includes('resolve_area')); + // Chat Completions answers 400 invalid_function_parameters for a + // pattern with Unicode property classes, as resolve_area's area ids + // use; OpenAI chat leaves those patterns out. + if (name === 'openai') + assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/); } }); test(`${name} throttled replies say they are rate limited`, async (t) => { @@ -697,3 +705,25 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t const body = await (await f.send()).json(); assert.equal(body.content.tool_calls, undefined); }); + +test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => { + assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/); + assert.deepEqual( + chatSchema({ + type: 'object', + properties: { + id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{ pattern: '\\P{N}' }] }, + }, + }), + { + type: 'object', + properties: { + id: { type: 'string', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{}] }, + }, + }, + ); +}); From 8ec92eb48c97291fcdb755728fca2fadf5df85ed Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 21:52:03 -0800 Subject: [PATCH 079/174] docs(changelog): OpenAI chat tool patterns (#1616) --- changelog.d/1616-openai-chat-tool-patterns.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1616-openai-chat-tool-patterns.md diff --git a/changelog.d/1616-openai-chat-tool-patterns.md b/changelog.d/1616-openai-chat-tool-patterns.md new file mode 100644 index 000000000..225d3f4a5 --- /dev/null +++ b/changelog.d/1616-openai-chat-tool-patterns.md @@ -0,0 +1,4 @@ +- Typed chat on OpenAI answers again: OpenAI refused every message because + `resolve_area`'s area-id pattern uses Unicode classes Chat Completions does + not accept. Chat leaves those patterns out; area ids are still checked + before the tool runs (samehkhamis, #1616). From 021e887f94929578544b8654eb68d77b99a0b023 Mon Sep 17 00:00:00 2001 From: daika Date: Sat, 7 Nov 2026 12:57:08 +0700 Subject: [PATCH 080/174] =?UTF-8?q?feat(cctv):=20add=20Iceland=20(Vegager?= =?UTF-8?q?=C3=B0in=20/=20IRCA)=20as=20a=20keyless=20CCTV=20source=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One JSON list from gagnaveita.vegagerdin.is (about 500 camera views at roughly 170 road stations). Frames are JPEG stills in a single directory on www.vegagerdin.is; only links inside that directory are registered, and the media proxy pins the same directory. The list decides ids, coordinates and which frame path is proxied, so it is fetched over https with redirects refused and the body read capped at 2 MiB; every rejection path releases the response body. Camera ids come from the frame file name, which is unique per view in the live feed, and repeated frames collapse before the cap. The feed's view description (`Skyring`) is the label. An explicit Icelandic facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading (264 of 499 views); a view that states none, or two different ones, keeps the id-hash low-confidence pose. A bare road direction ("Miklabraut austur") is not read as a facing. IRCA publishes road camera images under CC BY 4.0 and asks that the licensor is acknowledged without implying endorsement; the attribution is in the Data attribution popover and DATA_SOURCES.md, and every camera carries the licence string. Default cap is 250 with anchors at Reykjavík and four regional centres so a cap keeps every region. CCTV_ICELAND_ENABLED=0 turns the pack off. Verified against the live feed through fetchCctvImageFromUpstream: 499 of 499 frames returned a JPEG, all distinct, all under 20 minutes old. --- .env.example | 2 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 31 +++ server/providers/cctv/media.js | 2 + server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 164 +++++++++++++++ src/data/cctvIceland.test.mjs | 327 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 11 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvIceland.test.mjs diff --git a/.env.example b/.env.example index b3de4cefa..169ec66e3 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras +# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today) # CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras # CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 # CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 03747265d..94f581fe4 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | +| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | | **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | @@ -178,6 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above in the Data attribution popover and carries the licence string on every camera. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. diff --git a/README.md b/README.md index 32981b337..8af137fcb 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c91f1c439..b16fd30d1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4358,7 +4358,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 4d3cf63fe..b5128e02a 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -20,6 +20,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadCataloniaSourcesFromOpenData, + loadIcelandSourcesFromOpenData, loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadKingCountySourcesFromOpenData, @@ -110,6 +111,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), load: loadCataloniaSourcesFromOpenData, }, + { + name: 'iceland', + enabled: () => envEnabled('CCTV_ICELAND_ENABLED'), + load: loadIcelandSourcesFromOpenData, + }, { name: 'hk', enabled: () => envEnabled('CCTV_HK_ENABLED'), diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index e49567c1f..096a67f96 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -309,6 +309,37 @@ export const CATALONIA_ANCHORS = [ { lat: 41.5636, lon: 2.0111 }, // Terrassa ]; +/** + * Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras: + * one keyless JSON list, one row per camera view (about 500 views at roughly + * 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each + * rewritten by the publisher every few minutes. Camera images are published + * under CC BY 4.0 (IRCA terms of use, "Open data" section). + */ +export const ICELAND_CAMERAS_URL = + 'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1'; +/** The only directory Iceland camera frames may come from. */ +export const ICELAND_IMAGE_ORIGIN = + 'https://www.vegagerdin.is/vgdata/vefmyndavelar/'; +/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */ +export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Ground-elevation prior in metres: the feed carries none, and the country + * runs from sea level to highland passes. The client's ground snap corrects + * this on 3D-tile stacks. */ +export const ICELAND_GROUND_ELEVATION_M = 60; +/** The feed lists about 500 views; the default keeps half so the shared + * catalog cap still leaves room for denser regions. */ +export const DEFAULT_ICELAND_MAX_SOURCES = 250; +/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps + * every region's nearest cameras instead of only the capital's. */ +export const ICELAND_ANCHORS = [ + { lat: 64.1466, lon: -21.9426 }, // Reykjavík + { lat: 65.6885, lon: -18.1262 }, // Akureyri + { lat: 65.2639, lon: -14.3948 }, // Egilsstaðir + { lat: 66.0749, lon: -23.1351 }, // Ísafjörður + { lat: 63.9331, lon: -20.9971 }, // Selfoss +]; + /** Hong Kong Transport Department traffic snapshot cameras: keyless English * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ export const DEFAULT_HK_CAMERAS_URL = diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 7f89f2ae1..ecb423c4f 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -14,6 +14,7 @@ import { NSW_IMAGE_USER_AGENT, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, TFL_IMAGE_ORIGIN, WARENDORF_IMAGE_ORIGINS, WSDOT_IMAGE_ORIGIN, @@ -523,6 +524,7 @@ for (const prefix of [ TFL_IMAGE_ORIGIN, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, ...WARENDORF_IMAGE_ORIGINS, NSW_IMAGE_ORIGIN, CALGARY_IMAGE_ORIGIN, diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e49d12ffa..a1815eedc 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) { ); } +/** Iceland bounding box (mainland and nearby islands, with a margin). */ +export function isLikelyIcelandCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 63.0 && + lat <= 66.7 && + lon >= -25.0 && + lon <= -13.0 + ); +} + /** * Decode numeric HTML character references, decimal ("í") and hex * ("í") alike, the entity form the Catalonia feed uses for accented diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1ae4de637..1a7cf9f54 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -134,6 +134,12 @@ import { HOUSTON_MAX_CATALOG_BYTES, HOUSTON_GROUND_ELEVATION_M, HOUSTON_ANCHORS, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, + ICELAND_GROUND_ELEVATION_M, + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -152,6 +158,7 @@ import { isLikelyCalgaryCoordinate, isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, + isLikelyIcelandCoordinate, decodeNumericEntities, isLikelyNycCoordinate, isLikelyHoustonCoordinate, @@ -2029,6 +2036,163 @@ export async function loadCataloniaSourcesFromOpenData() { } } +/** + * Compass headings for Icelandic direction words, as they appear after "til" + * (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen", + * as in "séð austur"). + */ +const ICELAND_DIRECTION_HEADINGS = { + norður: 0, + norðaustur: 45, + austur: 90, + suðaustur: 135, + suður: 180, + suðvestur: 225, + vestur: 270, + norðvestur: 315, +}; +const ICELAND_DIRECTION_RE = + /(?", "í <átt>" or "séð <átt>" counts: a bare place name or + * a road direction ("Miklabraut austur") is not a facing. A description naming two + * different directions is ambiguous and yields NaN, and "niður á veg" (down + * onto the road) states none. + * + * @param {string} text - The feed's `Skyring` field. + * @returns {number} Heading in degrees [0..360), or NaN. + */ +export function icelandHeadingFromDescription(text) { + const headings = new Set(); + for (const match of String(text || '') + .toLowerCase() + .matchAll(ICELAND_DIRECTION_RE)) { + headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]); + } + return headings.size === 1 ? headings.values().next().value : NaN; +} + +/** + * One Vegagerðin camera row -> one catalog source, or null. A row is one camera + * view; a station (`Maelist_nr`) carries several. The id comes from the frame + * file name, which is unique per view in the live feed. + * + * @param {object} row - Element of the `vefmyndavelar2014_1` array. + * @returns {?object} + */ +export function icelandCameraToSource(row) { + const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN); + if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null; + // Numbers only: Number(null) and Number('') are 0. + const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN; + const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN; + if (!isLikelyIcelandCoordinate(lat, lon)) return null; + const stem = new URL(url).pathname + .slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length) + .replace(/\.jpe?g$/i, '') + .toLowerCase(); + if (!/^[a-z0-9_-]+$/.test(stem)) return null; + const cameraId = `is-vegagerdin-${stem}`; + const description = String(row.Skyring || '') + .replace(/\s+/g, ' ') + .replace(/\b(\p{L}+) \1\b/giu, '$1') + .trim(); + const station = String(row.Myndavel || '').trim(); + const heading = icelandHeadingFromDescription(description); + const hasHeading = Number.isFinite(heading); + return { + id: cameraId, + name: description || station || `Vegagerðin ${row.Maelist_nr}`, + city: 'Iceland', + cityId: 'iceland', + provider: 'Vegagerðin (IRCA)', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: ICELAND_GROUND_ELEVATION_M, + feedType: 'image', + url, + snapshotUrl: url, + sourceKind: 'iceland-vegagerdin', + license: + 'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0', + code: cameraDisplayCode(station.toUpperCase() || description), + }; +} + +/** + * Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list. + * Frames are stills on the IRCA host and the loader registers only links + * inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides + * ids, coordinates, labels and which frame path is proxied, so it is fetched + * over https, redirects are refused and the body read is capped at + * ICELAND_MAX_CATALOG_BYTES. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadIcelandSourcesFromOpenData() { + try { + const resp = await fetch(ICELAND_CAMERAS_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Iceland catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Iceland camera download failed:', resp.status); + return discard(); + } + const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES); + const rows = Array.isArray(body) ? body : []; + const cameras = rows.map(icelandCameraToSource).filter(Boolean); + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_ICELAND_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS); + console.log( + `[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Iceland camera download error:', + error?.message || error, + ); + return []; + } +} + /** * Read one XML child tag's text from an `` block. * diff --git a/src/data/cctvIceland.test.mjs b/src/data/cctvIceland.test.mjs new file mode 100644 index 000000000..3d1d6384c --- /dev/null +++ b/src/data/cctvIceland.test.mjs @@ -0,0 +1,327 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js'; +import { + icelandCameraToSource, + icelandHeadingFromDescription, + loadIcelandSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** One row shaped like the live `vefmyndavelar2014_1` payload. */ +const row = (over = {}) => ({ + Maelist_nr: 7001, + Myndavel: 'Hellisheiði', + Vegheiti: 'Hringvegur', + NrVegur: '1', + Skyring: 'Hellisheiði séð til vesturs', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`, + PntX: 395000, + PntY: 390000, + Breidd: 64.0136, + Lengd: -21.4, + ...over, +}); + +/** A live response body plus a flag that flips when the stream is cancelled. */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const json = (value) => + new Response(JSON.stringify(value), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +test('an Iceland row maps to a source on the pinned frame directory', () => { + const source = icelandCameraToSource(row()); + assert.equal(source.id, 'is-vegagerdin-hellisheidi_1'); + assert.equal(source.name, 'Hellisheiði séð til vesturs'); + assert.equal(source.city, 'Iceland'); + assert.equal(source.cityId, 'iceland'); + assert.equal(source.provider, 'Vegagerðin (IRCA)'); + assert.equal(source.lat, 64.0136); + assert.equal(source.lon, -21.4); + assert.equal(source.headingDeg, 270); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'iceland-vegagerdin'); + assert.equal(source.code, 'HELLISHEIÐI'); + assert.match(source.license, /CC BY 4\.0/); + assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('a view that states no facing takes the low-confidence id-hash pose', () => { + const source = icelandCameraToSource( + row({ + Skyring: 'Hellisheiði séð niður á veg', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`, + }), + ); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.headingDeg % 22.5, 0); + assert.equal( + source.headingDeg, + icelandCameraToSource( + row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }), + ).headingDeg, + 'the fallback depends on the id, not on the text', + ); +}); + +test('the label collapses the feed’s doubled spaces and repeated words', () => { + assert.equal( + icelandCameraToSource( + row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }), + ).name, + 'Strandarheiði séð niður á veg til austurs', + ); +}); + +test('only an explicit facing sets a heading', () => { + const cases = [ + ['Hellisheiði séð til vesturs', 270], + ['Holtavörðuheiði til norðurs', 0], + ['Klettsháls í suður', 180], + ['Arnarneshæð séð austur', 90], + ['Bústaðabrú séð norður Kringlumýrarbraut', 0], + ['Staður séð til norðausturs', 45], + ['Staður séð til suðausturs', 135], + ['Staður séð í suðvestur', 225], + ['Staður séð til norðvesturs', 315], + ['STAÐUR SÉÐ TIL VESTURS', 270], + ]; + for (const [text, heading] of cases) { + assert.equal(icelandHeadingFromDescription(text), heading, text); + } + for (const text of [ + '', + null, + 'Gautsdalur niður á veg', + 'Miklabraut austur', // a road direction, not a facing + 'Biskupsháls - norður', + 'Hringtorg norðan Selfoss', + 'Norðurárdalur niður á veg', // a place name that contains a cardinal + 'Staður séð til suðurlands', // a longer word, not "suður" + 'Staður séð til norðurs og til suðurs', // two facings: ambiguous + 'Norðfjarðargöng séð til Eskifjarðar', + ]) { + assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text)); + } +}); + +test('rows off the pinned directory, or with unusable frames or places, are dropped', () => { + const bad = [ + { Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://user:pw@www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' }, + { Slod: '' }, + { Slod: null }, + { Breidd: 0, Lengd: 0 }, + { Breidd: null, Lengd: null }, + { Breidd: '64.01', Lengd: '-21.4' }, + { Breidd: '64.01' }, // each coordinate must be a number on its own + { Lengd: '-21.4' }, + { Breidd: 59.9, Lengd: 10.7 }, // Oslo + { Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere + ]; + for (const over of bad) { + assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over)); + } + assert.equal(icelandCameraToSource(null), null); + assert.equal(icelandCameraToSource({}), null); + assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true); + assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true); +}); + +test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' }); + const rows = Array.from({ length: 12 }, (_, i) => + row({ + Maelist_nr: 8000 + i, + Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`, + Skyring: `Far ${i}`, + Breidd: 66.2, + Lengd: -15.9 + i * 0.01, + }), + ); + // Reykjavík cameras come last in the feed but first in the priority order. + rows.push( + row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Breidd: 64.1466, Lengd: -21.9426 }), + ); + rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg` })); // repeated frame + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return json(rows); + }); + const sources = await loadIcelandSourcesFromOpenData(); + assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]); + assert.equal(sources.length, 8); + assert.equal(sources[0].id, 'is-vegagerdin-rvk_1'); + assert.equal( + new Set(sources.map((s) => s.id)).size, + sources.length, + 'a frame the feed repeats collapses to one camera', + ); + assert.equal(sources.available, 13); +}); + +test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from({ length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, (_, i) => + row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + DEFAULT_ICELAND_MAX_SOURCES, + ); +}); + +test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => new Response('x', { status: 503 })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.restoreAll(); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => json({ rows: [] })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 200 })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const redirected = streamingResponse({ + status: 302, + headers: { location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1' }, + }); + t.mock.method(globalThis, 'fetch', async () => redirected.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal(redirected.state.cancelled, true, 'the redirect body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 500 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the error body is cancelled'); + + const valid = JSON.stringify([row()]); + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + return new Response(valid, { + headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) }, + }); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(padded)); + controller.close(); + }, + }), + ); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-')); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_CALTRANS_DISTRICTS: undefined, + CCTV_TFL_ENABLED: undefined, + CCTV_ICELAND_ENABLED: undefined, + CCTV_ICELAND_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); + if (String(url) !== ICELAND_CAMERAS_URL) { + return new Response('unavailable', { status: 503 }); + } + return json([row()]); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['is-vegagerdin-hellisheidi_1'], + ); + + process.env.CCTV_ICELAND_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.some(([url]) => url === ICELAND_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 7d88c6462..1734809c6 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -321,6 +321,13 @@ export const DATA_CREDITS = [ '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', }, + { + key: 'iceland-cctv', + html: + 'CCTV cameras & frames (Iceland): ' + + 'Icelandic Road and Coastal Administration (Vegagerðin) — ' + + 'CC BY 4.0', + }, { key: 'kingcounty-cctv', html: From f5d5da702376cce3fcc346e62a7002d02e029949 Mon Sep 17 00:00:00 2001 From: daika Date: Sat, 7 Nov 2026 12:57:48 +0700 Subject: [PATCH 081/174] docs(changelog): add the Iceland CCTV pack fragment --- changelog.d/1617-cctv-iceland.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/1617-cctv-iceland.md diff --git a/changelog.d/1617-cctv-iceland.md b/changelog.d/1617-cctv-iceland.md new file mode 100644 index 000000000..cf66651bf --- /dev/null +++ b/changelog.d/1617-cctv-iceland.md @@ -0,0 +1,12 @@ +- Add Icelandic road cameras from the Icelandic Road and Coastal Administration + (Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to + `gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166 + stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`, + and only links inside that directory are registered. The catalog is fetched + over https with redirects refused and a 2 MiB body cap. An explicit facing in + the view description ("til vesturs", "í suður", "séð austur") sets a + high-confidence heading; other views keep the low-confidence fallback pose. + IRCA publishes the images under CC BY 4.0, credited in the Data attribution + popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir, + Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and + `CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617). From 891eb560918d3bfe51bc9d46673d5aca42543f3c Mon Sep 17 00:00:00 2001 From: NeoXrated23 <12134461+NeoXrated23@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:46:48 +0200 Subject: [PATCH 082/174] feat(cctv): add optional Giroc camera and support larger HLS segments --- DATA_SOURCES.md | 1 + SECURITY.md | 2 +- config/cctv_sources.romania.json | 23 +++++++++++ docs/CURRENT-STATE.md | 23 +++++++++++ server/providers/cctv/stream.js | 4 +- src/data/cctvHlsStream.test.mjs | 71 ++++++++++++++++++++++++++++++++ src/data/dataCredits.js | 8 ++++ 7 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 config/cctv_sources.romania.json diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 03747265d..0426b6aa1 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -68,6 +68,7 @@ How to read this: | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | | **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | +| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | diff --git a/SECURITY.md b/SECURITY.md index 7b894c346..d0fd19b3e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,7 +69,7 @@ browser-key fallback for existing single-key setups. The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay: - **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts. -- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary. +- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary. - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. diff --git a/config/cctv_sources.romania.json b/config/cctv_sources.romania.json new file mode 100644 index 000000000..11b3f9696 --- /dev/null +++ b/config/cctv_sources.romania.json @@ -0,0 +1,23 @@ +[ + { + "id": "ro-giroc-webcamromania", + "name": "Giroc — Webcam Romania", + "city": "Giroc, Timiș", + "cityId": "giroc", + "provider": "Webcam Romania · webcamromania.ro", + "sourceKind": "configured", + "feedType": "hls", + "url": "https://w4.webcamromania.ro/giroc/index.m3u8", + "snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg", + "lat": 45.70028947266781, + "lon": 21.233675914004227, + "headingDeg": 0, + "headingConfidence": "low", + "pitchDeg": -17, + "fovDeg": 74, + "rangeM": 700, + "mountHeightM": 24, + "groundElevationM": 87, + "license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates." + } +] diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c91f1c439..9c5678270 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1558,6 +1558,29 @@ owners. Tuner calculations have a pure entry, with existing layer exports preserved. Destruction removes listeners and state subscriptions before ending tuning; a delayed Enable result cannot reveal or refocus removed controls. +## Giroc / Webcam Romania CCTV source pack + +`config/cctv_sources.romania.json` is an optional single-camera pack for Giroc, +Timiș, Romania. To use it, set `CCTV_SOURCES_FILE=config/cctv_sources.romania.json` +in the local `.env` and restart the server. Set `CCTV_FORCE_AUSTIN=1` as well to +retain the built-in live packs; despite its historical name, this flag applies +to all live packs. The default catalog is unchanged. + +The camera uses Webcam Romania's public HLS stream and JPEG snapshot, fetched +at runtime without an API key. Its position, 45.70028947266781, 21.233675914004227, +was corrected by the contributor rather than copied from the directory's +inaccurate pin. Heading, pitch, field of view, mounting height and ground +elevation are uncalibrated priors; the camera retains the RAW PRIOR badge. +See `DATA_SOURCES.md` for attribution and provider terms. + +The existing HLS puller resolves same-origin relative master/media references +and serves local leased playlists and segments. It downloads complete segments +without forwarding a browser Range header. Its per-segment streamed-byte cap +is 8 MiB: Giroc's roughly ten-second 2702×1520 segments can exceed the former +4 MiB cap (one measured 6,437,120 bytes). Retained data remains capped at +24 MiB and 12 segments per session, with two sessions maximum. Redirect +refusal, origin pinning, timeouts and lease expiry are unchanged. + ## NYC DOT TMC CCTV source pack The CCTV catalog can add New York City traffic cameras from the NYC DOT diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index da811f5f9..4e37cda20 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -5,7 +5,9 @@ export const HLS_LIMITS = Object.freeze({ sessions: 2, leasesPerSession: 8, playlistBytes: 256 * 1024, - segmentBytes: 4 * 1024 * 1024, + // High-resolution cameras can exceed 4 MiB in a single ten-second TS segment. + // Retained data still shares the independent 24 MiB session budget below. + segmentBytes: 8 * 1024 * 1024, sessionBytes: 24 * 1024 * 1024, segments: 12, pollMs: 2000, diff --git a/src/data/cctvHlsStream.test.mjs b/src/data/cctvHlsStream.test.mjs index a087eebdf..4a5f4afbc 100644 --- a/src/data/cctvHlsStream.test.mjs +++ b/src/data/cctvHlsStream.test.mjs @@ -10,6 +10,77 @@ const playlist = '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n'; const base = 'https://camera.example/live/list.m3u8'; +test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => { + // Giroc publishes roughly ten-second TS segments; one measured 6,437,120 + // bytes. The former 4 MiB download cap rejected it before playback began. + const segmentBytes = 6_437_120; + const master = + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n'; + const media = + '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' + + [0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join(''); + const manager = createHlsPuller({ + limits: { ...HLS_LIMITS, pollMs: 100000 }, + fetchImpl: async (url) => { + if (url === base) return new Response(master); + if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8') + return new Response(media); + const match = + /^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url); + if (!match) throw new Error('Unexpected playlist reference'); + // No Content-Length: the actual streamed bytes must also be accepted. + return new Response(Buffer.alloc(segmentBytes, Number(match[1]))); + }, + }); + t.after(() => manager.shutdown()); + const entry = await manager.ensure('giroc', base); + await entry.polling; + const result = await manager.buildPlaylist(entry, 'giroc'); + assert.ok(result, 'large valid segments should produce a playable playlist'); + assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/); + assert.equal(manager.getSegment('giroc', entry.token, 0), null); + const newest = manager.getSegment('giroc', entry.token, 4); + assert.equal(newest.length, segmentBytes); + assert.equal(newest[0], 4); + assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 }); +}); + +test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => { + for (const declared of [true, false]) { + let chunks = 0; + let cancelled = false; + await assert.rejects( + fetchHlsBytes(base, { + maxBytes: HLS_LIMITS.segmentBytes, + fetchImpl: async () => + new Response( + declared + ? 'small body' + : new ReadableStream({ + pull(controller) { + // Each chunk fits on its own; only their cumulative size + // crosses the cap. Leave it open to verify cancellation. + controller.enqueue( + Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)), + ); + }, + cancel() { + cancelled = true; + }, + }), + { + headers: declared + ? { 'content-length': String(8 * 1024 * 1024 + 1) } + : {}, + }, + ), + }), + /HLS response too large/, + ); + if (!declared) assert.equal(cancelled, true); + } +}); + test('playlist parser uses sequence, refuses escaping and unsupported references', () => { assert.deepEqual( parseHlsMedia(playlist, base).map((s) => s.seq), diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 7d88c6462..b7895321f 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -345,6 +345,14 @@ export const DATA_CREDITS = [ 'Transport Department / DATA.GOV.HK ' + '(Terms of Use — attribution required)', }, + { + key: 'webcamromania-cctv', + html: + 'Giroc camera (when configured): ' + + 'Webcam Romania. ' + + 'Video rights remain with the camera operator; ' + + 'provider terms apply.', + }, { key: 'vegvesen-cctv', html: From 9b65d41bab29327b9934f4214898ecfb0318107d Mon Sep 17 00:00:00 2001 From: NeoXrated23 <12134461+NeoXrated23@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:48:07 +0200 Subject: [PATCH 083/174] docs: add changelog entry for Giroc camera contribution --- changelog.d/1619-giroc-camera-hls-segments.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1619-giroc-camera-hls-segments.md diff --git a/changelog.d/1619-giroc-camera-hls-segments.md b/changelog.d/1619-giroc-camera-hls-segments.md new file mode 100644 index 000000000..f47718e16 --- /dev/null +++ b/changelog.d/1619-giroc-camera-hls-segments.md @@ -0,0 +1,5 @@ +- Add an optional Giroc / Webcam Romania CCTV source pack with corrected + coordinates, live HLS, a snapshot fallback and provider attribution. Allow + HLS segments up to 8 MiB so high-resolution cameras whose segments exceed + 4 MiB can play, while retaining the 24 MiB per-session cache and existing + origin and session limits (NeoXrated23, #1619). From 479286a6a61fa14c455ca2357096238ae2b43b92 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 7 Nov 2026 03:52:38 -0800 Subject: [PATCH 084/174] CCTV opens on the camera nearest the viewer, not the first in the catalog Turning CCTV on picked the first catalog record, so at King's Cross it showed a Sydney camera. enable() now picks the camera nearest the viewer. Until someone chooses a camera, each settled move re-picks the nearest, so a share link whose flight lands after CCTV turned on still opens nearby. --- src/data/cctv.js | 2 + src/data/cctv.test.mjs | 116 ++++++++++++++++++++++++++++ src/data/sharedOsmDisplays.test.mjs | 2 +- src/layers/cctv/index.js | 2 + src/layers/cctv/lifecycle.js | 13 ++-- src/layers/cctv/navigation.js | 32 ++++++++ src/layers/cctv/selection.js | 2 + src/layers/cctv/state.js | 6 ++ src/layers/cctv/testing.js | 2 + 9 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/data/cctv.js b/src/data/cctv.js index ad95f2794..ed3f56654 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest = export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const focusCctvRecord = layer.focusCctvRecord; export const maybeAutoHop = layer.maybeAutoHop; +export const openingCameraId = layer.openingCameraId; +export const reopenNearestCamera = layer.reopenNearestCamera; export const cctvCycleIndex = layer.cctvCycleIndex; export { CCTV_CALIBRATION_STORAGE_KEY_V1, diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index f76acdb72..bcd5c48ab 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -61,6 +61,8 @@ import cctvLayer, { materializeCctvActiveCoverageEntities, materializeCctvVisibleCoverageEntities, maybeAutoHop, + openingCameraId, + reopenNearestCamera, prioritizeActiveCctvGeometryRecord, processCctvGeometryDrainBatch, processCctvGeometryQueueBatch, @@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones } }); +test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => { + // QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera, + // the first catalog record, instead of the TfL camera 0.3 km away. + const viewer = makeDeselectViewer(); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + const sydney = makeDeselectRecord('au-sydney'); + sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 }; + const london = makeDeselectRecord('gb-kings-cross'); + london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 }; + try { + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: null, + enabled: false, + }); + assert.equal(openingCameraId(), 'gb-kings-cross'); + _setCctvCoverageStateForTest({ + viewer: null, + records: [sydney, london], + activeCameraId: null, + enabled: false, + }); + assert.equal( + openingCameraId(), + 'au-sydney', + 'with no viewer position the first record is the fallback', + ); + _setCctvCoverageStateForTest({ viewer, records: [], enabled: false }); + assert.equal(openingCameraId(), null); + } finally { + _setCctvCoverageStateForTest({ enabled: false }); + } +}); + +test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => { + // A share link turns CCTV on before its flight lands, so enable() picks + // from the startup view; the flight's settled move re-picks near London. + const viewer = makeDeselectViewer(); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + const sydney = makeDeselectRecord('au-sydney'); + sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 }; + const london = makeDeselectRecord('gb-kings-cross'); + london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 }; + for (const record of [sydney, london]) + Object.assign(record, { + groundResolved: {}, + groundSamples: {}, + groundPrior: 0, + frustumGeometry: null, + frustumPositions: null, + probeClampRangeM: null, + }); + _setCctvOverlayHostForTest({ + setEntries() {}, + setVisible() {}, + clearSource() {}, + }); + // Activation builds the projection canvas and frame image. + const originalDocument = globalThis.document; + const originalImage = globalThis.Image; + globalThis.document = { + createElement: () => ({ getContext: () => null }), + }; + globalThis.Image = class {}; + try { + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: 'au-sydney', + enabled: true, + openingCameraProvisional: true, + }); + assert.equal(reopenNearestCamera(), true); + assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross'); + assert.equal(reopenNearestCamera(), false, 'already the nearest'); + + // Someone chooses a camera: settled moves leave it alone. + setActiveCamera('au-sydney'); + assert.equal(reopenNearestCamera(), false); + assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney'); + + // A deselect is a choice too. + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: 'au-sydney', + enabled: true, + openingCameraProvisional: true, + }); + deactivateActiveCamera(); + assert.equal(reopenNearestCamera(), false); + assert.equal(cctvLayer.getUIState().activeCameraId, null); + } finally { + globalThis.document = originalDocument; + globalThis.Image = originalImage; + _setCctvOverlayHostForTest(); + _setCctvCoverageStateForTest({ enabled: false }); + } +}); + test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => { const calls = []; _setCctvOverlayHostForTest({ diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 273a9dc56..46b5d8894 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => { const v = viewer(t), empty = new Proxy({}, { get: () => noop }); const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost }, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty }, - parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} }); + parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} }); lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable(); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); }); diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 6f1cc77c7..36e1360b4 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) { _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, focusCctvRecord: parts.navigation.focusCctvRecord, maybeAutoHop: parts.navigation.maybeAutoHop, + openingCameraId: parts.navigation.openingCameraId, + reopenNearestCamera: parts.navigation.reopenNearestCamera, cctvCycleIndex: parts.navigation.cctvCycleIndex, }, ); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 4e4443df8..0a10d0090 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ layerState._enabled = false; layerState._activeCameraId = null; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; layerState._lastHopAt = 0; layerState._lastViewContext = ''; layerState._calibrationById = parts.calibration.loadCalibrationStore(); @@ -299,11 +300,8 @@ export function createLifecycle({ } layerState._count = layerState._records.length; - if (layerState._records.length > 0) { - // Projection runtime + first frame fetch are deferred to enable() so - // initializing the catalog stays render-cheap. - layerState._activeCameraId = layerState._records[0].camera.id; - } + // No camera is picked here: enable() opens on the one nearest the + // viewer, unless restored params selected one first. // Task 5: if the prior batch lost init's bounded race, apply it post-hoc // when it lands (pure recomputes — applyLateGroundPriors guards against @@ -361,6 +359,7 @@ export function createLifecycle({ layerState._horizonCullListener = () => { layerState._cameraMoving = false; stopViewMotionTimer(); + parts.navigation.reopenNearestCamera(); settleView(); }; layerState._viewer.camera.moveEnd.addEventListener( @@ -508,8 +507,9 @@ export function createLifecycle({ return Boolean(coverage && layerState._recordById.has(coverage[1])); }); if (!layerState._activeCameraId && layerState._records.length) { - layerState._activeCameraId = layerState._records[0].camera.id; + layerState._activeCameraId = parts.navigation.openingCameraId(); layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = true; } const activeRecord = parts.selection.getActiveRecord(); if (activeRecord) { @@ -629,6 +629,7 @@ export function createLifecycle({ layerState._enabled = false; layerState._activeCameraId = null; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; // Clear existing subscribers rather than replacing the Set — // replacing would silently orphan any unsubscribe() closures layerState._listeners.clear(); diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index 6c576bb4f..5ee3ed2a4 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -33,6 +33,36 @@ export function createNavigation({ return best?.id || null; } + /** + * The camera the layer opens on when none is selected: the one nearest the + * viewer, or the first record when the viewer has no position yet. + * @returns {string|null} Camera ID, or null when the catalog is empty. + */ + + function openingCameraId() { + return ( + nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null + ); + } + + /** + * After a settled move, moves the opening camera to the one nearest the + * view, until someone chooses a camera. A shared view's flight lands after + * enable() picked from the startup view. + * @returns {boolean} True when the active camera changed. + */ + + function reopenNearestCamera() { + if (!layerState._openingCameraProvisional || !layerState._enabled) + return false; + const nearest = nearestCameraIdToViewer(); + if (!nearest || nearest === layerState._activeCameraId) return false; + parts.selection.setActiveCamera(nearest); + // setActiveCamera ends the opening pick; this is still that pick. + layerState._openingCameraProvisional = true; + return true; + } + /** * Flies the Cesium viewer camera to frame the specified CCTV camera, * looking along its heading from above. @@ -149,6 +179,8 @@ export function createNavigation({ } return { nearestCameraIdToViewer, + openingCameraId, + reopenNearestCamera, focusCctvRecord, focusCamera, maybeAutoHop, diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index c86425fd0..f4c8f639e 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -83,6 +83,7 @@ export function createSelection({ } layerState._activeCameraId = cameraId; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; // A real activation creates projection work — wake the self-stopping loop. parts.projection.startProjectionLoop(); if (previousActiveRecord && previousActiveRecord !== record) { @@ -169,6 +170,7 @@ export function createSelection({ if (!record) return false; layerState._activeCameraId = null; layerState._autoHopSuspended = true; + layerState._openingCameraProvisional = false; record.activationDone = false; parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { parts.geometry.applyFrustumGeometry( diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 78c2b8fa9..5e70c814f 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -36,6 +36,12 @@ export function createState({ services }) { layerState._autoHop = false; + // True while the active camera is the one enable() opened on and nobody has + // chosen a camera since: each settled move then re-picks the one nearest + // the view, so a share flight that lands after enable still opens nearby. + + layerState._openingCameraProvisional = false; + // An explicit empty-space deselect keeps AUTO HOP configured but prevents its // timer from silently choosing a replacement. A later explicit activation or // AUTO HOP toggle-on releases the hold. diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js index c4aada9bb..207256f36 100644 --- a/src/layers/cctv/testing.js +++ b/src/layers/cctv/testing.js @@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) { coverageMode = 'on', showProjection = false, healthById = new Map(), + openingCameraProvisional = false, } = {}) { layerState._viewer = viewer; layerState._records = Array.isArray(records) ? records : []; @@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) { layerState._billboards = null; layerState._activeCameraId = activeCameraId; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = !!openingCameraProvisional; layerState._enabled = !!enabled; layerState._coverageMode = parts.model.normalizeCoverageMode( coverageMode, From 2cac463c6f4f69bad5e0657998cc303cf2fba50c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 7 Nov 2026 03:52:54 -0800 Subject: [PATCH 085/174] Changelog: CCTV opening camera --- changelog.d/1620-cctv-opening-camera.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1620-cctv-opening-camera.md diff --git a/changelog.d/1620-cctv-opening-camera.md b/changelog.d/1620-cctv-opening-camera.md new file mode 100644 index 000000000..10aa13f19 --- /dev/null +++ b/changelog.d/1620-cctv-opening-camera.md @@ -0,0 +1,3 @@ +- Turning CCTV on opens the camera nearest you instead of the first camera in + the catalog, which showed a Sydney camera in London. A share link opens on + the camera nearest the shared view once its flight lands (samehkhamis, #1620). From 872bbefb630718a16a454cbcb6ebab4125c956b1 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 7 Nov 2026 14:29:06 +0000 Subject: [PATCH 086/174] docs(cctv): README HLS cap to 8 MiB; Giroc pack local-only note --- README.md | 2 +- docs/CURRENT-STATE.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 32981b337..4098305da 100644 --- a/README.md +++ b/README.md @@ -639,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that pack with `CCTV_DELDOT_ENABLED=0`. The server allows two concurrent sessions. Each retains at most 12 segments -and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten +and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten second deadline. There are no segment files or ffmpeg processes. Redirects, off-origin references, encrypted playlists and non-MPEG-TS segments are refused. Each decoder has its own client lease (at most eight per session), including diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c5678270..62f617f2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1571,7 +1571,10 @@ at runtime without an API key. Its position, 45.70028947266781, 21.2336759140042 was corrected by the contributor rather than copied from the directory's inaccurate pin. Heading, pitch, field of view, mounting height and ground elevation are uncalibrated priors; the camera retains the RAW PRIOR badge. -See `DATA_SOURCES.md` for attribution and provider terms. +See `DATA_SOURCES.md` for attribution and provider terms. Webcam Romania's +terms grant no reuse licence for the video and name no camera operator; the +provider's sanctioned route is its iframe embed. Keep this pack to local use +and off hosted deployments unless the operator grants permission. The existing HLS puller resolves same-origin relative master/media references and serves local leased playlists and segments. It downloads complete segments From 39d70fa1c0e1878e6208068b1876d16614274fba Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 7 Nov 2026 14:38:52 +0000 Subject: [PATCH 087/174] fix(cctv): keep Icelandic labels intact and treat 'og' facings as ambiguous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label repeat-collapse uses letter lookarounds instead of ASCII-only \b, so 'Þverá Árnessýslu' is no longer joined and 'séð séð' collapses. - 'til austurs og vesturs' and 'í norður og suður' name two facings and keep the low-confidence pose. - Fallback name uses the frame stem when the station number is missing. - Tests: label and heading regressions, dedupe keeps the last row, cap clamp, high-confidence pose, frame redirect pin; file added to format scope. - Credit names the dataset, links the terms and states no endorsement. --- DATA_SOURCES.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/sources.js | 27 ++++--- src/data/cctvIceland.test.mjs | 120 ++++++++++++++++++++++++++++--- src/data/dataCredits.js | 6 +- 5 files changed, 136 insertions(+), 20 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 94f581fe4..e97d2f021 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -179,7 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. -- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above in the Data attribution popover and carries the licence string on every camera. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index dc9700593..b23ab75b0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -583,6 +583,7 @@ "src/data/cctvHk.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvA22.test.mjs", + "src/data/cctvIceland.test.mjs", "src/data/cctvNyc.test.mjs", "src/data/cctvHouston.test.mjs", "src/data/cctvNswSource.test.mjs", diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1a7cf9f54..f6bfa35be 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -2051,15 +2051,22 @@ const ICELAND_DIRECTION_HEADINGS = { vestur: 270, norðvestur: 315, }; -const ICELAND_DIRECTION_RE = - /(?", "í <átt>" or "séð <átt>" counts: a bare place name or - * a road direction ("Miklabraut austur") is not a facing. A description naming two - * different directions is ambiguous and yields NaN, and "niður á veg" (down - * onto the road) states none. + * a road direction ("Miklabraut austur") is not a facing. A description naming + * two different directions ("til norðurs og til suðurs", "til austurs og + * vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the + * road) states none. * * @param {string} text - The feed's `Skyring` field. * @returns {number} Heading in degrees [0..360), or NaN. @@ -2069,7 +2076,9 @@ export function icelandHeadingFromDescription(text) { for (const match of String(text || '') .toLowerCase() .matchAll(ICELAND_DIRECTION_RE)) { - headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]); + for (const word of [match[1], match[2]]) { + if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]); + } } return headings.size === 1 ? headings.values().next().value : NaN; } @@ -2097,14 +2106,16 @@ export function icelandCameraToSource(row) { const cameraId = `is-vegagerdin-${stem}`; const description = String(row.Skyring || '') .replace(/\s+/g, ' ') - .replace(/\b(\p{L}+) \1\b/giu, '$1') + // Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá + // Árnessýslu" while missing "séð séð". + .replace(/(? { assert.equal(source.lon, -21.4); assert.equal(source.headingDeg, 270); assert.equal(source.headingConfidence, 'high'); + assert.equal(source.pitchDeg, -24); + assert.equal(source.fovDeg, 56); + assert.equal(source.rangeM, 210); + assert.equal(source.mountHeightM, 10); assert.equal(source.feedType, 'image'); assert.equal(source.sourceKind, 'iceland-vegagerdin'); assert.equal(source.code, 'HELLISHEIÐI'); @@ -109,6 +114,27 @@ test('the label collapses the feed’s doubled spaces and repeated words', () => ).name, 'Strandarheiði séð niður á veg til austurs', ); + const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name; + // Repeats of words with Icelandic letters collapse too. + assert.equal( + label('Hellisheiði séð séð til austurs'), + 'Hellisheiði séð til austurs', + ); + assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá'); + // A word ending in a letter the next word starts with is not a repeat. + for (const text of [ + 'Þverá Árnessýslu', + 'brú Úlfarsá', + 'Vesturlandsvegur við Elliðaá Ártúnsbrekku', + ]) + assert.equal(label(text), text); +}); + +test('a row with no label, station or station number is named by its frame', () => { + const source = icelandCameraToSource( + row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }), + ); + assert.equal(source.name, 'Vegagerðin hellisheidi_1'); }); test('only an explicit facing sets a heading', () => { @@ -123,6 +149,8 @@ test('only an explicit facing sets a heading', () => { ['Staður séð í suðvestur', 225], ['Staður séð til norðvesturs', 315], ['STAÐUR SÉÐ TIL VESTURS', 270], + ['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing + ['Staður séð til austurs og Vesturlandsvegur', 90], ]; for (const [text, heading] of cases) { assert.equal(icelandHeadingFromDescription(text), heading, text); @@ -137,6 +165,9 @@ test('only an explicit facing sets a heading', () => { 'Norðurárdalur niður á veg', // a place name that contains a cardinal 'Staður séð til suðurlands', // a longer word, not "suður" 'Staður séð til norðurs og til suðurs', // two facings: ambiguous + 'Staður séð til austurs og vesturs', // the second facing shares "til" + 'Staður séð í norður og suður', + 'Staður séð í norður og í suður', 'Norðfjarðargöng séð til Eskifjarðar', ]) { assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text)); @@ -189,10 +220,16 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea }), ); // Reykjavík cameras come last in the feed but first in the priority order. + // A frame the feed repeats keeps its last row, as the other packs do. + rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' })); rows.push( - row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Breidd: 64.1466, Lengd: -21.9426 }), + row({ + Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, + Skyring: 'Kringlumýrarbraut', + Breidd: 64.1466, + Lengd: -21.9426, + }), ); - rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg` })); // repeated frame const seen = []; t.mock.method(globalThis, 'fetch', async (url, init) => { seen.push([String(url), init.redirect]); @@ -202,6 +239,8 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]); assert.equal(sources.length, 8); assert.equal(sources[0].id, 'is-vegagerdin-rvk_1'); + assert.equal(sources[0].name, 'Kringlumýrarbraut'); + assert.equal(sources[0].lat, 64.1466); assert.equal( new Set(sources.map((s) => s.id)).size, sources.length, @@ -213,8 +252,9 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { t.mock.method(console, 'log', () => {}); withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); - const rows = Array.from({ length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, (_, i) => - row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + const rows = Array.from( + { length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, + (_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), ); t.mock.method(globalThis, 'fetch', async () => json(rows)); assert.equal( @@ -223,17 +263,71 @@ test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { ); }); +test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from({ length: 650 }, (_, i) => + row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + for (const [value, expected] of [ + ['0', 8], + ['3', 8], + ['1000', 600], + ['abc', DEFAULT_ICELAND_MAX_SOURCES], + ]) { + process.env.CCTV_ICELAND_MAX_SOURCES = value; + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + expected, + value, + ); + } +}); + +test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => { + const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`; + const redirectTo = (location) => async (url) => + url === frame + ? new Response(null, { status: 302, headers: { location } }) + : new Response('frame', { headers: { 'content-type': 'image/jpeg' } }); + for (const location of [ + '/vgdata/other/x.jpg', + '/vgdata/vefmyndavelar/../x.jpg', + '/', + ]) + assert.equal( + await fetchWithinHost(frame, {}, redirectTo(location)), + null, + location, + ); + const followed = await fetchWithinHost( + frame, + {}, + redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'), + ); + assert.equal(await followed.text(), 'frame'); +}); + test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); - t.mock.method(globalThis, 'fetch', async () => new Response('x', { status: 503 })); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('x', { status: 503 }), + ); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); t.mock.restoreAll(); t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(globalThis, 'fetch', async () => json({ rows: [] })); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); - t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 200 })); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 200 }), + ); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); t.mock.method(globalThis, 'fetch', async () => { throw new Error('network down'); @@ -245,11 +339,17 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async ( t.mock.method(console, 'warn', () => {}); const redirected = streamingResponse({ status: 302, - headers: { location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1' }, + headers: { + location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1', + }, }); t.mock.method(globalThis, 'fetch', async () => redirected.response); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); - assert.equal(redirected.state.cancelled, true, 'the redirect body is cancelled'); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); t.mock.restoreAll(); t.mock.method(console, 'warn', () => {}); @@ -287,7 +387,9 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async ( test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); - const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-')); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'), + ); t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); withEnv(t, { CCTV_SOURCES_FILE: undefined, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 1734809c6..704db7fb8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -324,9 +324,11 @@ export const DATA_CREDITS = [ { key: 'iceland-cctv', html: - 'CCTV cameras & frames (Iceland): ' + + 'CCTV cameras & frames (Iceland): road camera images, fetched live, from the ' + 'Icelandic Road and Coastal Administration (Vegagerðin) — ' + - 'CC BY 4.0', + 'CC BY 4.0 ' + + '(terms); ' + + 'no endorsement by IRCA is implied', }, { key: 'kingcounty-cctv', From 6af14f7f13e6be7421f8da4883a87a1c1452f4de Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 7 Nov 2026 17:53:55 +0000 Subject: [PATCH 088/174] fix(cctv): leave Iceland to its own pack, credit World and Canada packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main now ships an Iceland pack that loads the full live Vegagerðin list (about 500 views) with its CC BY 4.0 credit. The World catalog's 166 Vegagerðin rows point at the same frames under different ids, so every one of those cameras would be drawn twice. Drop them from the World export and its loader policy; World is now 2,230 rows and the default catalog limit 7,350 (4,000 shared slots plus 3,350 reserved). Register Data attribution entries for Ottawa, Québec, TripCheck and MLIT, as every other CCTV pack has. MLIT's terms ask for the source in the form "出典:国土交通省 川の防災情報" with the page link, and ODOT asks for "Camera courtesy of ODOT"; TripCheck partner cameras keep their owner's name in the label. The Houston guard test from main checked the old 4,000 default ceiling; check the 4,000 shared slots instead, like the other pack tests. --- .env.example | 4 +- DATA_SOURCES.md | 4 +- README.md | 2 +- .../1506-cctv-taiwan-quebec-ottawa-world.md | 4 +- config/cctv_sources.world-image-cameras.json | 2656 ----------------- docs/CURRENT-STATE.md | 22 +- scripts/dev-fresh.sh | 2 +- server/providers/cctv/constants.js | 8 +- server/providers/cctv/media.js | 2 - server/providers/cctv/worldCameras.js | 12 +- src/data/cctvCatalogCap.test.mjs | 4 +- src/data/cctvHouston.test.mjs | 6 +- src/data/cctvWorldQuebec.test.mjs | 68 +- src/data/dataCredits.js | 32 + src/data/dataCredits.test.mjs | 14 + 15 files changed, 115 insertions(+), 2725 deletions(-) diff --git a/.env.example b/.env.example index bf4c0438f..8b3b5f831 100644 --- a/.env.example +++ b/.env.example @@ -275,8 +275,8 @@ AISSTREAM_API_KEY= # CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first) # CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links) # CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md) -# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Iceland, Oregon and MLIT (Japan) still cameras -# CCTV_MAX_SOURCES=7516 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World +# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras +# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d7ef60b80..baf154774 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -173,7 +173,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. - **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off. -- **World cams image subset.** The normal CCTV catalog adds 166 Icelandic Road and Coastal Administration cameras, 1,176 Oregon TripCheck stills, and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The server validates and pins each image URL to its provider path. [Vegagerðin's data-service licence](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) permits reuse with attribution but excludes third-party rights; [ODOT allows TripCheck image use and requests credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions); MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) are CC BY-compatible with attribution and warn against high-volume/periodic collection, so those frames refresh at ten minutes per camera. Images are not bundled or persisted; a bounded 32 MB in-memory cache reuses them until the per-source cadence. The 249 NZTA Journey Planner rows are excluded because its [website terms prohibit scraping](https://www.journeys.nzta.govt.nz/about-this-site/terms-of-use); use an authorized API feed or obtain permission before adding those rows. +- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover. - **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed. - **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`. - **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`. @@ -226,7 +226,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | | **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel | -| **World camera image subset** (2,396 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: Vegagerðin open-government licence, ODOT TripCheck public images, and MLIT site content compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded | Vegagerðin, Oregon Department of Transportation, and Japan Ministry of Land, Infrastructure, Transport and Tourism (MLIT) | +| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | | **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | diff --git a/README.md b/README.md index 41e01dc71..ef2eb0767 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | up to 7,516 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,396 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/changelog.d/1506-cctv-taiwan-quebec-ottawa-world.md b/changelog.d/1506-cctv-taiwan-quebec-ottawa-world.md index 466c97830..25e0c832d 100644 --- a/changelog.d/1506-cctv-taiwan-quebec-ottawa-world.md +++ b/changelog.d/1506-cctv-taiwan-quebec-ottawa-world.md @@ -1,7 +1,7 @@ - Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120 Québec camera locations, City of Ottawa traffic cameras (needs a free city - access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,396 curated - image-backed cameras from Iceland, Oregon, and Japan (InoukT, #1506). + access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated + image-backed cameras from Oregon and Japan (InoukT, #1506). - Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and loads only after the viewer presses Play; enforce provider-specific frame refresh limits, and report recognized MLIT maintenance images as degraded diff --git a/config/cctv_sources.world-image-cameras.json b/config/cctv_sources.world-image-cameras.json index cb3738ac6..7bddd62a1 100644 --- a/config/cctv_sources.world-image-cameras.json +++ b/config/cctv_sources.world-image-cameras.json @@ -1,2660 +1,4 @@ [ - { - "id": "world-is-7075", - "name": "Almannaskarð - norður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.291877, - "lon": -15.028035, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7076", - "name": "Almannaskarð - suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.282717, - "lon": -15.039817, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7185", - "name": "Almenningar", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.158108, - "lon": -19.0424, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almenningar_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/almenningar_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7038", - "name": "Arnarneshæð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.099812, - "lon": -21.916253, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/arnarneshaed_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/arnarneshaed_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7143", - "name": "Arnarnesvegur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.088166, - "lon": -21.831622, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/arnarnesvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/arnarnesvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7085", - "name": "BB-göng_BO", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.072533, - "lon": -23.321167, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_5.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_5.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7083", - "name": "BB-göng_BR", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.028351, - "lon": -23.343915, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7084", - "name": "BB-göng_TU", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.0555, - "lon": -23.22625, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bb-gong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7107", - "name": "Bakkaselsbrekka", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.476392, - "lon": -18.665278, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bakkaselsbrekka_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bakkaselsbrekka_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7027", - "name": "Biskupsháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.561817, - "lon": -16.021983, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/biskupshals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/biskupshals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7063", - "name": "Blikdalsá", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.26805, - "lon": -21.829398, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/blikdalsa_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/blikdalsa_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7097", - "name": "Blönduós", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.668366, - "lon": -20.234482, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/blonduos_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/blonduos_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7052", - "name": "Bolungarvíkurgöng við Bolungarvík", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.143108, - "lon": -23.222907, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bolungarvikurgong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bolungarvikurgong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7053", - "name": "Bolungarvíkurgöng við Hnífsdal", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.117604, - "lon": -23.121938, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bolungarvikurgong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bolungarvikurgong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7096", - "name": "Brandagil", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.158283, - "lon": -21.056983, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brandagil_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brandagil_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7024", - "name": "Brattabrekka", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.866317, - "lon": -21.517767, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brattabrekka_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brattabrekka_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7016", - "name": "Breiðdalsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.906147, - "lon": -14.598584, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/breiddalsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/breiddalsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7067", - "name": "Brekknaheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.178067, - "lon": -15.2321, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brekknaheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/brekknaheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7051", - "name": "Bræðratunguvegur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.157328, - "lon": -20.362327, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/braedratunguvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/braedratunguvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7109", - "name": "Búlandshöfði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.941614, - "lon": -23.497806, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bulandshofdi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bulandshofdi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7039", - "name": "Bústaðabrú", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.125601, - "lon": -21.899461, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bustadabru_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/bustadabru_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7175", - "name": "Dettifoss", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.81686, - "lon": -16.445947, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dettifoss_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dettifoss_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7166", - "name": "Dýrafjarðargöng norður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.833918, - "lon": -23.228209, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dyrafjardargong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dyrafjardargong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7165", - "name": "Dýrafjarðargöng suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.788544, - "lon": -23.205102, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dyrafjardargong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/dyrafjardargong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7042", - "name": "Eldhraun", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.734772, - "lon": -18.20224, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/eldhraun_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/eldhraun_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7126", - "name": "Engidalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.08368, - "lon": -21.943055, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/engidalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/engidalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7010", - "name": "Ennisháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.572462, - "lon": -21.323936, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/ennishals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/ennishals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7008", - "name": "Fagridalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.120933, - "lon": -14.332117, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fagridalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fagridalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7077", - "name": "Festarfjall", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.857286, - "lon": -22.342569, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/festarfjall_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/festarfjall_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7102", - "name": "Finnafjörður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.0855, - "lon": -15.156867, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/finnafjordur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/finnafjordur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7009", - "name": "Fjarðarheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.266621, - "lon": -14.253634, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fjardarheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fjardarheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7119", - "name": "Fljót", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.089883, - "lon": -19.059717, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fljot_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fljot_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7066", - "name": "Fljótsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.698564, - "lon": -17.499295, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fljotsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fljotsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7032", - "name": "Fossahlíð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.957933, - "lon": -22.78245, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fossahlid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fossahlid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7013", - "name": "Fremri-Háls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.250876, - "lon": -15.816055, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fremri-hals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/fremri-hals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7092", - "name": "Fróðárheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.847631, - "lon": -23.476199, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/frodarheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/frodarheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7081", - "name": "Fáskrúðsfjarðargöng - norður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.011883, - "lon": -14.198683, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/faskrudsfjgong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/faskrudsfjgong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7082", - "name": "Fáskrúðsfjarðargöng - suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.960417, - "lon": -14.172867, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/faskrudsfjgong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/faskrudsfjgong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7026", - "name": "Gauksmýri", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.346047, - "lon": -20.800297, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gauksmyri_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gauksmyri_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7114", - "name": "Gautavík", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.73725, - "lon": -14.345083, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gautavik_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gautavik_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7110", - "name": "Gautsdalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.538256, - "lon": -21.842686, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gautsdalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gautsdalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7019", - "name": "Gemlufallsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.942309, - "lon": -23.432798, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gemlufallsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gemlufallsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7079", - "name": "Gjábakki", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.249752, - "lon": -21.019507, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gjabakki_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gjabakki_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7148", - "name": "Grenivíkurvegur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.898222, - "lon": -18.066028, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/grenivikurvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/grenivikurvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7089", - "name": "Grundartangi", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.3563, - "lon": -21.805967, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/grundartangi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/grundartangi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7149", - "name": "Græfur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.184028, - "lon": -14.340167, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/graefur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/graefur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7158", - "name": "Gullfoss", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.306474, - "lon": -20.207642, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gullfoss_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gullfoss_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7184", - "name": "Gíghæð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.904852, - "lon": -22.418383, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gighaed_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gighaed_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7128", - "name": "Gígjukvísl", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.937763, - "lon": -17.34657, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gigjukvisl_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/gigjukvisl_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7091", - "name": "Haffjarðará", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.841883, - "lon": -22.321233, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/haffjardara_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/haffjardara_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7192", - "name": "Hafnarfjall", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.465117, - "lon": -21.96405, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hafnarfjall_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hafnarfjall_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7195", - "name": "Hafnarnes", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.278083, - "lon": -15.197433, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hafnarnes_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hafnarnes_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7189", - "name": "Hallsteinsnes", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.530875, - "lon": -22.33087, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hallsteinsnes_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hallsteinsnes_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7157", - "name": "Heiðarsporður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.937867, - "lon": -21.062867, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/heidarspordur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/heidarspordur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7177", - "name": "Hellisbrú", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.951933, - "lon": -21.02155, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hellisbru_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hellisbru_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7001", - "name": "Hellisheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.016823, - "lon": -21.343208, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hellisheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hellisheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7105", - "name": "Hestgerði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.192367, - "lon": -15.745, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hestgerdi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hestgerdi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7002", - "name": "Holtavörðuheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.988534, - "lon": -21.055462, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/holtavorduheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/holtavorduheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7188", - "name": "Hrafnanes", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.493841, - "lon": -23.319467, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hrafnanes_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hrafnanes_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7116", - "name": "Hvaldalsá", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.421664, - "lon": -14.533666, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hvaldalsa_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hvaldalsa_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7072", - "name": "Hvalnesskriður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.445883, - "lon": -14.508367, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hvalnesskridur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hvalnesskridur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7950", - "name": "Hveradalir", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.01815, - "lon": -21.3881, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hveradalir_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hveradalir_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7029", - "name": "Hálfdán", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.646498, - "lon": -23.712367, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/halfdan_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/halfdan_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7025", - "name": "Hámundarstaðaháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.953966, - "lon": -18.458446, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hamundarstadahals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hamundarstadahals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7057", - "name": "Héðinsfjarðargöng Siglufjarðarmegin", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.126469, - "lon": -18.891003, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7054", - "name": "Héðinsfjarðargöng Ólafsfjarðarmegin", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.073042, - "lon": -18.672672, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_7.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_7.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7056", - "name": "Héðinsfjarðargöng í Héðinsfirði Siglufjarðarmegin", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.106659, - "lon": -18.820808, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7055", - "name": "Héðinsfjarðargöng í Héðinsfirði Ólafsfjarðarmegin", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.100719, - "lon": -18.808873, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_5.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hedinsfjardargong_5.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7048", - "name": "Hófaskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.299938, - "lon": -15.889139, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hofaskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/hofaskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7197", - "name": "Hólmur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.281267, - "lon": -15.47055, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/holmur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/holmur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7073", - "name": "Jökuldalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.303438, - "lon": -15.219723, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/jokuldalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/jokuldalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7104", - "name": "Jökulsá á Dal", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.431483, - "lon": -14.594317, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/jokulsa_a_dal_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/jokulsa_a_dal_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7101", - "name": "Kaldakinn", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.744166, - "lon": -17.58806, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kaldakinn_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kaldakinn_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7115", - "name": "Kambar", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.012683, - "lon": -21.235, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7121", - "name": "Kambar brekka", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.00995, - "lon": -21.221667, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_brekka_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_brekka_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7144", - "name": "Kambar neðst", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.005433, - "lon": -21.220433, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_nedst_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambar_nedst_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7046", - "name": "Kambaskriður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.800384, - "lon": -13.890564, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambaskridur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kambaskridur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7194", - "name": "Kauptún", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.081424, - "lon": -21.899733, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kauptun_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kauptun_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7133", - "name": "Kjálkafjörður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.59001, - "lon": -22.914407, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kjalkafjordur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kjalkafjordur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7059", - "name": "Kleifaheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.51205, - "lon": -23.6922, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kleifaheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kleifaheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7190", - "name": "Kleifarvatn", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.920643, - "lon": -22.012407, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kleifarvatn_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kleifarvatn_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7033", - "name": "Klettsháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.662483, - "lon": -22.58965, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/klettshals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/klettshals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7094", - "name": "Klettsháls - brekka", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.651417, - "lon": -22.576617, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/klettshals_-_brekka_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/klettshals_-_brekka_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7060", - "name": "Kolás", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.697217, - "lon": -21.632352, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kolas_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kolas_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7178", - "name": "Kotströnd", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.972967, - "lon": -21.140483, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kotstrond_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kotstrond_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7124", - "name": "Kringlan-Kringlumbr", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.132195, - "lon": -21.898917, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kringlan_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kringlan_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7130", - "name": "Kringlan-Miklabraut", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.132818, - "lon": -21.896473, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kringlan_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kringlan_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7023", - "name": "Kvísker", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.969783, - "lon": -16.421567, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kvisker_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/kvisker_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7034", - "name": "Landvegamót", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.879392, - "lon": -20.501199, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/landvegamot_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/landvegamot_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7098", - "name": "Langidalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.52165, - "lon": -19.86015, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/langidalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/langidalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7151", - "name": "Laugará", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.81985, - "lon": -22.497817, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/laugara_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/laugara_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7061", - "name": "Laxárdalsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.204994, - "lon": -21.330345, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/laxardalsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/laxardalsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7108", - "name": "Leirvogstungumelar", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.183531, - "lon": -21.681764, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/leirvogstungumelar_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/leirvogstungumelar_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7122", - "name": "Litla Kaffistofan", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.053222, - "lon": -21.491055, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/litla_kaffistofan_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/litla_kaffistofan_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7090", - "name": "Litli-Kroppur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.638183, - "lon": -21.4492, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/litli_kroppur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/litli_kroppur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7045", - "name": "Lyngdalsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.203447, - "lon": -20.802666, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lyngdalsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lyngdalsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7021", - "name": "Lómagnúpur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.95665, - "lon": -17.499717, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lomagnupur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lomagnupur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7193", - "name": "Lónsbakki", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.702183, - "lon": -18.15035, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lonsbakki_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/lonsbakki_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7028", - "name": "Markarfljót (Bakkafjöruvegamót)", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.620866, - "lon": -20.033304, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/markarfljot_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/markarfljot_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7030", - "name": "Miklidalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.582402, - "lon": -23.851674, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/miklidalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/miklidalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7174", - "name": "Miðdegishæð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.8775, - "lon": -16.545833, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/middegishaed_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/middegishaed_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7113", - "name": "Mjósund", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.240359, - "lon": -14.175723, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mjosund_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mjosund_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7064", - "name": "Mosfellsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.215677, - "lon": -21.341263, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mosfellsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mosfellsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7015", - "name": "Möðrudalsöræfi", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.456818, - "lon": -15.591021, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/modrudalsoraefi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/modrudalsoraefi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7131", - "name": "Múlagöng suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.073167, - "lon": -18.537333, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mulagong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mulagong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7132", - "name": "Múlagöng vestur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.084, - "lon": -18.6065, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mulagong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/mulagong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7145", - "name": "Mýrdalssandur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.480315, - "lon": -18.550563, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myrdalssandur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myrdalssandur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7037", - "name": "Mývatnsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.615823, - "lon": -17.212958, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myvatnsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myvatnsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7069", - "name": "Mývatnsöræfi", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.65755, - "lon": -16.47965, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myvatnsoraefi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/myvatnsoraefi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7095", - "name": "Norðdalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.744617, - "lon": -22.078733, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/norddalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/norddalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7156", - "name": "Norðfjarðargöng-norður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.124482, - "lon": -13.915609, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordfjardargong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordfjardargong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7155", - "name": "Norðfjarðargöng-suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.082813, - "lon": -14.053012, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordfjardargong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordfjardargong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7099", - "name": "Norðurárdalur í Skagafirði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.430683, - "lon": -19.162183, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordurard_skagaf_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nordurard_skagaf_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7040", - "name": "Nónskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.095198, - "lon": -21.888131, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nonskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/nonskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7169", - "name": "Pétursey", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.4701, - "lon": -19.251117, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/petursey_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/petursey_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7146", - "name": "Raufarhafnarvegur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.327167, - "lon": -15.980055, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/raufarhafnarvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/raufarhafnarvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7198", - "name": "Rauðimelur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.033757, - "lon": -22.080267, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/raudimelur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/raudimelur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7183", - "name": "Refasveit", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.718136, - "lon": -20.227673, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/refasveit_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/refasveit_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7118", - "name": "Reykjanesviti", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.817839, - "lon": -22.704767, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/reykjanesviti_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/reykjanesviti_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7044", - "name": "Reynisfjall", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.454317, - "lon": -19.056033, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/reynisfjall_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/reynisfjall_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7074", - "name": "Rósaselstorg", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.0024, - "lon": -22.60195, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/rosaselstorg_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/rosaselstorg_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7080", - "name": "Sandskeið", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.065583, - "lon": -21.58065, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sandskeid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sandskeid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7018", - "name": "Sandvíkurheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.893674, - "lon": -14.825577, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sandvikurheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sandvikurheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7150", - "name": "Sauðárkrókur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.742917, - "lon": -19.62225, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/saudarkrokur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/saudarkrokur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7078", - "name": "Selvogur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.845248, - "lon": -21.691278, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/selvogur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/selvogur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7049", - "name": "Siglufjarðarvegur við Herkonugil", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.179951, - "lon": -18.971278, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/siglufjardarvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/siglufjardarvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7036", - "name": "Skeiðavegamót", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.953267, - "lon": -20.701783, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skeidavegamot_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skeidavegamot_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7120", - "name": "Skinnastaður, Öxarfirði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.061442, - "lon": -16.445403, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skinnastadur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skinnastadur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7196", - "name": "Skógey", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.295274, - "lon": -15.320026, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skogey_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/skogey_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7100", - "name": "Stafá", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.06965, - "lon": -19.298883, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/stafa_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/stafa_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7170", - "name": "Steinar", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.544382, - "lon": -19.694161, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/steinar_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/steinar_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7011", - "name": "Steingrímsfjarðarheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.749429, - "lon": -22.126183, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/steingrimsfjheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/steingrimsfjheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7005", - "name": "Strandarheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.000586, - "lon": -22.226954, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strandarheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strandarheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7160", - "name": "Strákagöng suður", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.1796, - "lon": -18.910667, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strakagong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strakagong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7159", - "name": "Strákagöng vestur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.184917, - "lon": -18.922667, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strakagong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/strakagong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7187", - "name": "Sveifluháls, Vatnsskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.966326, - "lon": -21.940598, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sveifluhals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sveifluhals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7093", - "name": "Sveinatunga", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.84295, - "lon": -21.269167, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sveinatunga_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sveinatunga_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7031", - "name": "Svínadalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.309117, - "lon": -21.741746, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/svinadalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/svinadalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7086", - "name": "Svínavatn", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.1061, - "lon": -20.702183, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/svinavatn_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/svinavatn_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7111", - "name": "Súðavíkurhlíð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.06425, - "lon": -22.986917, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sudavikurhlid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/sudavikurhlid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7191", - "name": "Teygingarlækur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.8813, - "lon": -17.757167, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/teygingarlaekur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/teygingarlaekur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7065", - "name": "Tjörnes", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.129617, - "lon": -16.95305, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/tjornes_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/tjornes_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7182", - "name": "Varmahlíð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.55366, - "lon": -19.445514, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/varmahlid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/varmahlid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7022", - "name": "Vatnaleið", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.907717, - "lon": -22.86325, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnaleid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnaleid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7058", - "name": "Vatnsfjarðarháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.930983, - "lon": -22.535717, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsfjardarhals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsfjardarhals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7014", - "name": "Vatnsskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.509321, - "lon": -19.689764, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7047", - "name": "Vatnsskarð eystra", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.561104, - "lon": -13.994153, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsskard_eystra_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vatnsskard_eystra_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7162", - "name": "Vaðlaheiðargöng - austur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.728371, - "lon": -17.91807, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vadlaheidargong_3.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vadlaheidargong_3.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7161", - "name": "Vaðlaheiðargöng - vestur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.693667, - "lon": -18.053667, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vadlaheidargong_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vadlaheidargong_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7103", - "name": "Vegaskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.44225, - "lon": -15.939483, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vegaskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vegaskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7127", - "name": "Vikurvegur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.132667, - "lon": -21.763667, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7071", - "name": "Vopnafjarðarheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.59614, - "lon": -15.311734, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vopnafjardarheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vopnafjardarheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7070", - "name": "Vopnafjarðarleið", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.658553, - "lon": -15.161115, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vopnafjardarleid_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vopnafjardarleid_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7152", - "name": "Víkurgerði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.897063, - "lon": -13.850671, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurgerdi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurgerdi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7006", - "name": "Víkurskarð", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.812633, - "lon": -18.00925, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurskard_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vikurskard_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7112", - "name": "Víðidalur", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.467183, - "lon": -20.518317, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vididalur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/vididalur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7125", - "name": "Ártúnsbrekka", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.124787, - "lon": -21.849255, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/artunsbrekka_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/artunsbrekka_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7117", - "name": "Ódrjúgsháls", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.555028, - "lon": -22.337805, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/odrjugshals_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/odrjugshals_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7050", - "name": "Ólafsfjarðarvegur við Sauðanes", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 66.043923, - "lon": -18.516821, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/olafsfjardarvegur_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/olafsfjardarvegur_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7003", - "name": "Öxnadalsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.468042, - "lon": -18.693506, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/oxnadalsheidi_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/oxnadalsheidi_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7129", - "name": "Þrengslavegamót", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.038, - "lon": -21.456083, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/threngslavegamot_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/threngslavegamot_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7035", - "name": "Þrengsli", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 63.931028, - "lon": -21.392668, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/threngsli_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/threngsli_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7043", - "name": "Þröskuldar", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.550184, - "lon": -21.833513, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/throskuldar_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/throskuldar_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7020", - "name": "Þverárfjall", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.781272, - "lon": -20.013938, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thverarfjall_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thverarfjall_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7147", - "name": "Þverárfjall á Laxárdalsheiði", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 65.808028, - "lon": -19.847833, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thverarfjall_laxardal_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thverarfjall_laxardal_1.jpg", - "frameRefreshMs": 300000 - }, - { - "id": "world-is-7088", - "name": "Þyrill", - "city": "Iceland", - "cityId": "iceland", - "provider": "Vegagerðin road cameras", - "lat": 64.388697, - "lon": -21.421562, - "feedType": "image", - "sourceKind": "vegagerdin-road", - "license": "Vegagerðin (Icelandic Road and Coastal Administration) public road camera", - "credit": "Icelandic Road and Coastal Administration (Vegagerðin)", - "url": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thyrill_1.jpg", - "snapshotUrl": "https://www.vegagerdin.is/vgdata/vefmyndavelar/thyrill_1.jpg", - "frameRefreshMs": 300000 - }, { "id": "world-jp-river-310497008", "name": "中津隈8号橋", diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8185641b3..3f7e96e44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1574,8 +1574,8 @@ id-hash fallback at low confidence. Once enabled, the 250 cameras nearest the five borough cores load. `CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is -7,516: 4,000 shared round-robin slots, plus room above them for the 1,120 -Québec locations and 2,396 World image sources. Lower explicit +7,350: 4,000 shared round-robin slots, plus room above them for the 1,120 +Québec locations and 2,230 World image sources. Lower explicit `CCTV_MAX_SOURCES` values are honored; reservations only use the capacity above the 4,000 shared slots. @@ -1594,7 +1594,7 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error. By default all ~336 cameras load, nearest downtown first. `CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and `CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default -7,516-source catalog limit with the other packs; Québec and the World image +7,350-source catalog limit with the other packs; Québec and the World image pack have reserved slots and the remaining sources use round-robin allocation. ## Lithuania CCTV source pack @@ -5409,8 +5409,8 @@ silently demoting every later lookup for the session. kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806 GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors - on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,516: 4,000 shared - round-robin slots plus 3,516 reserved Québec and World image slots. + on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared + round-robin slots plus 3,350 reserved Québec and World image slots. Reservations only use capacity above the 4,000 shared slots, so at 4,000 or less every pack, Québec and World included, shares the cap round-robin. ~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once @@ -7095,8 +7095,8 @@ configure automatic restarts. The regular CCTV source loader reads `public/quebec-cameras.json` by default when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680 MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams. -The default catalog limit is 7,516: it reserves all 1,120 Québec locations and -2,396 World image feeds, and keeps 4,000 slots for the existing round-robin +The default catalog limit is 7,350: it reserves all 1,120 Québec locations and +2,230 World image feeds, and keeps 4,000 slots for the existing round-robin camera packs. Reservations only use capacity above those 4,000 slots: a lower explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World included, instead of taking the shared slots. @@ -7143,16 +7143,16 @@ id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack ## World camera image feeds The default `world-cameras` pack reads only the checked-in, curated -`config/cctv_sources.world-image-cameras.json` file. It adds 166 Vegagerðin, -1,176 Oregon TripCheck, and 1,054 MLIT river cameras. Duplicate Canadian feeds -are excluded because DriveBC, Ottawa, and Québec already have app packs. NZTA's +`config/cctv_sources.world-image-cameras.json` file. It adds 1,176 Oregon +TripCheck and 1,054 MLIT river cameras. Duplicate feeds are excluded because +DriveBC, Ottawa, Québec and Iceland (Vegagerðin) already have app packs. NZTA's 249 rows are excluded because they were collected from a Journey Planner web cache whose current terms prohibit automated scraping; an authorized API feed or permission is required before adding them. The wider discovery export is not shipped with the app. The app validates and pins each still-image URL to its provider directory -before the server proxy uses it. Iceland and Oregon frames refresh no faster +before the server proxy uses it. Oregon frames refresh no faster than every five minutes; MLIT river frames refresh every ten minutes to reduce requests. Images are not persisted; the proxy reuses frames in a bounded 32 MB in-memory cache until their provider cadence. Set diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index f4a26953f..5d5ba872f 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}" CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}" CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}" CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}" -CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7516}" +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}" # Capture which provider credentials genuinely came from the parent shell # before this launcher resolves dotenv and Keychain fallbacks. Only names are diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 038994010..79b05573f 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -12,9 +12,9 @@ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; export const CCTV_SHARED_SOURCE_SLOTS = 4000; /** * Catalog-wide default: the 4,000 shared slots plus room reserved for the - * 1,120 Québec and 2,396 World image locations. + * 1,120 Québec and 2,230 World image locations. */ -export const DEFAULT_CCTV_MAX_SOURCES = 7516; +export const DEFAULT_CCTV_MAX_SOURCES = 7350; /** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ export const CCTV_MAX_SOURCES_CEILING = 8000; /** Reference point for Austin camera prioritization (Congress & 6th). */ @@ -227,14 +227,12 @@ export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze( [1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`), ); /** Direct still-image directories from the World cams add-on's eligible packs. */ -export const VEGAGERDIN_IMAGE_ORIGIN = - 'https://www.vegagerdin.is/vgdata/vefmyndavelar/'; export const TRIPCHECK_IMAGE_ORIGIN = 'https://www.tripcheck.com/roadcams/cams/'; export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/'; export const DEFAULT_WORLD_CCTV_SOURCE_FILE = 'config/cctv_sources.world-image-cameras.json'; -export const WORLD_CCTV_SOURCE_COUNT = 2396; +export const WORLD_CCTV_SOURCE_COUNT = 2230; /** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */ export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json'; export const DEFAULT_TALLINN_MAX_SOURCES = 255; diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 667f42f55..7dac9f03a 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -14,7 +14,6 @@ import { TRIPCHECK_IMAGE_ORIGIN, NSW_IMAGE_ORIGIN, NSW_IMAGE_USER_AGENT, - VEGAGERDIN_IMAGE_ORIGIN, OTTAWA_IMAGE_ORIGIN, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, @@ -538,7 +537,6 @@ for (const prefix of [ KING_COUNTY_IMAGE_ORIGIN, WSDOT_IMAGE_ORIGIN, A22_IMAGE_ORIGIN, - VEGAGERDIN_IMAGE_ORIGIN, TRIPCHECK_IMAGE_ORIGIN, MLIT_RIVER_IMAGE_ORIGIN, ...TAIWAN_HIGHWAY_IMAGE_ORIGINS, diff --git a/server/providers/cctv/worldCameras.js b/server/providers/cctv/worldCameras.js index 4614ef9c3..a4c68647d 100644 --- a/server/providers/cctv/worldCameras.js +++ b/server/providers/cctv/worldCameras.js @@ -5,19 +5,11 @@ import { DEFAULT_WORLD_CCTV_SOURCE_FILE, MLIT_RIVER_IMAGE_ORIGIN, TRIPCHECK_IMAGE_ORIGIN, - VEGAGERDIN_IMAGE_ORIGIN, WORLD_CCTV_SOURCE_COUNT, } from './constants.js'; import { pinnedCameraUrl } from './normalize.js'; const SOURCE_POLICIES = Object.freeze({ - 'vegagerdin-road': { - idPrefix: 'world-is-', - origin: VEGAGERDIN_IMAGE_ORIGIN, - bounds: [63, 68, -26, -12], - filename: /^\/vgdata\/vefmyndavelar\/[^/]+\.jpg$/i, - frameRefreshMs: 5 * 60 * 1000, - }, 'tripcheck-odot': { idPrefix: 'world-or-', origin: TRIPCHECK_IMAGE_ORIGIN, @@ -72,7 +64,9 @@ function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) { /** * Load only the new image-backed networks from the add-on export. Existing * Canadian networks, Québec entries, JMA placeholder images, and NZTA's - * scraped Journey Planner rows are deliberately excluded. + * scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland) + * rows are excluded too: the Iceland pack loads the full live list of those + * cameras, so a World copy would draw each one twice. */ export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) { const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE); diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index f4b368379..445322375 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -106,7 +106,7 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack', const reservedFixture = () => [ ...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)), { ...pack('quebec', 1120), reservedCount: 1120 }, - { ...pack('world', 2396), reservedCount: 2396 }, + { ...pack('world', 2230), reservedCount: 2230 }, ]; const keptByName = (packs) => Object.fromEntries(packs.map(({ name, kept }) => [name, kept])); @@ -128,7 +128,7 @@ test('at the default cap reserved packs are whole and shared packs keep the floo const kept = keptByName(packs); assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES); assert.equal(kept.quebec, 1120); - assert.equal(kept.world, 2396); + assert.equal(kept.world, 2230); for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200); assert.match( sources[0].id, diff --git a/src/data/cctvHouston.test.mjs b/src/data/cctvHouston.test.mjs index 82153152e..19d4331e2 100644 --- a/src/data/cctvHouston.test.mjs +++ b/src/data/cctvHouston.test.mjs @@ -7,7 +7,7 @@ import { loadHoustonSourcesFromOpenData, } from '../../server/providers/cctv/sources.js'; import { - DEFAULT_CCTV_MAX_SOURCES, + CCTV_SHARED_SOURCE_SLOTS, DEFAULT_HOUSTON_MAX_SOURCES, HOUSTON_CAMERAS_URL, HOUSTON_GROUND_ELEVATION_M, @@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => { } }); -test('the shipped catalog ceiling is not raised to make room for this pack', () => { - assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); +test('the shared catalog slots are not raised to make room for this pack', () => { + assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000); assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250); }); diff --git a/src/data/cctvWorldQuebec.test.mjs b/src/data/cctvWorldQuebec.test.mjs index 0859329af..605831382 100644 --- a/src/data/cctvWorldQuebec.test.mjs +++ b/src/data/cctvWorldQuebec.test.mjs @@ -58,16 +58,15 @@ function sourceRoot(t, { world = [], quebec = null } = {}) { return root; } -const icelandRow = (overrides = {}) => ({ - id: 'world-is-7075', - name: 'Almannaskarð - norður', - provider: 'Vegagerðin road cameras', - lat: 64.291877, - lon: -15.028035, - sourceKind: 'vegagerdin-road', - url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg', - snapshotUrl: - 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg', +const oregonRow = (overrides = {}) => ({ + id: 'world-or-764', + name: '3rd St at Harwood MP18.35', + provider: 'TripCheck (ODOT)', + lat: 44.3029, + lon: -120.8549, + sourceKind: 'tripcheck-odot', + url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg', + snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg', ...overrides, }); @@ -76,7 +75,8 @@ test('the shipped World catalog loads every curated row', (t) => { const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT }); assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT); const byKind = Object.groupBy(sources, (source) => source.sourceKind); - assert.equal(byKind['vegagerdin-road'].length, 166); + // Iceland comes from the live Iceland pack, never a second World copy. + assert.equal(byKind['vegagerdin-road'], undefined); assert.equal(byKind['tripcheck-odot'].length, 1176); assert.equal(byKind['mlit-river'].length, 1054); for (const source of byKind['mlit-river']) @@ -87,31 +87,41 @@ test('the World loader accepts a pinned row and rejects anything outside its pol quiet(t); const rejected = [ ['unknown kind', { sourceKind: 'nzta-journeys' }], - ['wrong id prefix', { id: 'world-or-7075' }], + [ + 'Iceland duplicates the Iceland pack', + { + sourceKind: 'vegagerdin-road', + lat: 64.291877, + lon: -15.028035, + url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg', + snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg', + }, + ], + ['wrong id prefix', { id: 'world-jp-river-7075' }], ['outside bounds', { lat: 40, lon: -15 }], - ['other host', { url: 'https://evil.test/vgdata/vefmyndavelar/a.jpg' }], - ['other directory', { url: 'https://www.vegagerdin.is/other/a.jpg' }], - ['query string', { url: icelandRow().url + '?x=1' }], - ['not a jpg', { url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a.png' }], - ['plain http', { url: icelandRow().url.replace('https:', 'http:') }], + ['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }], + ['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }], + ['query string', { url: oregonRow().url + '?x=1' }], + ['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }], + ['plain http', { url: oregonRow().url.replace('https:', 'http:') }], [ 'url and snapshot differ', - { snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/b.jpg' }, + { snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' }, ], ]; const root = sourceRoot(t, { world: [ - icelandRow(), - icelandRow(), // duplicate id + oregonRow(), + oregonRow(), // duplicate id ...rejected.map(([, overrides], i) => - icelandRow({ id: `world-is-${9000 + i}`, ...overrides }), + oregonRow({ id: `world-or-${9000 + i}`, ...overrides }), ), ], }); const sources = loadWorldCameraSources({ sourceRoot: root }); assert.deepEqual( sources.map((source) => source.id), - ['world-is-7075'], + ['world-or-764'], ); assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000); assert.equal(sources[0].feedType, 'image'); @@ -238,14 +248,14 @@ test('/sources lists Québec browser media only when the operator opts in', asyn test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => { quiet(t); - const root = sourceRoot(t, { world: [icelandRow()] }); + const root = sourceRoot(t, { world: [oregonRow()] }); let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } }); const app = mount(t, root, (url) => - url === icelandRow().url ? answer() : null, + url === oregonRow().url ? answer() : null, ); - const frame = '/frame/world-is-7075'; + const frame = '/frame/world-or-764'; const upstreamHits = () => - app.requests.filter((url) => url === icelandRow().url).length; + app.requests.filter((url) => url === oregonRow().url).length; const first = await app.call(frame); assert.equal(first.headers['X-CCTV-Source'], 'upstream-image'); @@ -255,17 +265,17 @@ test('a World frame is fetched once per refresh floor and the cooldown never spe // A camera whose first fetch fails is inside its failure backoff: it gets // the placeholder, not Google Street View, and no second upstream request. - const failRoot = sourceRoot(t, { world: [icelandRow()] }); + const failRoot = sourceRoot(t, { world: [oregonRow()] }); answer = () => new Response('down', { status: 503 }); const failing = mount(t, failRoot, (url) => - url === icelandRow().url ? answer() : null, + url === oregonRow().url ? answer() : null, ); const failed = await failing.call(frame); assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image'); const retry = await failing.call(frame); assert.equal(retry.headers['X-CCTV-Source'], 'synthetic'); assert.equal( - failing.requests.filter((url) => url === icelandRow().url).length, + failing.requests.filter((url) => url === oregonRow().url).length, 1, 'the backoff holds the provider cadence', ); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 704db7fb8..8ffbf3aaf 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -299,6 +299,38 @@ export const DATA_CREDITS = [ 'CCTV static data (MOTC 2.0), released under the ' + 'Open Government Data License, Taiwan (Version 1.0)', }, + { + key: 'ottawa-cctv', + html: + 'Traffic cameras (Ottawa, with an access certificate): ' + + 'City of Ottawa Traffic Camera Images API', + }, + { + key: 'quebec-cctv', + html: + 'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' + + 'Données Québec ' + + '(CC BY 4.0); ' + + 'Montréal camera locations © OpenStreetMap contributors (ODbL). ' + + 'Video (when enabled) and linked webcam pages stay with ' + + 'Québec 511 and each operator, under their own terms', + }, + { + key: 'tripcheck-cctv', + html: + 'Road camera stills (Oregon): camera courtesy of ODOT, via ' + + 'TripCheck; ' + + 'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label', + }, + { + key: 'mlit-river-cctv', + html: + 'River camera stills (Japan): 出典:国土交通省 ' + + '川の防災情報 ' + + '(Ministry of Land, Infrastructure, Transport and Tourism; ' + + 'terms), ' + + 'fetched on view, at most every ten minutes per camera', + }, { key: 'calgary-cctv', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 023c79671..79305ad6f 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -153,3 +153,17 @@ test('every Street Level imagery source is in the attribution lightbox', () => { `${key} has no credit`, ); }); + +test('the PR #1506 camera packs are credited with their sources and terms', () => { + const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html; + assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/); + assert.match(html('quebec-cctv') || '', /CC BY 4\.0/); + assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/); + assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/); + // MLIT's terms ask for the source in this form, with the page link. + assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/); + assert.match( + html('mlit-river-cctv') || '', + /href="https:\/\/www\.river\.go\.jp\//, + ); +}); From 72112ecb45e2275401aa8e2bf9fcbab4e2312898 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:04:37 +0100 Subject: [PATCH 089/174] feat(cockpit): ride the selected vessel from its bridge With no aircraft tracked, Cockpit can ride the selected AIS vessel. Entry needs only Live AIS Vessels and a selected vessel; C, double-click and the entry buttons (reading BRIDGE with a ship icon) enter it, while voice stays aircraft-only. The eye sits on the AIS position at a representative bridge height for the vessel's type family, looks along true heading and moves along course over ground. The last fix is dead-reckoned for up to 180 s between the layer's refreshes, and on rivers and lakes the waterline is read from the rendered surface around the ship rather than the geoid. The bridge HUD shows speed and course over ground, a straight heading tape, window mullions, the AIS destination as a reported voyage and a heading-up radar of nearby ships (3 to 24 NM). The arrow keys look around, Home or 0 looks ahead again. The vessel layer hides the ridden ship's sprite and card, ignores scene clicks while held, and returns the camera to the vessel's map view on exit. --- docs/CURRENT-STATE.md | 89 +- index.html | 2 +- scripts/package-boundaries.json | 23 + src/cameraHandoff.test.mjs | 5 +- src/cockpitLook.js | 183 +++ src/cockpitLook.test.mjs | 137 ++ src/cockpitMath.js | 40 + src/cockpitMath.test.mjs | 42 + src/cockpitRadar.js | 166 +++ src/cockpitRadar.test.mjs | 140 +++ src/cockpitSubjects.js | 170 +++ src/cockpitSubjects.test.mjs | 137 ++ src/cockpitTracking.js | 3 +- src/contextModePolicy.js | 12 + src/contextModePolicy.test.mjs | 12 + src/i18n/locales/ar.json | 48 +- src/i18n/locales/de.json | 44 +- src/i18n/locales/es.json | 45 +- src/i18n/locales/fr.json | 45 +- src/i18n/locales/it.json | 45 +- src/i18n/locales/ja.json | 43 +- src/i18n/locales/pt.json | 45 +- src/i18n/locales/ru.json | 46 +- src/i18n/locales/uk.json | 46 +- src/i18n/locales/zh-CN.json | 43 +- src/i18n/locales/zh-Hant.json | 43 +- src/i18n/same-as-english.json | 18 +- src/layers/vessels/cockpitSubject.js | 142 +++ src/layers/vessels/cockpitSubject.test.mjs | 113 ++ src/layers/vessels/queries.js | 102 ++ src/layers/vessels/radarContacts.test.mjs | 118 ++ src/layers/vessels/records.js | 31 + src/layers/vessels/records.test.mjs | 30 + src/layers/vessels/rendering.js | 12 +- src/layers/vessels/selection.js | 3 + src/layers/vessels/state.js | 2 + src/ui/applicationShell.js | 3 + src/ui/cockpitBodyClasses.test.mjs | 2 +- src/ui/cockpitBridge.test.mjs | 1319 ++++++++++++++++++++ src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitCamera.js | 230 +++- src/ui/cockpitController.js | 121 +- src/ui/cockpitCoordinator.js | 52 +- src/ui/cockpitEntryGate.test.mjs | 36 +- src/ui/cockpitInput.js | 122 +- src/ui/cockpitInstrumentState.js | 15 + src/ui/cockpitInstruments.js | 212 ++++ src/ui/cockpitPresentation.js | 8 +- src/ui/cockpitTrackingController.js | 164 ++- src/ui/stores/cockpit.js | 25 +- src/ui/styles/cockpit.css | 454 +++++++ src/ui/styles/cockpitRadar.css | 193 +++ src/ui/views/CockpitView.vue | 86 +- src/ui/views/ContextView.vue | 4 +- src/ui/views/IntelHudView.vue | 4 +- src/ui/views/panels/CockpitRadar.vue | 164 +++ src/ui/views/panels/CockpitRoute.vue | 8 +- style.css | 1 + 58 files changed, 5313 insertions(+), 137 deletions(-) create mode 100644 src/cockpitLook.js create mode 100644 src/cockpitLook.test.mjs create mode 100644 src/cockpitRadar.js create mode 100644 src/cockpitRadar.test.mjs create mode 100644 src/cockpitSubjects.js create mode 100644 src/cockpitSubjects.test.mjs create mode 100644 src/layers/vessels/cockpitSubject.js create mode 100644 src/layers/vessels/cockpitSubject.test.mjs create mode 100644 src/layers/vessels/radarContacts.test.mjs create mode 100644 src/ui/cockpitBridge.test.mjs create mode 100644 src/ui/styles/cockpitRadar.css create mode 100644 src/ui/views/panels/CockpitRadar.vue diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3bf6b1051..f8f69be47 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1524,6 +1524,77 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti and queued panel work stop before asynchronous layer restoration; final camera and portal cleanup follows that restoration. +### Ship bridge view + +With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. +Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a +selected vessel; Contacts and every other layer may be off, before or during +the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode +change still in flight blocks entry until it settles. `C` enters it, and the +entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`) +and under the selected vessel's Intel HUD readout in the bottom-right corner +(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber +layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no +subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone, +so no other feed being off makes it partial. +Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only. +The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's +sprite, card and detection bracket while it is held (`setCockpitSubject`), and +ignores scene clicks so open water cannot deselect it. The eye sits on the AIS +position at a representative bridge height for the AIS type family +(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m, +tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text +type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no +family, including the reserved and wing-in-ground codes the vessel layer also +files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor +(`src/cockpitSubjects.js`). The camera faces true heading (course over ground +when heading is missing) and moves along course over ground. A ship reporting +neither has no direction: the camera keeps the heading it had on entry, the +heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal +names the ship without a heading, no bow or stern mark is drawn, and the eye +stays on the fix rather than moving along a made-up course. The vessel layer +redraws a ship only on its 60-second refresh, so the camera's target is the last +fix dead-reckoned along course over ground for up to 180 s, and the existing +inertial anchor absorbs each new fix. The fix's age is read without comparing +the two clocks (`vesselFixAgeSec`): the time since this client first received +the fix, plus how far it trailed that refresh's newest fix, so a client clock +running fast or slow neither projects the ship nor marks it stale, and a fix +the feed keeps re-serving from its cache goes on ageing into STALE FEED. A +stale vessel, or a projection past that limit, holds the camera on the target. +The vessel layer floats ships on the geoid, which on a river or lake can lie +well below the water (the Seine through Paris is ~27 m up), so the bridge also +reads the rendered surface around its ship: the ship's position and a ring of +eight probes 30 m out, the lowest probe taken as the water when it is 2 m to +4,000 m above sea level. The first read starts from the tiles already loaded, then +the full-detail read replaces it; it repeats every 5 s. Only one full-detail +read runs at a time: while one is still running, a refresh reads the loaded +tiles instead, and the slow read applies whenever it lands unless a newer +one has. A failed read keeps the sea surface. + +The HUD keeps its frame, vision styles, briefing, weather and Display/Radio +utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows +speed over ground in knots, true heading and course over ground in place of +altitude, hides the altitude tape, and shows the crew-entered AIS destination as +a Reported Voyage card without an origin or direction arrow. The arrow keys +look around the bridge in 5° steps (Shift for 45°): Left and Right turn the +view all the way round, Up and Down pitch it up to ±90°, and the camera eases +there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD +button shown while the view is turned, bring it back over the bow at the rest +pitch. The keys stay with a focused text field, slider, tab, radio group, list +or menu. The heading tape is a straight strip that scrolls continuously with +the camera, so the caret reads the view's exact heading, while the readout +above it keeps the ship's true heading; a ring with a centre dot marks the bow +(prograde) and a ring with a cross the stern (retrograde) at their bearings +from the caret. The bridge radar +shows nearby AIS ships heading-up and opens at 3 NM +(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps +3, 6, 12 and 24 NM and the chosen range is kept across bridges. Contact +Previous/Next and Live Signals aircraft handoffs are refused on the bridge and +the Contact card stays hidden. Exit returns the camera to the vessel's map view +through the layer's own focus. Tracking an aircraft or losing the vessel +selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs` +covers entry, readouts, refusal and exit. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -6270,14 +6341,16 @@ apply to `public/` assets or built-preview serving. `src/contextModePolicy.js`). Contacts is not required, and the other aircraft layer, Live AIS Vessels and every other layer may be turned on or off before or during a session. A Context mode change still in flight blocks entry until - it settles. Turning off the layer the view rides ends it. Riding an aircraft - while Contacts is on adopts its layer as the user's own, as a direct selection - does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of - its layers going off, restores the pre-Contacts layers but keeps the ridden - one and the view; Live Signals then drops the rows Contacts built and shows - `CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use - the same gate; voice entry still opens Contacts first, as its layer-targeted - entry navigates through Contacts. + it settles. Turning off the layer the view rides ends it. Riding an aircraft, + or a vessel from its bridge, while Contacts is on adopts its layer as the + user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts + ending, by its own switch or one of its layers going off, restores the + pre-Contacts layers but keeps the ridden one and the view; Live Signals then + drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible + Cockpit actions, the `C` shortcut and voice use the same gate; voice entry + still opens Contacts first, as its layer-targeted entry navigates through + Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`; + see [Ship bridge view](#ship-bridge-view)). - **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. diff --git a/index.html b/index.html index f5e3adc83..39ee7e01d 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e224cd87e..8c175d027 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -39,6 +39,7 @@ "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", + "src/ui/views/panels/CockpitRadar.vue", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -904,6 +905,11 @@ "src/cockpitMath.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -972,6 +978,11 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/data/detectionPolicy.js", @@ -1515,6 +1526,8 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", + "src/geoDistance.js", "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -2383,6 +2396,9 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", @@ -2944,6 +2960,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/drawMarks.js", "src/annotations/drawMode.js", @@ -3729,6 +3746,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", @@ -4145,6 +4163,11 @@ "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/data/detectionPolicy.js", "src/styles/anime.js", diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 398c51c34..a668180fe 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,7 +42,7 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || !entity?.position) return false;', + 'if (!info || (!vessel && !entity?.position)) return false;', 'this.onCameraTakeover?.();', 'this.viewer.camera.cancelFlight();', 'this.viewer.trackedEntity = undefined;', @@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => ); ordered(transaction, [ /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, - 'if (!entryError) entered = Boolean(cockpitView.enter());', + // Aircraft-only: a selected vessel must not stand in for a voice target. + "if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));", ], 'Cockpit entry transaction'); // No direct camera control: every mutation goes through a layer tracker or // the cockpit controller, both of which stamp. diff --git a/src/cockpitLook.js b/src/cockpitLook.js new file mode 100644 index 000000000..8292cde25 --- /dev/null +++ b/src/cockpitLook.js @@ -0,0 +1,183 @@ +/** + * Bridge look-around policy: how the arrow keys turn the bridge camera away + * from the bow and back, and how a turned view is described. Yaw is relative + * to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no + * Cesium and no translated text; the Cockpit words the readout. + */ + +/** One arrow press turns the view this far. */ +export const LOOK_STEP_DEG = 5; + +/** One Shift+arrow press turns the view this far: two reach either beam. */ +export const LOOK_COARSE_STEP_DEG = 45; + +/** The view pitches no further than straight up or straight down. */ +export const LOOK_PITCH_LIMIT_DEG = 90; + +/** + * The eased view's time constant. Three of them (about 180 ms) close 95% of + * a step, so a press glides rather than snaps and a held key turns smoothly. + */ +export const LOOK_EASE_TAU_MS = 60; + +/** Closer than this the eased view lands on its target. */ +const LOOK_SETTLE_DEG = 0.05; + +/** Arrow key → the axis it turns and the direction it turns it. */ +const LOOK_ARROWS = Object.freeze({ + ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }), + ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }), + ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }), + ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }), +}); + +/** + * Wrap a yaw to (-180, 180]: negative to port, positive to starboard. + * @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead. + * @returns {number} + */ +export function wrapLookYaw(deg) { + if (!Number.isFinite(deg)) return 0; + const wrapped = (((deg % 360) + 360) % 360) - 360; + const yaw = wrapped <= -180 ? wrapped + 360 : wrapped; + return yaw === 0 ? 0 : yaw; +} + +/** + * Clamp a view pitch to [-90, 90]. + * @param {number} deg Pitch in degrees; a non-finite pitch reads as level. + * @returns {number} + */ +export function clampLookPitch(deg) { + if (!Number.isFinite(deg)) return 0; + const pitch = Math.min( + LOOK_PITCH_LIMIT_DEG, + Math.max(-LOOK_PITCH_LIMIT_DEG, deg), + ); + return pitch === 0 ? 0 : pitch; +} + +/** + * The step an arrow key asks for, or null for any other key. + * @param {string} key `KeyboardEvent.key`. + * @param {boolean} [coarse] Shift is held: step 45° instead of 5°. + * @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null} + */ +export function lookKeyStep(key, coarse = false) { + const arrow = LOOK_ARROWS[key]; + if (!arrow) return null; + return { + ...arrow, + stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG, + }; +} + +/** + * Move one value to the next multiple of `stepDeg` in the direction `sign`. + * A value off the grid (the bridge's -2° rest pitch) lands on it first, so + * the readout shows round angles. + */ +function nextOnGrid(value, sign, stepDeg) { + const cells = value / stepDeg; + const next = + sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1; + return next * stepDeg; +} + +/** + * Apply one key step to a look target. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped + * to (-180, 180], pitch clamped to [-90, 90]. + */ +export function stepLook(target, step) { + const yawDeg = wrapLookYaw(target?.yawDeg ?? 0); + const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0); + if (!step) return { yawDeg, pitchDeg }; + if (step.axis === 'yaw') + return { + yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)), + pitchDeg, + }; + return { + yawDeg, + pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)), + }; +} + +/** + * Advance the eased view toward its target. Yaw takes the shorter way + * round; with `immediate` (reduced motion) the view jumps to the target. + * @param {{yawDeg: number, pitchDeg: number}} current Eased view now. + * @param {{yawDeg: number, pitchDeg: number}} target Look target. + * @param {number} dtMs Milliseconds since the previous step. + * @param {{immediate?: boolean}} [options] + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function easeLook(current, target, dtMs, { immediate = false } = {}) { + const goalYaw = wrapLookYaw(target?.yawDeg ?? 0); + const goalPitch = clampLookPitch(target?.pitchDeg ?? 0); + if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch }; + const fraction = + Number.isFinite(dtMs) && dtMs > 0 + ? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS) + : 0; + const fromYaw = wrapLookYaw(current.yawDeg); + const yawError = wrapLookYaw(goalYaw - fromYaw); + const pitchError = goalPitch - clampLookPitch(current.pitchDeg); + const yawDeg = + Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalYaw + : wrapLookYaw(fromYaw + yawError * fraction); + const pitchDeg = + Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalPitch + : clampLookPitch(current.pitchDeg + pitchError * fraction); + return { yawDeg, pitchDeg }; +} + +/** + * Whether a look differs from the rest view (ahead at `restPitchDeg`). + * @param {{yawDeg: number, pitchDeg: number}} look + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {boolean} + */ +export function lookIsOffset(look, restPitchDeg) { + return ( + wrapLookYaw(look?.yawDeg ?? 0) !== 0 || + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg) + ); +} + +/** + * The parts of a look readout, for the Cockpit to word. A part is null when + * that axis rests: yaw ahead, or pitch at the rest pitch. + * @param {{yawDeg: number, pitchDeg: number}} look Look target. + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number, + * elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is + * the angle off the bow (0–180) and `angleDeg` the angle off level (0–90), + * both rounded to whole degrees. + */ +export function lookReadoutParts(look, restPitchDeg) { + const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0)); + const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg)); + const pitchOffset = + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg); + let side = null; + if (Math.abs(yaw) >= 180) side = 'astern'; + else if (yaw > 0) side = 'starboard'; + else if (yaw < 0) side = 'port'; + let elevation = null; + if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level'; + return { + side, + bearingDeg: Math.abs(yaw), + elevation, + angleDeg: Math.abs(pitch), + }; +} diff --git a/src/cockpitLook.test.mjs b/src/cockpitLook.test.mjs new file mode 100644 index 000000000..f1f4a239c --- /dev/null +++ b/src/cockpitLook.test.mjs @@ -0,0 +1,137 @@ +// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps, +// yaw wrapping, pitch limits, easing and the readout's parts. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LOOK_COARSE_STEP_DEG, + LOOK_STEP_DEG, + clampLookPitch, + easeLook, + lookIsOffset, + lookKeyStep, + lookReadoutParts, + stepLook, + wrapLookYaw, +} from './cockpitLook.js'; + +const REST = { yawDeg: 0, pitchDeg: -2 }; + +/** Press one key `count` times from `start`. */ +function press(start, key, count = 1, coarse = false) { + let look = start; + for (let index = 0; index < count; index += 1) + look = stepLook(look, lookKeyStep(key, coarse)); + return look; +} + +test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => { + assert.equal(wrapLookYaw(0), 0); + assert.equal(wrapLookYaw(-0), 0); + assert.equal(wrapLookYaw(180), 180); + assert.equal(wrapLookYaw(-180), 180); + assert.equal(wrapLookYaw(185), -175); + assert.equal(wrapLookYaw(-185), 175); + assert.equal(wrapLookYaw(720 + 45), 45); + assert.equal(wrapLookYaw(NaN), 0); + assert.equal(clampLookPitch(120), 90); + assert.equal(clampLookPitch(-120), -90); + assert.equal(clampLookPitch(-0), 0); + assert.equal(clampLookPitch(Infinity), 0); +}); + +test('only the arrows step, 5° plain and 45° with Shift', () => { + assert.deepEqual(lookKeyStep('ArrowLeft'), { + axis: 'yaw', + sign: -1, + stepDeg: LOOK_STEP_DEG, + }); + assert.deepEqual(lookKeyStep('ArrowUp', true), { + axis: 'pitch', + sign: 1, + stepDeg: LOOK_COARSE_STEP_DEG, + }); + assert.equal(lookKeyStep('ArrowDown').sign, -1); + assert.equal(lookKeyStep('ArrowRight').sign, 1); + for (const key of ['Home', '0', 'a', 'Escape', undefined]) + assert.equal(lookKeyStep(key), null); +}); + +test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => { + assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 }); + assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 }); + assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90); + assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90); + // A coarse step from off the 45° grid lands on it. + assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45); + // Past astern the yaw wraps to the other side. + assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180); + assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135); + assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175); +}); + +test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => { + assert.equal(press(REST, 'ArrowUp').pitchDeg, 0); + assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10); + assert.equal(press(REST, 'ArrowDown').pitchDeg, -5); + assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw'); +}); + +test('the eased view closes most of a step in about 180 ms', () => { + const target = { yawDeg: 90, pitchDeg: -2 }; + let view = { ...REST }; + view = easeLook(view, target, 180); + assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`); + for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50); + assert.deepEqual(view, target, 'it lands exactly on the target'); + assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion'); +}); + +test('the eased yaw takes the short way across astern', () => { + const view = easeLook( + { yawDeg: 170, pitchDeg: 0 }, + { yawDeg: -170, pitchDeg: 0 }, + 30, + ); + assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`); +}); + +test('reduced motion jumps straight to the target', () => { + const target = { yawDeg: -45, pitchDeg: 30 }; + assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target); +}); + +test('the readout names the side, the bearing and the elevation', () => { + assert.equal(lookIsOffset(REST, -2), false); + assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true); + assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true); + assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), { + side: 'starboard', + bearingDeg: 45, + elevation: 'up', + angleDeg: 10, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), { + side: 'port', + bearingDeg: 90, + elevation: null, + angleDeg: 2, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), { + side: null, + bearingDeg: 0, + elevation: 'down', + angleDeg: 30, + }); + assert.equal( + lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation, + 'level', + ); + assert.equal( + lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side, + 'astern', + ); +}); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 3186b1f3c..53daa63d1 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -176,6 +176,46 @@ export function compassDivisions(heading) { ); } +/** + * A heading tape that scrolls continuously: the 30-degree divisions within + * `reach` divisions of `heading`, each placed by its offset from the exact + * heading in division slots, so the centre caret reads the heading itself + * rather than the nearest division. `nearest` marks the division closest to + * the caret. + * @param {number} heading Degrees true. + * @param {number} [reach] Divisions each side of the caret. + * @returns {Array<{heading: number, slot: number, nearest: boolean}>} + */ +export function scrollingCompassDivisions(heading, reach = 4) { + if (!Number.isFinite(heading)) return []; + const exact = normalizeHeading(heading); + const centre = Math.round(exact / 30) * 30; + const divisions = []; + for (let step = -reach; step <= reach; step += 1) { + const division = normalizeHeading(centre + step * 30); + divisions.push({ + heading: division, + slot: Number((relativeBearing(division, exact) / 30).toFixed(4)), + nearest: step === 0, + }); + } + return divisions; +} + +/** + * Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape: + * its offset from the caret (the tape's exact heading) in division slots of + * 30 degrees, or null when it lies more than three divisions out. + * @param {number} heading Degrees true. + * @param {number} tapeHeading The heading under the tape's caret. + * @returns {?number} + */ +export function compassMarkerSlot(heading, tapeHeading) { + if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null; + const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4)); + return Math.abs(slot) <= 3 ? slot : null; +} + /** Format a compass division as a cardinal/intercardinal label or degrees. */ export function formatCompassDivision(heading) { const normalized = normalizeHeading(heading); diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 5a7f029c0..a59a4b029 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -11,6 +11,8 @@ import { cockpitSurfaceWaitExpired, cockpitUiUpdateDue, compassDivisions, + compassMarkerSlot, + scrollingCompassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, formatCockpitEmergency, @@ -151,6 +153,46 @@ test('compass divisions remain ordered across north', () => { assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]); }); +test('compass marks sit in division slots off the caret, or off the tape', () => { + // Measured from the exact heading under the caret, not the nearest division. + assert.equal(compassMarkerSlot(350, 350), 0); + assert.equal(compassMarkerSlot(35, 350), 1.5); + assert.equal(compassMarkerSlot(260, 350), -3); + assert.equal(compassMarkerSlot(80, 350), 3); + assert.equal(compassMarkerSlot(90, 350), null); + assert.equal(compassMarkerSlot(180, 350), null); + assert.equal(compassMarkerSlot(Number.NaN, 0), null); +}); + +test('a scrolling tape puts the exact heading under the caret', () => { + // At 100° the divisions sit a third of a slot off their snapped places, + // so the bow mark (also 100°) lines up with the caret, not 10° beside it. + const tape = scrollingCompassDivisions(100); + assert.deepEqual( + tape.map(({ heading }) => heading), + [330, 0, 30, 60, 90, 120, 150, 180, 210], + ); + const nearest = tape.find((division) => division.nearest); + assert.equal(nearest.heading, 90); + assert.equal(nearest.slot, -0.3333); + assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667); + assert.equal(compassMarkerSlot(100, 100), 0); + assert.equal(compassMarkerSlot(90, 100), nearest.slot); + // Across north the slots stay continuous. + assert.deepEqual( + scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [ + heading, + slot, + ]), + [ + [330, -0.8333], + [0, 0.1667], + [30, 1.1667], + ], + ); + assert.deepEqual(scrollingCompassDivisions(Number.NaN), []); +}); + test('compass labels use cardinals where exact and degrees elsewhere', () => { assert.equal(formatCompassDivision(270), 'W'); assert.equal(formatCompassDivision(30), '030'); diff --git a/src/cockpitRadar.js b/src/cockpitRadar.js new file mode 100644 index 000000000..5cf1f90f7 --- /dev/null +++ b/src/cockpitRadar.js @@ -0,0 +1,166 @@ +/** + * Bridge radar policy: the range steps, the range rings and the heading-up + * projection of nearby AIS vessels onto the radar's range circle. Pure: no + * DOM, no Cesium and no translated text; the Cockpit words the labels. + */ +import { + bearingBetweenCoordinates, + normalizeHeading, + relativeBearing, +} from './cockpitMath.js'; +import { greatCircleMeters } from './geoDistance.js'; + +/** Metres in one international nautical mile. */ +export const METRES_PER_NM = 1852; + +/** The radar's range steps in nautical miles, shortest first. */ +export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]); + +/** The range the radar opens on. */ +export const DEFAULT_RADAR_RANGE_NM = 3; + +/** At most this many contacts are drawn, nearest first. */ +export const RADAR_MAX_CONTACTS = 60; + +/** Range rings divide the range into this many equal steps. */ +export const RADAR_RING_COUNT = 4; + +/** + * Normalize a range to one of the radar's steps. + * @param {number} rangeNm Requested range in nautical miles. + * @returns {number} The range when it is a step, else the default range. + */ +export function radarRangeNm(rangeNm) { + return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM; +} + +/** + * The range after this one; the longest range wraps to the shortest. + * @param {number} rangeNm Current range in nautical miles. + * @returns {number} The next range step. + */ +export function nextRadarRange(rangeNm) { + const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm)); + return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length]; +} + +/** + * Range ring distances at each quarter of the range, innermost first. + * @param {number} rangeNm Range in nautical miles. + * @returns {number[]} Ring distances in nautical miles; the last is the range. + */ +export function radarRingsNm(rangeNm) { + const range = radarRangeNm(rangeNm); + return Array.from( + { length: RADAR_RING_COUNT }, + (_, index) => (range * (index + 1)) / RADAR_RING_COUNT, + ); +} + +/** + * The search radius for a range, in metres. + * @param {number} rangeNm Range in nautical miles. + * @returns {number} + */ +export function radarRangeM(rangeNm) { + return radarRangeNm(rangeNm) * METRES_PER_NM; +} + +const round4 = (value) => Math.round(value * 1e4) / 1e4; + +/** + * Project vessel contacts onto a heading-up radar. + * + * Positions are fractions of the range circle: x to the right (starboard), + * y up (ahead), both in [-1, 1]. Contacts outside the range, or without a + * position, are left out; the rest come nearest first, at most `maxCount`. + * @param {{latitude: number, longitude: number, headingDeg: number}} own + * Own ship position and the heading the radar is drawn up from. + * @param {Array<{id: string, name?: string, latitude: number, + * longitude: number, course?: number|null, color?: string, + * stale?: boolean}>} contacts Nearby vessels. + * @param {{rangeNm?: number, maxCount?: number}} [options] + * @returns {Array<{id: string, name: string, x: number, y: number, + * distanceNm: number, relativeBearingDeg: number, + * relativeCourseDeg: number|null, color: string, stale: boolean}>} + * relativeBearingDeg and relativeCourseDeg are clockwise from the bow in + * [-180, 180). + */ +export function projectRadarContacts(own, contacts, options = {}) { + const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM); + const cap = + Number.isFinite(options.maxCount) && options.maxCount > 0 + ? Math.floor(options.maxCount) + : RADAR_MAX_CONTACTS; + const lat = Number(own?.latitude); + const lon = Number(own?.longitude); + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + !Array.isArray(contacts) + ) + return []; + const heading = normalizeHeading(own.headingDeg ?? 0); + const projected = []; + for (const contact of contacts) { + const toLat = Number(contact?.latitude); + const toLon = Number(contact?.longitude); + const distanceNm = + greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) / + METRES_PER_NM; + if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue; + const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon); + const relativeBearingDeg = + bearing === null ? 0 : relativeBearing(bearing, heading); + const radians = (relativeBearingDeg * Math.PI) / 180; + const fraction = distanceNm / rangeNm; + const course = + contact.course === null || contact.course === undefined + ? NaN + : Number(contact.course); + projected.push({ + id: String(contact.id), + name: String(contact.name || contact.id), + x: round4(Math.sin(radians) * fraction), + y: round4(Math.cos(radians) * fraction), + distanceNm, + relativeBearingDeg, + relativeCourseDeg: Number.isFinite(course) + ? relativeBearing(course, heading) + : null, + color: contact.color || '', + stale: contact.stale === true, + }); + } + projected.sort((a, b) => a.distanceNm - b.distanceNm); + return projected.slice(0, cap); +} + +/** + * The radar presentation without its worded labels. + * @param {{latitude: number, longitude: number, headingDeg: number, + * courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground. + * @param {Array} contacts See {@link projectRadarContacts}. + * @param {number} rangeNm Range in nautical miles. + * @returns {{rangeNm: number, ringsNm: number[], headingDeg: number, + * ownRelativeCourseDeg: number|null, contacts: Array}} + */ +export function radarPicture(own, contacts, rangeNm) { + const range = radarRangeNm(rangeNm); + const headingDeg = normalizeHeading(own?.headingDeg ?? 0); + const course = + own?.courseDeg === null || own?.courseDeg === undefined + ? NaN + : Number(own.courseDeg); + return { + rangeNm: range, + ringsNm: radarRingsNm(range), + headingDeg, + ownRelativeCourseDeg: Number.isFinite(course) + ? relativeBearing(course, headingDeg) + : null, + contacts: projectRadarContacts({ ...own, headingDeg }, contacts, { + rangeNm: range, + }), + }; +} diff --git a/src/cockpitRadar.test.mjs b/src/cockpitRadar.test.mjs new file mode 100644 index 000000000..e18403972 --- /dev/null +++ b/src/cockpitRadar.test.mjs @@ -0,0 +1,140 @@ +// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and +// heading-up contact projection. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_RADAR_RANGE_NM, + METRES_PER_NM, + RADAR_MAX_CONTACTS, + RADAR_RANGES_NM, + nextRadarRange, + projectRadarContacts, + radarPicture, + radarRangeM, + radarRingsNm, +} from './cockpitRadar.js'; + +const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 }); +/** One nautical mile of latitude, in degrees. */ +const NM_LAT = 1 / 60; +/** One nautical mile of longitude at 54.5°N, in degrees. */ +const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180)); + +function contact(id, northNm, eastNm, extra = {}) { + return { + id, + name: id, + latitude: OWN.latitude + northNm * NM_LAT, + longitude: OWN.longitude + eastNm * NM_LON, + course: null, + color: '#fff', + stale: false, + ...extra, + }; +} + +test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => { + assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]); + assert.equal(DEFAULT_RADAR_RANGE_NM, 3); + assert.equal(nextRadarRange(3), 6); + assert.equal(nextRadarRange(6), 12); + assert.equal(nextRadarRange(12), 24); + assert.equal(nextRadarRange(24), 3); + assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default'); + assert.equal(radarRangeM(6), 6 * METRES_PER_NM); +}); + +test('range rings sit at each quarter of the range', () => { + assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]); + assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]); +}); + +test('a contact dead ahead is straight up the scope', () => { + const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], { + rangeNm: 6, + }); + assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`); + assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`); + assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02); + assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5); +}); + +test('heading-up turns the picture with the bow', () => { + // Heading east: a contact to the north is on the port beam, one to the + // south on the starboard beam, one to the east dead ahead. + const own = { ...OWN, headingDeg: 90 }; + const byId = Object.fromEntries( + projectRadarContacts(own, [ + contact('N', 2, 0), + contact('S', -2, 0), + contact('E', 0, 2), + ]).map((entry) => [entry.id, entry]), + ); + assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port'); + assert.ok( + byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02, + 'south is to starboard', + ); + assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead'); + assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1); + assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1); +}); + +test('a contact to starboard is right of the centre', () => { + const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]); + assert.ok(starboard.x > 0.2, `x ${starboard.x}`); + assert.ok(Math.abs(starboard.y) < 0.01); +}); + +test('course is drawn relative to the bow and stays null when unknown', () => { + const own = { ...OWN, headingDeg: 90 }; + const [crossing, drifting] = projectRadarContacts(own, [ + contact('A', 1, 0, { course: 180 }), + contact('B', 2, 0, { course: null }), + ]); + assert.equal(crossing.relativeCourseDeg, 90); + assert.equal(drifting.relativeCourseDeg, null); +}); + +test('contacts beyond the range are left out', () => { + const contacts = projectRadarContacts( + OWN, + [contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)], + { rangeNm: 6 }, + ); + assert.deepEqual( + contacts.map((entry) => entry.id), + ['in'], + ); + assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1)); +}); + +test('contacts come nearest first, capped', () => { + const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) => + contact(`V${index}`, 0, 5.5 - index * 0.07), + ); + const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 }); + assert.equal(contacts.length, RADAR_MAX_CONTACTS); + for (let index = 1; index < contacts.length; index += 1) + assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm); + assert.equal( + projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length, + 3, + 'a smaller cap is honoured', + ); +}); + +test('a picture carries the range, rings, heading and own course over ground', () => { + const picture = radarPicture( + { ...OWN, headingDeg: 350, courseDeg: 10 }, + [contact('A', 1, 0)], + 12, + ); + assert.equal(picture.rangeNm, 12); + assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]); + assert.equal(picture.headingDeg, 350); + assert.equal(picture.ownRelativeCourseDeg, 20); + assert.equal(picture.contacts.length, 1); + assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°'); + assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null); +}); diff --git a/src/cockpitSubjects.js b/src/cockpitSubjects.js new file mode 100644 index 000000000..aab08ff72 --- /dev/null +++ b/src/cockpitSubjects.js @@ -0,0 +1,170 @@ +/** + * Camera policy for first-person subjects other than aircraft: where a + * vessel's bridge eye sits and how its reported fix advances between feed + * refreshes. No DOM or i18n dependencies, so the policy is unit-testable. + */ +import { projectGroundArcLatLon } from './data/motionModel.js'; + +/** Bridge cameras look along the horizon; a ship's eye is already high. */ +export const VESSEL_BRIDGE_PITCH_DEG = -2; + +/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */ +export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0; + +/** Clearance over the shared ground floor for a camera that floats on water. */ +export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2; + +/** Eye height used when the vessel layer knows no height for the type. */ +export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12; + +/** + * A reported fix older than this stops advancing: the vessel holds where the + * last projection left it instead of sailing on through a feed outage. + */ +export const VESSEL_DEAD_RECKON_MAX_S = 180; + +/** + * The vessel layer floats every ship on the geoid (mean sea level), but a + * river or lake can sit well above it: the Seine through Paris is ~27 m up, + * which put a bridge eye under the water. The bridge reads the rendered + * surface around its ship instead: probes on a ring this far out, plus one + * at the ship itself. + */ +export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30; + +/** Probes on the ring around the ship. */ +export const VESSEL_WATERLINE_PROBE_COUNT = 8; + +/** How often the bridge reads the water under its ship again, ms. */ +export const VESSEL_WATERLINE_REFRESH_MS = 5000; + +/** A surface less than this above sea level is the sea itself, metres. */ +export const VESSEL_WATERLINE_MIN_RISE_M = 2; + +/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */ +export const VESSEL_WATERLINE_MAX_RISE_M = 4000; + +/** + * Where to read the surface around a ship: the ship's own position, then a + * ring of probes around it. + * @param {number} latitude + * @param {number} longitude + * @returns {Array<{lat: number, lon: number}>} + */ +export function waterlineProbePoints( + latitude, + longitude, + radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M, + count = VESSEL_WATERLINE_PROBE_COUNT, +) { + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return []; + const points = [{ lat: latitude, lon: longitude }]; + const metresPerDegLat = 111_320; + const metresPerDegLon = + metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180)); + for (let i = 0; i < count; i += 1) { + const bearing = (2 * Math.PI * i) / count; + points.push({ + lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat, + lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon, + }); + } + return points; +} + +/** + * The water surface a bridge floats on, ellipsoidal metres. Quays, bridges, + * moored boats and the ship's own model all stand above the water, so the + * lowest probe is the water. A lowest probe barely above sea level is the + * sea; one implausibly high is a bad sample. Either way the geoid stands. + * @param {number} seaSurfaceM The geoid sea surface the layer floats ships on. + * @param {Array} sampleHeightsM Rendered-surface probes. + * @returns {number} + */ +export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) { + const heights = (sampleHeightsM || []).filter(Number.isFinite); + if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM; + const lowest = Math.min(...heights); + const rise = lowest - seaSurfaceM; + return rise >= VESSEL_WATERLINE_MIN_RISE_M && + rise <= VESSEL_WATERLINE_MAX_RISE_M + ? lowest + : seaSurfaceM; +} + +/** + * Camera placement for a vessel subject, in the shape the Cockpit camera + * reads for every subject kind. + * @param {{eyeHeightM?: number|null}} info Vessel subject info. + * @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}} + */ +export function vesselCameraProfile(info) { + return { + forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M, + upM: Number.isFinite(info?.eyeHeightM) + ? info.eyeHeightM + : VESSEL_DEFAULT_EYE_HEIGHT_M, + pitchDeg: VESSEL_BRIDGE_PITCH_DEG, + minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M, + }; +} + +/** + * How old a vessel's fix is now, seconds, or null without a fix time. + * + * The fix time is on the AIS feed's clock and `nowMs` on this client's, so + * comparing them directly would turn a client clock running minutes fast + * into minutes of age (every bridge projecting its full limit and reading + * STALE FEED). With the time this client first received the fix (client + * clock) and the newest fix in that refresh (feed clock), the age is the + * time since the fix arrived plus how far it trailed that newest fix; each + * part is read on one clock. A fix the feed keeps re-serving keeps its first + * receipt, so it ages into STALE FEED. The age leaves out the newest fix's + * own delay before the refresh, typically seconds: a feed that was already + * stalled when the bridge first saw it ages from then. Without them it falls + * back to the clock difference, never negative. + * @param {{fixEpochMs: number|null, receivedAtMs?: number|null, + * feedEpochMs?: number|null}} fix + * @param {number} nowMs Wall-clock time, ms. + * @returns {number|null} + */ +export function vesselFixAgeSec(fix, nowMs) { + const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {}; + if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null; + if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs)) + return ( + Math.max(0, (nowMs - receivedAtMs) / 1000) + + Math.max(0, (feedEpochMs - fixEpochMs) / 1000) + ); + return Math.max(0, (nowMs - fixEpochMs) / 1000); +} + +/** + * Advance a vessel's last reported fix along its course over ground. + * + * The vessel layer redraws a ship only when the feed refreshes, so its icon + * stands still between fixes. A camera converging on that still point would + * sail backwards against its own motion. Projecting the fix forward gives the + * Cockpit anchor a moving target, and the bounded age keeps a silent feed from + * carrying the ship away indefinitely. + * @param {{latitude: number, longitude: number, courseDeg: number|null, + * speedMps: number|null, fixEpochMs: number|null, + * receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last + * report; its age is `vesselFixAgeSec`. + * @param {number} nowMs Wall-clock time, ms. + * @returns {{latitude: number, longitude: number, ageSec: number|null}} + */ +export function deadReckonVesselFix(fix, nowMs) { + const { latitude, longitude, courseDeg, speedMps } = fix || {}; + const ageSec = vesselFixAgeSec(fix, nowMs); + if (ageSec === null) return { latitude, longitude, ageSec: null }; + const { lat, lon } = projectGroundArcLatLon( + latitude, + longitude, + courseDeg, + speedMps, + 0, + Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S), + ); + return { latitude: lat, longitude: lon, ageSec }; +} diff --git a/src/cockpitSubjects.test.mjs b/src/cockpitSubjects.test.mjs new file mode 100644 index 000000000..cf1ff1b58 --- /dev/null +++ b/src/cockpitSubjects.test.mjs @@ -0,0 +1,137 @@ +// src/cockpitSubjects.test.mjs — the vessel camera profile and dead +// reckoning of AIS fixes between feed refreshes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + VESSEL_BRIDGE_MIN_CLEARANCE_M, + VESSEL_BRIDGE_PITCH_DEG, + VESSEL_DEAD_RECKON_MAX_S, + VESSEL_DEFAULT_EYE_HEIGHT_M, + VESSEL_WATERLINE_PROBE_RADIUS_M, + deadReckonVesselFix, + vesselFixAgeSec, + vesselCameraProfile, + vesselWaterlineM, + waterlineProbePoints, +} from './cockpitSubjects.js'; + +const EARTH_M_PER_DEG_LAT = 111_195; + +test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => { + assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), { + forwardM: 0, + upM: 30, + pitchDeg: VESSEL_BRIDGE_PITCH_DEG, + minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M, + }); +}); + +test('a vessel with no known eye height uses the default', () => { + for (const info of [{}, { eyeHeightM: null }, null]) { + assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M); + } +}); + +test('a fix advances along course over ground for its age', () => { + const fixEpochMs = 1_000_000; + const advanced = deadReckonVesselFix( + { + latitude: 0, + longitude: 0, + courseDeg: 0, + speedMps: 10, + fixEpochMs, + }, + fixEpochMs + 60_000, + ); + assert.equal(advanced.ageSec, 60); + // Sixty seconds north at 10 m/s. + assert.ok( + Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2, + `expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`, + ); + assert.ok(Math.abs(advanced.longitude) < 1e-9); +}); + +test('projection stops at the dead-reckoning limit', () => { + const fixEpochMs = 0; + const at = (ageSec) => + deadReckonVesselFix( + { latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs }, + fixEpochMs + ageSec * 1000, + ); + const limit = at(VESSEL_DEAD_RECKON_MAX_S); + const later = at(VESSEL_DEAD_RECKON_MAX_S * 4); + assert.equal(later.longitude, limit.longitude); + assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4); +}); + +test('a fix without time, course or speed stays where it was reported', () => { + const fix = { + latitude: 54.3, + longitude: 10.1, + courseDeg: null, + speedMps: 6, + fixEpochMs: 0, + }; + assert.deepEqual(deadReckonVesselFix(fix, 30_000), { + latitude: 54.3, + longitude: 10.1, + ageSec: 30, + }); + assert.deepEqual( + deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000), + { latitude: 54.3, longitude: 10.1, ageSec: null }, + ); +}); + +test('waterline probes ring the ship at the probe radius', () => { + const points = waterlineProbePoints(48.859, 2.293); + assert.equal(points.length, 9); + assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 }); + for (const point of points.slice(1)) { + const northM = (point.lat - 48.859) * 111_320; + const eastM = + (point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180); + assert.ok( + Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) < + 0.01, + ); + } + assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []); +}); + +test('the waterline is the lowest probe when it stands clear of the sea', () => { + // The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m. + assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2); + // Swell and tile noise are the sea itself. + assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6); + // Under the sea surface (a dock, a bad tile) never sinks the ship. + assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6); + // Nothing sampled, or implausibly high. + assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6); + assert.equal(vesselWaterlineM(44.6, [9000]), 44.6); +}); + +test("a fix's age is read on each clock, so a fast client clock adds none", () => { + const fixEpochMs = 1_800_000_000_000; + // The refresh's newest fix is 10 s newer than this one; the client, its + // clock five minutes fast, received the refresh 5 s ago. + const receivedAtMs = fixEpochMs + 12_000 + 300_000; + const fix = { + latitude: 0, + longitude: 0, + courseDeg: 90, + speedMps: 5, + fixEpochMs, + receivedAtMs, + feedEpochMs: fixEpochMs + 10_000, + }; + assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15); + assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15); + // Without receive data the clocks are compared, never below zero. + const bare = { fixEpochMs }; + assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60); + assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0); + assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null); +}); diff --git a/src/cockpitTracking.js b/src/cockpitTracking.js index 3671e3a36..97aa50d8e 100644 --- a/src/cockpitTracking.js +++ b/src/cockpitTracking.js @@ -65,7 +65,8 @@ export function enterCockpitWithTracking({ ); } } - if (!entryError) entered = Boolean(cockpitView.enter()); + // This transaction selects aircraft; a selected vessel must not stand in. + if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' })); } catch (error) { entryThrew = true; entryError = error instanceof Error ? error : new Error(String(error)); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index fd3324760..4c477127a 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -445,6 +445,18 @@ export function cockpitEntryAllowed({ ); } +/** + * Whether the selected vessel's bridge view can be entered. Like aircraft + * Cockpit it needs only its own layer, Live AIS Vessels, with a vessel + * selected. A Context mode change in flight still owns the camera, so entry + * waits it out. + * @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state + * @returns {boolean} + */ +export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) { + return Boolean(vesselsEnabled) && !contextModeChanging; +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/contextModePolicy.test.mjs b/src/contextModePolicy.test.mjs index 0453bd9b8..e9828834f 100644 --- a/src/contextModePolicy.test.mjs +++ b/src/contextModePolicy.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + bridgeEntryAllowed, cockpitEntryAllowed, contextAllowedLayerIds, contextLayerEnableBlockReason, @@ -426,6 +427,17 @@ test("cockpit entry needs only the tracked aircraft's own layer", () => { subjectLayerEnabled: false, contextModeChanging: false, }), false, 'there is no aircraft to ride'); + assert.equal(cockpitEntryAllowed({ + subjectLayerId: 'ais-live-vessels', + subjectLayerEnabled: true, + contextModeChanging: false, + }), false, "a vessel is the bridge gate's subject, not Cockpit's"); +}); + +test('bridge entry needs only Live AIS Vessels', () => { + assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true); + assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false); + assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false); }); test('context teardown guard restores coordination state after success and failure', async () => { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 189a025d5..362e8ff7e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -149,11 +149,26 @@ "Voice model: {pendingId} — click to switch to {target}; applies next session": "النموذج الصوتي: {pendingId} — انقر للتبديل إلى {target}؛ يسري في الجلسة التالية", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "التكلفة التقديرية للجلسة على {modelId} — عدد الاستجابات: {responses}. تنبيه عند {warn}، وتنتهي الجلسة عند {cap}.", "Aircraft cockpit view": "عرض قمرة قيادة الطائرة", + "Ship bridge view": "عرض غرفة قيادة السفينة", "EXIT COCKPIT": "الخروج من القمرة", + "EXIT BRIDGE": "الخروج من غرفة القيادة", "GROUND SPEED": "السرعة الأرضية", + "SPEED OVER GROUND": "السرعة فوق القاع", + "COURSE OVER GROUND": "المسار فوق القاع", "ALTITUDE": "الارتفاع", "CONTACT": "الهدف", "CONTACTS · 250 KM": "الأهداف · 250 كم", + "RINGS {spacing} NM": "الحلقات كل {spacing} ميل بحري", + "{count} CONTACTS": { + "zero": "لا أهداف ({count})", + "one": "هدف واحد ({count})", + "two": "هدفان ({count})", + "few": "{count} أهداف", + "many": "{count} هدفًا", + "other": "{count} هدف" + }, + "Radar range {range}. Select for the next range.": "مدى الرادار {range}. اختر للانتقال إلى المدى التالي.", + "Bridge radar": "رادار غرفة القيادة", "LIVE SIGNALS": "إشارات مباشرة", "OBSERVED / MAPPED PINGS": "إشارات مرصودة / مرسومة", "CURRENT": "الحالي", @@ -226,16 +241,32 @@ "Landmark: --": "المَعلم: --", "Search any location": "البحث عن أي موقع", "Search any location...": "ابحث عن أي موقع…", + "LOOK STBD {bearing}°": "النظر إلى الميمنة {bearing}°", + "LOOK PORT {bearing}°": "النظر إلى الميسرة {bearing}°", + "LOOK ASTERN": "النظر إلى المؤخرة", + "UP {angle}°": "أعلى {angle}°", + "DOWN {angle}°": "أسفل {angle}°", "LEVEL": "أفقي", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "النظر {elevation}", "Estimated destination direction": "اتجاه الوجهة التقديري", "DEST ---°": "الوجهة ---°", + "BRIDGE WINDOW · FWD": "نافذة غرفة القيادة · الأمام", "OPTICAL PLANE · 01": "المستوى البصري · 01", + "HEADING HOLD · AIS": "تثبيت الاتجاه · AIS", + "NO HEADING · AIS": "لا اتجاه · AIS", "VISOR LOCK · ACTIVE": "قفل واقي الخوذة · نشط", "FIRST PERSON": "منظور الشخص الأول", + "BRIDGE VIEW": "عرض غرفة القيادة", "LIVE TRACK · COURSE ALIGNED": "تتبّع مباشر · بمحاذاة المسار", "Cockpit vision style": "نمط رؤية القمرة", "Current aircraft heading": "اتجاه الطائرة الحالي", + "Look ahead: turn the bridge view back to the bow": "النظر إلى الأمام: إعادة عرض غرفة القيادة نحو مقدمة السفينة", + "Look ahead (Home or 0)": "النظر إلى الأمام (Home أو 0)", + "LOOK AHEAD": "النظر إلى الأمام", + "Current vessel heading": "اتجاه السفينة الحالي", "GROUND SPEED · KTS": "السرعة الأرضية · عقدة", + "SPEED OVER GROUND · KTS": "السرعة فوق القاع · عقدة", "ALTITUDE · FT": "الارتفاع · قدم", "PREV": "السابق", "Previous cockpit vision style": "نمط رؤية القمرة السابق", @@ -247,9 +278,16 @@ "Exit cockpit and return to full globe view": "الخروج من القمرة والعودة إلى عرض الكرة الأرضية كاملة", "RESET": "إعادة ضبط", "Exit cockpit view": "الخروج من عرض القمرة", + "Exit bridge view": "الخروج من عرض غرفة القيادة", "Estimated flight plan": "خطة الرحلة التقديرية", + "Reported voyage": "الرحلة المُبلَّغ عنها", "ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية", + "REPORTED VOYAGE": "الرحلة المُبلَّغ عنها", "ROUTE DATA UNAVAILABLE": "بيانات المسار غير متاحة", + "CONTACT NAVIGATION OFF": "التنقل بين الأهداف متوقف", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "تبقى غرفة القيادة مع السفينة المحددة", + "{range} NM": "{range} ميل بحري", + "AIS DESTINATION · CREW REPORTED": "وجهة AIS · أبلغ عنها الطاقم", "FROM": "من", "TO": "إلى", "UNKNOWN": "غير معروف", @@ -292,6 +330,7 @@ "SELECT CONTEXT": "اختر السياق", "Contact Context actions": "إجراءات سياق الهدف", "COCKPIT": "القمرة", + "BRIDGE": "غرفة القيادة", "SEARCH NEARBY SITES": "البحث عن المواقع القريبة", "Reclassify tracked contact as TR-3B": "إعادة تصنيف الهدف المتتبَّع إلى TR-3B", "Reclassify as TR-3B": "إعادة التصنيف إلى TR-3B", @@ -331,6 +370,8 @@ "Current style: {style} — click for next": "النمط الحالي: {style} — انقر للتالي", "TRACK ACQUIRED": "تم التقاط الهدف", "{label} · COURSE {heading}°": "{label} · المسار {heading}°", + "{label} · HEADING {heading}°": "{label} · الاتجاه {heading}°", + "{label} · NO HEADING": "{label} · لا اتجاه", "Select flight {title}": "اختيار الرحلة {title}", "CONTEXT STANDBY": "السياق في وضع الاستعداد", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "فعّل السياق العالمي لإشارات العناصر القريبة", @@ -358,6 +399,8 @@ "STALE FEED": "مصدر متقادم", "LIVE TRACK": "تتبّع مباشر", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · بمحاذاة المسار", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ارتفاع العين {height}", + "LIVE AIS": "AIS مباشر", "ARROW · ESTIMATED DIRECTION": "سهم · اتجاه تقديري", "DEST {bearing}": "الوجهة {bearing}", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "فُقد الهدف · آخر قراءة معروفة · لا يعني أن الوضع آمن", @@ -3110,6 +3153,8 @@ "LOCAL": "محلي", "ESC EXIT": "ESC خروج", "C TOGGLE": "C تبديل", + "ARROWS LOOK": "الأسهم للنظر", + "HOME RESET": "Home إعادة الضبط", "Active weather products": "منتجات الطقس النشطة", "RECENT IMAGERY": "الصور الحديثة", "Imagery days in the box": "أيام الصور في المنطقة", @@ -3484,5 +3529,6 @@ "Close camera preview": "إغلاق معاينة الكاميرا", "Refresh map still": "تحديث صورة الخريطة الثابتة", "The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", - "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك." + "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.", + "COURSE HOLD · AIS": "تثبيت المسار · AIS" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 419afc9a6..902169ca1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -431,8 +431,14 @@ "LIVE TRACK": "LIVE-TRACK", "COMMERCIAL": "ZIVIL", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · IN KURSRICHTUNG", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · AUGESHÖHE {height}", + "LIVE AIS": "LIVE-AIS", "ARROW · ESTIMATED DIRECTION": "PFEIL · GESCHÄTZTE RICHTUNG", "ROUTE DATA UNAVAILABLE": "ROUTENDATEN NICHT VERFÜGBAR", + "CONTACT NAVIGATION OFF": "KONTAKTNAVIGATION AUS", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "DIE BRÜCKE BLEIBT BEIM GEWÄHLTEN SCHIFF", + "{range} NM": "{range} SM", + "AIS DESTINATION · CREW REPORTED": "AIS-ZIEL · VON DER BESATZUNG GEMELDET", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Cockpit-Wettereffekte deaktivieren", "Enable cockpit weather effects": "Cockpit-Wettereffekte aktivieren", @@ -477,6 +483,8 @@ "Reclassify as TR-3B": "Als TR-3B umklassifizieren", "TRACK ACQUIRED": "TRACK ERFASST", "{label} · COURSE {heading}°": "{label} · KURS {heading}°", + "{label} · HEADING {heading}°": "{label} · STEUERKURS {heading}°", + "{label} · NO HEADING": "{label} · KEIN STEUERKURS", "Context could not restore every layer; try again": "Kontext konnte nicht alle Ebenen wiederherstellen; erneut versuchen", "No selected data layers": "Keine Datenebenen ausgewählt", "{count} data layers could not be cleared": { @@ -663,14 +671,27 @@ "Session ended — cost cap {cost}": "Sitzung beendet: Kostenlimit {cost}", "Radio did not start": "Radio wurde nicht gestartet", "Aircraft cockpit view": "Flugzeug-Cockpitansicht", + "Ship bridge view": "Ansicht von der Schiffsbrücke", + "LOOK STBD {bearing}°": "BLICK STEUERBORD {bearing}°", + "LOOK PORT {bearing}°": "BLICK BACKBORD {bearing}°", + "LOOK ASTERN": "BLICK ACHTERAUS", + "UP {angle}°": "HOCH {angle}°", + "DOWN {angle}°": "TIEF {angle}°", "LEVEL": "HORIZONT", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "BLICK {elevation}", "Estimated destination direction": "Geschätzte Richtung zum Ziel", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "BRÜCKENFENSTER · VORAUS", "OPTICAL PLANE · 01": "OPTISCHE EBENE · 01", + "HEADING HOLD · AIS": "STEUERKURS FIX · AIS", + "NO HEADING · AIS": "KEIN STEUERKURS · AIS", "VISOR LOCK · ACTIVE": "VISIER-LOCK · AKTIV", "GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KN", + "SPEED OVER GROUND · KTS": "FAHRT ÜBER GRUND · KN", "ALTITUDE · FT": "HÖHE · FT", "FIRST PERSON": "EGO-PERSPEKTIVE", + "BRIDGE VIEW": "BRÜCKENANSICHT", "LIVE TRACK · COURSE ALIGNED": "LIVE-TRACK · IN KURSRICHTUNG", "Cockpit vision style": "Sichtstil im Cockpit", "Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil", @@ -679,11 +700,24 @@ "Next cockpit vision style": "Nächster Cockpit-Sichtstil", "Next vision style": "Nächster Sichtstil", "Current aircraft heading": "Aktueller Flugzeugkurs", + "Look ahead: turn the bridge view back to the bow": "Blick voraus: Brückenansicht zurück zum Bug drehen", + "Look ahead (Home or 0)": "Blick voraus (Pos1 oder 0)", + "LOOK AHEAD": "BLICK VORAUS", + "Current vessel heading": "Aktueller Steuerkurs des Schiffs", "GROUND SPEED": "GESCHW. ÜBER GRUND", + "SPEED OVER GROUND": "FAHRT ÜBER GRUND", + "COURSE OVER GROUND": "KURS ÜBER GRUND", "ALTITUDE": "HÖHE", "Contact cockpit summary": "Cockpit-Zusammenfassung des Kontakts", "CONTACT": "KONTAKT", "CONTACTS · 250 KM": "KONTAKTE · 250 KM", + "RINGS {spacing} NM": "RINGE {spacing} SM", + "{count} CONTACTS": { + "one": "{count} KONTAKT", + "other": "{count} KONTAKTE" + }, + "Radar range {range}. Select for the next range.": "Radarreichweite {range}. Auswählen, um zur nächsten Reichweite zu wechseln.", + "Bridge radar": "Brückenradar", "Contact navigation": "Kontaktnavigation", "CONTEXT ONLY": "NUR KONTEXT", "Nearby cohort counts": "Anzahl Kontakte in der Nähe", @@ -691,7 +725,9 @@ "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "NUR VERFÜGBARE QUELLEN · KEINE ENTWARNUNG", "Cockpit briefing carousel": "Cockpit-Briefing-Karussell", "Estimated flight plan": "Geschätzter Flugplan", + "Reported voyage": "Gemeldete Reise", "ESTIMATED FLIGHT PLAN": "GESCHÄTZTER FLUGPLAN", + "REPORTED VOYAGE": "GEMELDETE REISE", "FROM": "VON", "TO": "NACH", "Cockpit briefing controls": "Cockpit-Briefing-Steuerung", @@ -722,7 +758,9 @@ "Exit cockpit and return to full globe view": "Cockpit verlassen und zur Vollglobus-Ansicht zurückkehren", "RESET": "RESET", "Exit cockpit view": "Cockpitansicht verlassen", + "Exit bridge view": "Brückenansicht verlassen", "EXIT COCKPIT": "COCKPIT VERLASSEN", + "EXIT BRIDGE": "BRÜCKE VERLASSEN", "Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets", "Expand Visual Presets": "Visuelle Presets ausklappen", "Pin visual presets": "Visuelle Presets anheften", @@ -752,6 +790,7 @@ "SELECT CONTEXT": "KONTEXT WÄHLEN", "Contact Context actions": "Aktionen für Kontaktkontext", "COCKPIT": "COCKPIT", + "BRIDGE": "BRÜCKE", "SEARCH NEARBY SITES": "STANDORTE IN DER NÄHE SUCHEN", "Reclassify tracked contact as TR-3B": "Verfolgten Kontakt als TR-3B umklassifizieren", "CONTACTS CONTEXT OFF": "KONTAKTKONTEXT AUS", @@ -2836,6 +2875,8 @@ "LOCAL": "LOKAL", "ESC EXIT": "ESC BEENDEN", "C TOGGLE": "C UMSCHALTEN", + "ARROWS LOOK": "PFEILE UMSEHEN", + "HOME RESET": "POS1 ZURÜCKSETZEN", "Active weather products": "Aktive Wetterprodukte", "RECENT IMAGERY": "AKTUELLE BILDDATEN", "Imagery days in the box": "Bildtage im Bereich", @@ -3184,5 +3225,6 @@ "Close camera preview": "Kameravorschau schließen", "Refresh map still": "Kartenstandbild aktualisieren", "The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", - "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht." + "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", + "COURSE HOLD · AIS": "KURS FIX · AIS" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 48963ac30..8c83513ed 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,11 +38,23 @@ "REFRESHING LIVE DATA": "ACTUALIZANDO DATOS", "Close data attribution": "Cerrar la atribución de datos", "Aircraft cockpit view": "Vista de cabina de la aeronave", + "Ship bridge view": "Vista desde el puente del buque", "EXIT COCKPIT": "SALIR DE CABINA", + "EXIT BRIDGE": "SALIR DEL PUENTE", "GROUND SPEED": "VEL. SUELO", + "SPEED OVER GROUND": "VEL. SOBRE FONDO", + "COURSE OVER GROUND": "RUMBO SOBRE FONDO", "ALTITUDE": "ALTITUD", "CONTACT": "CONTACTO", "CONTACTS · 250 KM": "CONTACTOS · 250 KM", + "RINGS {spacing} NM": "ANILLOS {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTACTO", + "many": "{count} DE CONTACTOS", + "other": "{count} CONTACTOS" + }, + "Radar range {range}. Select for the next range.": "Alcance del radar {range}. Selecciona para pasar al siguiente alcance.", + "Bridge radar": "Radar del puente", "LIVE SIGNALS": "SEÑALES EN VIVO", "OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS", "CURRENT": "ACTUAL", @@ -115,16 +127,32 @@ "Landmark: --": "Punto de interés: --", "Search any location": "Buscar un lugar", "Search any location...": "Buscar un lugar…", + "LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°", + "LOOK PORT {bearing}°": "VISTA BABOR {bearing}°", + "LOOK ASTERN": "VISTA A POPA", + "UP {angle}°": "ARRIBA {angle}°", + "DOWN {angle}°": "ABAJO {angle}°", "LEVEL": "NIVEL", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Dirección estimada del destino", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", + "HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS", + "NO HEADING · AIS": "SIN PROA · AIS", "VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO", "FIRST PERSON": "PRIMERA PERSONA", + "BRIDGE VIEW": "VISTA DEL PUENTE", "LIVE TRACK · COURSE ALIGNED": "RASTREO EN VIVO · RUMBO ALINEADO", "Cockpit vision style": "Estilo de visión de la cabina", "Current aircraft heading": "Rumbo actual de la aeronave", + "Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa", + "Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)", + "LOOK AHEAD": "MIRAR A PROA", + "Current vessel heading": "Proa actual del buque", "GROUND SPEED · KTS": "VEL. SUELO · KN", + "SPEED OVER GROUND · KTS": "VEL. SOBRE FONDO · KN", "ALTITUDE · FT": "ALTITUD · FT", "PREV": "ANT.", "Previous cockpit vision style": "Estilo de visión de cabina anterior", @@ -137,9 +165,16 @@ "Exit cockpit and return to full globe view": "Salir de la cabina y volver a la vista de globo completo", "RESET": "RESTABLECER", "Exit cockpit view": "Salir de la vista de cabina", + "Exit bridge view": "Salir de la vista del puente", "Estimated flight plan": "Plan de vuelo estimado", + "Reported voyage": "Viaje notificado", "ESTIMATED FLIGHT PLAN": "PLAN DE VUELO ESTIMADO", + "REPORTED VOYAGE": "VIAJE NOTIFICADO", "ROUTE DATA UNAVAILABLE": "DATOS DE RUTA NO DISPONIBLES", + "CONTACT NAVIGATION OFF": "NAVEGACIÓN DE CONTACTOS DESACTIVADA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "EL PUENTE PERMANECE EN EL BUQUE SELECCIONADO", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINO AIS · NOTIFICADO POR LA TRIPULACIÓN", "FROM": "DESDE", "TO": "HACIA", "UNKNOWN": "DESCONOCIDO", @@ -183,6 +218,7 @@ "SELECT CONTEXT": "SELECCIONAR CONTEXTO", "Contact Context actions": "Acciones de contexto de contacto", "COCKPIT": "CABINA", + "BRIDGE": "PUENTE", "SEARCH NEARBY SITES": "BUSCAR SITIOS CERCANOS", "Reclassify tracked contact as TR-3B": "Reclasificar el contacto rastreado como TR-3B", "Reclassify as TR-3B": "Reclasificar como TR-3B", @@ -223,6 +259,8 @@ "Current style: {style} — click for next": "Estilo actual: {style} — haz clic para el siguiente", "TRACK ACQUIRED": "RASTREO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMBO {heading}°", + "{label} · HEADING {heading}°": "{label} · PROA {heading}°", + "{label} · NO HEADING": "{label} · SIN PROA", "Select flight {title}": "Seleccionar el vuelo {title}", "CONTEXT STANDBY": "CONTEXTO EN ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVAR CONTEXTO GLOBAL PARA PINGS DE PROXIMIDAD", @@ -247,6 +285,8 @@ "STALE FEED": "FUENTE DESACTUALIZADA", "LIVE TRACK": "RASTREO EN VIVO", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · RUMBO ALINEADO", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. OJO {height}", + "LIVE AIS": "AIS EN VIVO", "ARROW · ESTIMATED DIRECTION": "FLECHA · DIRECCIÓN ESTIMADA", "DEST {bearing}": "DEST {bearing}", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACTO PERDIDO · ÚLTIMA LECTURA CONOCIDA · NO IMPLICA TODO DESPEJADO", @@ -1605,6 +1645,8 @@ "Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com", "ESC EXIT": "ESC SALIR", "C TOGGLE": "C ALTERNAR", + "ARROWS LOOK": "FLECHAS MIRAR", + "HOME RESET": "INICIO RESTABLECER", "SIG": "SIG", "NEWS": "NOTICIAS", "LOCAL": "LOCAL", @@ -3259,5 +3301,6 @@ "Close camera preview": "Cerrar vista previa de la cámara", "Refresh map still": "Actualizar imagen fija del mapa", "The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", - "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP." + "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", + "COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 16fc249b6..b98b2cd0a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMMERCIAL", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ALIGNÉ SUR LA ROUTE", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · HAUTEUR D'ŒIL {height}", + "LIVE AIS": "AIS EN DIRECT", "ARROW · ESTIMATED DIRECTION": "FLÈCHE · DIRECTION ESTIMÉE", "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", + "CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Désactiver les effets météo du cockpit", "Enable cockpit weather effects": "Activer les effets météo du cockpit", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Reclasser en TR-3B", "TRACK ACQUIRED": "PISTE ACQUISE", "{label} · COURSE {heading}°": "{label} · ROUTE {heading}°", + "{label} · HEADING {heading}°": "{label} · CAP {heading}°", + "{label} · NO HEADING": "{label} · SANS CAP", "Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez", "No selected data layers": "Aucune couche de données sélectionnée", "{count} data layers could not be cleared": { @@ -2697,14 +2705,27 @@ "Recent speech": "Propos récents", "HERE": "ICI", "Aircraft cockpit view": "Vue cockpit de l'aéronef", + "Ship bridge view": "Vue depuis la passerelle du navire", + "LOOK STBD {bearing}°": "VUE TRIBORD {bearing}°", + "LOOK PORT {bearing}°": "VUE BÂBORD {bearing}°", + "LOOK ASTERN": "VUE ARRIÈRE", + "UP {angle}°": "HAUT {angle}°", + "DOWN {angle}°": "BAS {angle}°", "LEVEL": "HORIZON", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VUE {elevation}", "Estimated destination direction": "Direction estimée de la destination", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "VITRE PASSERELLE · AVANT", "OPTICAL PLANE · 01": "PLAN OPTIQUE · 01", + "HEADING HOLD · AIS": "VERROU CAP · AIS", + "NO HEADING · AIS": "SANS CAP · AIS", "VISOR LOCK · ACTIVE": "VERROU VISIÈRE · ACTIF", "GROUND SPEED · KTS": "VITESSE SOL · ND", + "SPEED OVER GROUND · KTS": "VITESSE FOND · ND", "ALTITUDE · FT": "ALTITUDE · FT", "FIRST PERSON": "PREMIÈRE PERSONNE", + "BRIDGE VIEW": "VUE PASSERELLE", "LIVE TRACK · COURSE ALIGNED": "PISTE EN DIRECT · ALIGNÉ SUR LA ROUTE", "Cockpit vision style": "Style de vision du cockpit", "Previous cockpit vision style": "Style de vision précédent du cockpit", @@ -2713,13 +2734,27 @@ "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", "Current aircraft heading": "Cap actuel de l'aéronef", + "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", + "Look ahead (Home or 0)": "Vue avant (Home ou 0)", + "LOOK AHEAD": "VUE AVANT", + "Current vessel heading": "Cap actuel du navire", "GROUND SPEED": "VITESSE SOL", + "SPEED OVER GROUND": "VITESSE FOND", + "COURSE OVER GROUND": "ROUTE FOND", "KTS": "ND", "ALTITUDE": "ALTITUDE", "FT": "FT", "Contact cockpit summary": "Résumé cockpit du contact", "CONTACT": "CONTACT", "CONTACTS · 250 KM": "CONTACTS · 250 KM", + "RINGS {spacing} NM": "CERCLES {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTACT", + "many": "{count} DE CONTACTS", + "other": "{count} CONTACTS" + }, + "Radar range {range}. Select for the next range.": "Portée radar {range}. Sélectionnez pour passer à la portée suivante.", + "Bridge radar": "Radar de passerelle", "Contact navigation": "Navigation des contacts", "CONTEXT ONLY": "CONTEXTE SEULEMENT", "Nearby cohort counts": "Nombre de contacts par groupe à proximité", @@ -2731,7 +2766,9 @@ "WX": "MÉTÉO", "Cockpit briefing carousel": "Carrousel de briefing du cockpit", "Estimated flight plan": "Plan de vol estimé", + "Reported voyage": "Voyage déclaré", "ESTIMATED FLIGHT PLAN": "PLAN DE VOL ESTIMÉ", + "REPORTED VOYAGE": "VOYAGE DÉCLARÉ", "FROM": "DE", "TO": "VERS", "Cockpit briefing controls": "Commandes du briefing du cockpit", @@ -2765,11 +2802,15 @@ "Cockpit Radio volume": "Volume de la Radio du cockpit", "ESC EXIT": "ESC QUITTER", "C TOGGLE": "C BASCULER", + "ARROWS LOOK": "FLÈCHES REGARDER", + "HOME RESET": "HOME RECENTRER", "View switcher": "Sélecteur de vue", "Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier", "RESET": "RÉINIT.", "Exit cockpit view": "Quitter la vue cockpit", + "Exit bridge view": "Quitter la vue passerelle", "EXIT COCKPIT": "QUITTER LE COCKPIT", + "EXIT BRIDGE": "QUITTER LA PASSERELLE", "Navigation, voice, and visual preset controls": "Commandes de navigation, de voix et de préréglages visuels", "Expand Visual Presets": "Développer les préréglages visuels", "Pin visual presets": "Épingler les préréglages visuels", @@ -2802,6 +2843,7 @@ "SELECT CONTEXT": "CHOISIR UN CONTEXTE", "Contact Context actions": "Actions du contexte Contact", "COCKPIT": "COCKPIT", + "BRIDGE": "PASSERELLE", "SEARCH NEARBY SITES": "CHERCHER SITES PROCHES", "Reclassify tracked contact as TR-3B": "Reclasser le contact suivi en TR-3B", "CONTACTS CONTEXT OFF": "CONTEXTE CONTACTS DÉSACTIVÉ", @@ -3259,5 +3301,6 @@ "Close camera preview": "Fermer l’aperçu de la caméra", "Refresh map still": "Actualiser l’image fixe sur la carte", "The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", - "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP." + "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", + "COURSE HOLD · AIS": "VERROU ROUTE · AIS" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 7f130832f..3fbf91cc2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMMERCIALE", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ROTTA ALLINEATA", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALTEZZA OCCHIO {height}", + "LIVE AIS": "AIS LIVE", "ARROW · ESTIMATED DIRECTION": "FRECCIA · DIREZIONE STIMATA", "ROUTE DATA UNAVAILABLE": "DATI ROTTA NON DISPONIBILI", + "CONTACT NAVIGATION OFF": "NAVIGAZIONE CONTATTI DISATTIVATA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PLANCIA RESTA SULLA NAVE SELEZIONATA", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINAZIONE AIS · DICHIARATA DALL'EQUIPAGGIO", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Disattiva gli effetti meteo del cockpit", "Enable cockpit weather effects": "Attiva gli effetti meteo del cockpit", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Riclassifica come TR-3B", "TRACK ACQUIRED": "TRACCIA ACQUISITA", "{label} · COURSE {heading}°": "{label} · ROTTA {heading}°", + "{label} · HEADING {heading}°": "{label} · PRUA {heading}°", + "{label} · NO HEADING": "{label} · NESSUNA PRUA", "Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova", "No selected data layers": "Nessun livello dati selezionato", "{count} data layers could not be cleared": { @@ -2697,14 +2705,27 @@ "Recent speech": "Parlato recente", "HERE": "QUI", "Aircraft cockpit view": "Vista cockpit dell'aereo", + "Ship bridge view": "Vista dalla plancia della nave", + "LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°", + "LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°", + "LOOK ASTERN": "VISTA A POPPA", + "UP {angle}°": "SU {angle}°", + "DOWN {angle}°": "GIÙ {angle}°", "LEVEL": "ORIZZONTE", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Direzione stimata della destinazione", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI", "OPTICAL PLANE · 01": "PIANO OTTICO · 01", + "HEADING HOLD · AIS": "BLOCCO PRUA · AIS", + "NO HEADING · AIS": "NESSUNA PRUA · AIS", "VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO", "GROUND SPEED · KTS": "VEL. AL SUOLO · KTS", + "SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS", "ALTITUDE · FT": "QUOTA · FT", "FIRST PERSON": "PRIMA PERSONA", + "BRIDGE VIEW": "VISTA PLANCIA", "LIVE TRACK · COURSE ALIGNED": "TRACCIA LIVE · ROTTA ALLINEATA", "Cockpit vision style": "Stile di visione del cockpit", "Previous cockpit vision style": "Stile di visione precedente del cockpit", @@ -2713,13 +2734,27 @@ "Next cockpit vision style": "Stile di visione successivo del cockpit", "Next vision style": "Stile di visione successivo", "Current aircraft heading": "Prua attuale dell'aereo", + "Look ahead: turn the bridge view back to the bow": "Guarda a prua: riporta la vista plancia verso la prua", + "Look ahead (Home or 0)": "Guarda a prua (Home o 0)", + "LOOK AHEAD": "GUARDA A PRUA", + "Current vessel heading": "Prua attuale della nave", "GROUND SPEED": "VEL. AL SUOLO", + "SPEED OVER GROUND": "VEL. SUL FONDO", + "COURSE OVER GROUND": "ROTTA SUL FONDO", "KTS": "KTS", "ALTITUDE": "ALTITUDINE", "FT": "FT", "Contact cockpit summary": "Riepilogo contatto del cockpit", "CONTACT": "CONTATTO", "CONTACTS · 250 KM": "CONTATTI · 250 KM", + "RINGS {spacing} NM": "CERCHI {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTATTO", + "many": "{count} DI CONTATTI", + "other": "{count} CONTATTI" + }, + "Radar range {range}. Select for the next range.": "Portata radar {range}. Seleziona per passare alla portata successiva.", + "Bridge radar": "Radar di plancia", "Contact navigation": "Navigazione contatti", "CONTEXT ONLY": "SOLO CONTESTO", "Nearby cohort counts": "Conteggi dei gruppi vicini", @@ -2731,7 +2766,9 @@ "WX": "METEO", "Cockpit briefing carousel": "Carosello briefing del cockpit", "Estimated flight plan": "Piano di volo stimato", + "Reported voyage": "Viaggio dichiarato", "ESTIMATED FLIGHT PLAN": "PIANO DI VOLO STIMATO", + "REPORTED VOYAGE": "VIAGGIO DICHIARATO", "FROM": "DA", "TO": "A", "Cockpit briefing controls": "Controlli briefing del cockpit", @@ -2765,11 +2802,15 @@ "Cockpit Radio volume": "Volume Radio del cockpit", "ESC EXIT": "ESC ESCI", "C TOGGLE": "C ALTERNA", + "ARROWS LOOK": "FRECCE GUARDA", + "HOME RESET": "HOME RIPRISTINA", "View switcher": "Selettore vista", "Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero", "RESET": "RESET", "Exit cockpit view": "Esci dalla vista cockpit", + "Exit bridge view": "Esci dalla vista plancia", "EXIT COCKPIT": "ESCI DAL COCKPIT", + "EXIT BRIDGE": "ESCI DALLA PLANCIA", "Navigation, voice, and visual preset controls": "Controlli di navigazione, voce e preset visivi", "Expand Visual Presets": "Espandi Preset visivi", "Pin visual presets": "Fissa i preset visivi", @@ -2802,6 +2843,7 @@ "SELECT CONTEXT": "SELEZIONA CONTESTO", "Contact Context actions": "Azioni Contesto contatto", "COCKPIT": "COCKPIT", + "BRIDGE": "PLANCIA", "SEARCH NEARBY SITES": "CERCA SITI VICINI", "Reclassify tracked contact as TR-3B": "Riclassifica il contatto tracciato come TR-3B", "CONTACTS CONTEXT OFF": "CONTESTO CONTATTI DISATTIVATO", @@ -3259,5 +3301,6 @@ "Close camera preview": "Chiudi anteprima della telecamera", "Refresh map still": "Aggiorna immagine fissa sulla mappa", "The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", - "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP." + "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", + "COURSE HOLD · AIS": "BLOCCO ROTTA · AIS" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 62e8af101..4c764fe96 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,8 +1650,14 @@ "COMMERCIAL": "民間", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 針路一致", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "ライブAIS", "ARROW · ESTIMATED DIRECTION": "矢印 · 推定方向", "ROUTE DATA UNAVAILABLE": "経路データなし", + "CONTACT NAVIGATION OFF": "コンタクトナビゲーション オフ", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "ブリッジは選択中の船舶に固定", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS目的地 · 乗組員入力", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "コックピットの天候エフェクトを無効にする", "Enable cockpit weather effects": "コックピットの天候エフェクトを有効にする", @@ -1696,6 +1702,8 @@ "Reclassify as TR-3B": "TR-3Bに再分類", "TRACK ACQUIRED": "追跡開始", "{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首方位 {heading}°", + "{label} · NO HEADING": "{label} · 船首方位なし", "Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください", "No selected data layers": "選択中のデータレイヤーはありません", "{count} data layers could not be cleared": { @@ -2555,14 +2563,27 @@ "Recent speech": "最近の発話", "HERE": "ここ", "Aircraft cockpit view": "航空機コックピットビュー", + "Ship bridge view": "船舶ブリッジビュー", + "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", + "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", + "LOOK ASTERN": "視線 船尾方向", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "推定目的地の方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "ブリッジ窓 · 前方", "OPTICAL PLANE · 01": "光学面 · 01", + "HEADING HOLD · AIS": "船首方位保持 · AIS", + "NO HEADING · AIS": "船首方位なし · AIS", "VISOR LOCK · ACTIVE": "バイザーロック · 有効", "GROUND SPEED · KTS": "対地速度 · KT", + "SPEED OVER GROUND · KTS": "対地速力 · KT", "ALTITUDE · FT": "高度 · FT", "FIRST PERSON": "一人称視点", + "BRIDGE VIEW": "ブリッジビュー", "LIVE TRACK · COURSE ALIGNED": "ライブ追跡 · 針路一致", "Cockpit vision style": "コックピットのビジョンスタイル", "Previous cockpit vision style": "前のコックピットビジョンスタイル", @@ -2571,13 +2592,25 @@ "Next cockpit vision style": "次のコックピットビジョンスタイル", "Next vision style": "次のビジョンスタイル", "Current aircraft heading": "現在の機首方位", + "Look ahead: turn the bridge view back to the bow": "前方を見る:ブリッジビューを船首方向に戻す", + "Look ahead (Home or 0)": "前方を見る(Homeまたは0)", + "LOOK AHEAD": "前方を見る", + "Current vessel heading": "現在の船首方位", "GROUND SPEED": "対地速度", + "SPEED OVER GROUND": "対地速力", + "COURSE OVER GROUND": "対地針路", "KTS": "KT", "ALTITUDE": "高度", "FT": "FT", "Contact cockpit summary": "コンタクトのコックピット概要", "CONTACT": "コンタクト", "CONTACTS · 250 KM": "コンタクト · 250 km", + "RINGS {spacing} NM": "距離環 {spacing}海里", + "{count} CONTACTS": { + "other": "コンタクト {count}件" + }, + "Radar range {range}. Select for the next range.": "レーダーレンジ {range}。選択すると次のレンジに切り替わります。", + "Bridge radar": "ブリッジレーダー", "Contact navigation": "コンタクトナビゲーション", "CONTEXT ONLY": "参考情報のみ", "Nearby cohort counts": "周辺の種別ごとの件数", @@ -2589,7 +2622,9 @@ "WX": "気象", "Cockpit briefing carousel": "コックピットブリーフィングカルーセル", "Estimated flight plan": "推定フライトプラン", + "Reported voyage": "申告された航海", "ESTIMATED FLIGHT PLAN": "推定フライトプラン", + "REPORTED VOYAGE": "申告された航海", "FROM": "出発", "TO": "到着", "Cockpit briefing controls": "コックピットブリーフィングの操作", @@ -2623,11 +2658,15 @@ "Cockpit Radio volume": "コックピットラジオの音量", "ESC EXIT": "Escで終了", "C TOGGLE": "Cで切替", + "ARROWS LOOK": "矢印キーで見回す", + "HOME RESET": "Homeでリセット", "View switcher": "ビュー切替", "Exit cockpit and return to full globe view": "コックピットを終了して地球全体ビューに戻る", "RESET": "リセット", "Exit cockpit view": "コックピットビューを終了", + "Exit bridge view": "ブリッジビューを終了", "EXIT COCKPIT": "コックピット終了", + "EXIT BRIDGE": "ブリッジ終了", "Navigation, voice, and visual preset controls": "ナビゲーション、音声、ビジュアルプリセットの操作", "Expand Visual Presets": "ビジュアルプリセットを展開", "Pin visual presets": "ビジュアルプリセットを固定", @@ -2660,6 +2699,7 @@ "SELECT CONTEXT": "コンテキストを選択", "Contact Context actions": "コンタクトコンテキストの操作", "COCKPIT": "コックピット", + "BRIDGE": "ブリッジ", "SEARCH NEARBY SITES": "周辺施設を検索", "Reclassify tracked contact as TR-3B": "追跡中のコンタクトをTR-3Bに再分類", "CONTACTS CONTEXT OFF": "コンタクトコンテキスト オフ", @@ -3109,5 +3149,6 @@ "Close camera preview": "カメラのプレビューを閉じる", "Refresh map still": "地図上の静止画を更新", "The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", - "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。" + "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", + "COURSE HOLD · AIS": "対地針路保持 · AIS" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 04cf3ccfb..ff0f567b8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMERCIAL", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CURSO ALINHADO", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. DO OLHO {height}", + "LIVE AIS": "AIS AO VIVO", "ARROW · ESTIMATED DIRECTION": "SETA · DIREÇÃO ESTIMADA", "ROUTE DATA UNAVAILABLE": "DADOS DE ROTA INDISPONÍVEIS", + "CONTACT NAVIGATION OFF": "NAVEGAÇÃO DE CONTATOS DESLIGADA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "O PASSADIÇO PERMANECE NA EMBARCAÇÃO SELECIONADA", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINO AIS · INFORMADO PELA TRIPULAÇÃO", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Desativar efeitos de clima na cabine", "Enable cockpit weather effects": "Ativar efeitos de clima na cabine", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Reclassificar como TR-3B", "TRACK ACQUIRED": "RASTRO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMO {heading}°", + "{label} · HEADING {heading}°": "{label} · PROA {heading}°", + "{label} · NO HEADING": "{label} · SEM PROA", "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", "No selected data layers": "Nenhuma camada de dados selecionada", "{count} data layers could not be cleared": { @@ -2699,14 +2707,27 @@ "Recent speech": "Fala recente", "HERE": "AQUI", "Aircraft cockpit view": "Vista da cabine da aeronave", + "Ship bridge view": "Vista do passadiço da embarcação", + "LOOK STBD {bearing}°": "VISTA BORESTE {bearing}°", + "LOOK PORT {bearing}°": "VISTA BOMBORDO {bearing}°", + "LOOK ASTERN": "VISTA À RÉ", + "UP {angle}°": "ACIMA {angle}°", + "DOWN {angle}°": "ABAIXO {angle}°", "LEVEL": "NIVELADO", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Direção estimada do destino", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", + "HEADING HOLD · AIS": "TRAVA DE PROA · AIS", + "NO HEADING · AIS": "SEM PROA · AIS", "VISOR LOCK · ACTIVE": "TRAVA DO VISOR · ATIVA", "GROUND SPEED · KTS": "VELOC. SOLO · KT", + "SPEED OVER GROUND · KTS": "VELOC. NO FUNDO · KT", "ALTITUDE · FT": "ALTITUDE · FT", "FIRST PERSON": "PRIMEIRA PESSOA", + "BRIDGE VIEW": "VISTA DO PASSADIÇO", "LIVE TRACK · COURSE ALIGNED": "RASTRO AO VIVO · CURSO ALINHADO", "Cockpit vision style": "Estilo de visão da cabine", "Previous cockpit vision style": "Estilo anterior de visão da cabine", @@ -2715,13 +2736,27 @@ "Next cockpit vision style": "Próximo estilo de visão da cabine", "Next vision style": "Próximo estilo de visão", "Current aircraft heading": "Rumo atual da aeronave", + "Look ahead: turn the bridge view back to the bow": "Olhar à vante: voltar a vista do passadiço para a proa", + "Look ahead (Home or 0)": "Olhar à vante (Home ou 0)", + "LOOK AHEAD": "OLHAR À VANTE", + "Current vessel heading": "Proa atual da embarcação", "GROUND SPEED": "VELOC. SOLO", + "SPEED OVER GROUND": "VELOC. NO FUNDO", + "COURSE OVER GROUND": "RUMO NO FUNDO", "KTS": "KT", "ALTITUDE": "ALTITUDE", "FT": "FT", "Contact cockpit summary": "Resumo do contato na cabine", "CONTACT": "CONTATO", "CONTACTS · 250 KM": "CONTATOS · 250 KM", + "RINGS {spacing} NM": "ANÉIS {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTATO", + "many": "{count} DE CONTATOS", + "other": "{count} CONTATOS" + }, + "Radar range {range}. Select for the next range.": "Alcance do radar {range}. Selecione para passar ao próximo alcance.", + "Bridge radar": "Radar do passadiço", "Contact navigation": "Navegação de contatos", "CONTEXT ONLY": "SÓ CONTEXTO", "Nearby cohort counts": "Contagens por categoria nas proximidades", @@ -2733,7 +2768,9 @@ "WX": "WX", "Cockpit briefing carousel": "Carrossel de briefing da cabine", "Estimated flight plan": "Plano de voo estimado", + "Reported voyage": "Viagem informada", "ESTIMATED FLIGHT PLAN": "PLANO DE VOO ESTIMADO", + "REPORTED VOYAGE": "VIAGEM INFORMADA", "FROM": "DE", "TO": "PARA", "Cockpit briefing controls": "Controles do briefing da cabine", @@ -2767,11 +2804,15 @@ "Cockpit Radio volume": "Volume do rádio da cabine", "ESC EXIT": "ESC SAIR", "C TOGGLE": "C ALTERNAR", + "ARROWS LOOK": "SETAS OLHAR", + "HOME RESET": "HOME REDEFINIR", "View switcher": "Alternador de vista", "Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo", "RESET": "REDEFINIR", "Exit cockpit view": "Sair da vista da cabine", + "Exit bridge view": "Sair da vista do passadiço", "EXIT COCKPIT": "SAIR DA CABINE", + "EXIT BRIDGE": "SAIR DO PASSADIÇO", "Navigation, voice, and visual preset controls": "Controles de navegação, voz e predefinições visuais", "Expand Visual Presets": "Expandir predefinições visuais", "Pin visual presets": "Fixar predefinições visuais", @@ -2804,6 +2845,7 @@ "SELECT CONTEXT": "SELECIONE O CONTEXTO", "Contact Context actions": "Ações de contexto do contato", "COCKPIT": "CABINE", + "BRIDGE": "PASSADIÇO", "SEARCH NEARBY SITES": "BUSCAR LOCAIS PRÓXIMOS", "Reclassify tracked contact as TR-3B": "Reclassificar o contato rastreado como TR-3B", "CONTACTS CONTEXT OFF": "CONTEXTO DE CONTATOS DESLIGADO", @@ -3259,5 +3301,6 @@ "Close camera preview": "Fechar prévia da câmera", "Refresh map still": "Atualizar imagem fixa no mapa", "The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", - "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP." + "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.", + "COURSE HOLD · AIS": "TRAVA DE RUMO · AIS" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index eb0080bce..5e6f5ed44 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -411,10 +411,16 @@ "STALE FEED": "ПОТОК УСТАРЕЛ", "LIVE TRACK": "ТРЕК ОНЛАЙН", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС СОВМЕЩЁН", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВЫСОТА ГЛАЗА {height}", + "LIVE AIS": "AIS ОНЛАЙН", "COMMERCIAL": "ГРАЖДАНСКИЙ", "UNKNOWN": "НЕИЗВЕСТНО", "ARROW · ESTIMATED DIRECTION": "СТРЕЛКА · РАСЧЁТНОЕ НАПРАВЛЕНИЕ", "ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ", + "CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ", + "{range} NM": "{range} МОР. МИЛЬ", + "AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ", "DEST {bearing}": "НАЗН. {bearing}", "Disable cockpit weather effects": "Выключить погодные эффекты кабины", "Enable cockpit weather effects": "Включить погодные эффекты кабины", @@ -459,6 +465,8 @@ "Select flight {title}": "Выбрать рейс {title}", "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", "{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°", + "{label} · HEADING {heading}°": "{label} · КУРС {heading}°", + "{label} · NO HEADING": "{label} · НЕТ КУРСА", "Restore real aircraft": "Вернуть реальный самолёт", "Reclassify as TR-3B": "Переклассифицировать как TR-3B", "No selected data layers": "Нет выбранных слоёв данных", @@ -639,11 +647,19 @@ "Next filtered radio station": "Следующая отфильтрованная радиостанция", "Exit cockpit and return to full globe view": "Выйти из кабины и вернуться к виду всего глобуса", "Exit cockpit view": "Выйти из кабины", + "Exit bridge view": "Выйти с мостика", "Estimated destination direction": "Расчётное направление на пункт назначения", + "BRIDGE WINDOW · FWD": "ОКНО МОСТИКА · ПО НОСУ", "OPTICAL PLANE · 01": "ОПТИЧЕСКАЯ ПЛОСКОСТЬ · 01", + "HEADING HOLD · AIS": "УДЕРЖАНИЕ КУРСА · AIS", + "NO HEADING · AIS": "НЕТ КУРСА · AIS", "VISOR LOCK · ACTIVE": "БЛОКИРОВКА ВИЗОРА · АКТИВНА", "Cockpit vision style": "Стиль обзора кабины", "Current aircraft heading": "Текущий курс самолёта", + "Look ahead: turn the bridge view back to the bow": "Взгляд по носу: развернуть вид с мостика обратно к носу", + "Look ahead (Home or 0)": "Взгляд по носу (Home или 0)", + "LOOK AHEAD": "ВЗГЛЯД ПО НОСУ", + "Current vessel heading": "Текущий курс судна", "Nearby cohort counts": "Счётчики контактов поблизости", "Cockpit display options": "Параметры экрана кабины", "Cockpit compact Radio controls": "Компактное управление радио кабины", @@ -652,23 +668,37 @@ "Cockpit briefing controls": "Управление брифингом кабины", "View switcher": "Переключатель вида", "Aircraft cockpit view": "Вид кабины самолёта", + "Ship bridge view": "Вид с ходового мостика", "Estimated flight plan": "Расчётный план полёта", + "Reported voyage": "Заявленный рейс", "Live signals": "Сигналы в реальном времени", "Latest regional news": "Последние региональные новости", "Location-based information": "Информация по текущему месту", + "LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°", + "LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ", + "UP {angle}°": "ВВЕРХ {angle}°", + "DOWN {angle}°": "ВНИЗ {angle}°", "LEVEL": "ГОРИЗОНТ", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "ВЗГЛЯД {elevation}", "DEST ---°": "НАЗН. ---°", "CURRENT": "ТЕКУЩИЙ", "NEAREST OBSERVED / MAPPED": "БЛИЖАЙШИЕ НАБЛЮДАЕМЫЕ / НА КАРТЕ", "GROUND SPEED · KTS": "ПУТЕВАЯ СКОРОСТЬ · УЗ", + "SPEED OVER GROUND · KTS": "СКОРОСТЬ ПО ГРУНТУ · УЗ", "ALTITUDE · FT": "ВЫСОТА · ФТ", "FIRST PERSON": "ОТ ПЕРВОГО ЛИЦА", + "BRIDGE VIEW": "ВИД С МОСТИКА", "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН", "GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ", + "SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ", + "COURSE OVER GROUND": "КУРС ПО ГРУНТУ", "ALTITUDE": "ВЫСОТА", "CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "ESTIMATED FLIGHT PLAN": "РАСЧЁТНЫЙ ПЛАН ПОЛЁТА", + "REPORTED VOYAGE": "ЗАЯВЛЕННЫЙ РЕЙС", "FROM": "ОТКУДА", "TO": "КУДА", "TEMP": "ТЕМП.", @@ -679,7 +709,17 @@ "VOLUME": "ГРОМКОСТЬ", "RESET": "СБРОС", "EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ", + "EXIT BRIDGE": "ВЫЙТИ С МОСТИКА", "CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ", + "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ", + "{count} CONTACTS": { + "one": "{count} КОНТАКТ", + "few": "{count} КОНТАКТА", + "many": "{count} КОНТАКТОВ", + "other": "{count} КОНТАКТА" + }, + "Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.", + "Bridge radar": "Радар ходового мостика", "Expand Visual Presets": "Развернуть визуальные пресеты", "Pin visual presets": "Закрепить визуальные пресеты", "Keep visual presets open": "Держать визуальные пресеты открытыми", @@ -735,6 +775,7 @@ "CONTEXT": "КОНТЕКСТ", "SPACE MISSIONS": "КОСМИЧЕСКИЕ МИССИИ", "COCKPIT": "КАБИНА", + "BRIDGE": "МОСТИК", "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "ВЫБЕРИТЕ КОНТАКТЫ, ЧТОБЫ ЗАГРУЗИТЬ НАБЛЮДАЕМОЕ / КАРТОГРАФИРОВАННОЕ ОКРУЖЕНИЕ", "SELECT A MISSION TO INSPECT": "ВЫБЕРИТЕ МИССИЮ ДЛЯ ПРОСМОТРА", "Enable Radio, then choose a globe marker or use next.": "Включите радио, затем выберите маркер на глобусе или переключите на следующую станцию.", @@ -1632,6 +1673,8 @@ "Weather data by Open-Meteo.com": "Данные о погоде: Open-Meteo.com", "ESC EXIT": "ESC ВЫХОД", "C TOGGLE": "C ПЕРЕКЛ.", + "ARROWS LOOK": "СТРЕЛКИ ОБЗОР", + "HOME RESET": "HOME СБРОС", "SIG": "СИГН.", "NEWS": "НОВОСТИ", "LOCAL": "МЕСТН.", @@ -3334,5 +3377,6 @@ "Close camera preview": "Закрыть просмотр камеры", "Refresh map still": "Обновить кадр на карте", "The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", - "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес." + "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.", + "COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a61335f2d..807e62da1 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1724,8 +1724,14 @@ "COMMERCIAL": "КОМЕРЦІЙНИЙ", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КАМЕРА ЗА КУРСОМ", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВИСОТА ОКА {height}", + "LIVE AIS": "AIS ОНЛАЙН", "ARROW · ESTIMATED DIRECTION": "СТРІЛКА · ОРІЄНТОВНИЙ НАПРЯМОК", "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", + "CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ", + "{range} NM": "{range} МОР. МИЛЬ", + "AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ", "DEST {bearing}": "ПРИЗН. {bearing}", "Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни", "Enable cockpit weather effects": "Увімкнути погодні ефекти кабіни", @@ -1773,6 +1779,8 @@ "Reclassify as TR-3B": "Перекласифікувати як TR-3B", "TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО", "{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°", + "{label} · HEADING {heading}°": "{label} · КУРС {heading}°", + "{label} · NO HEADING": "{label} · НЕМАЄ КУРСУ", "Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз", "No selected data layers": "Шари даних не вибрано", "{count} data layers could not be cleared": { @@ -2767,14 +2775,27 @@ "Recent speech": "Нещодавнє мовлення", "HERE": "ТУТ", "Aircraft cockpit view": "Вигляд кабіни повітряного судна", + "Ship bridge view": "Вигляд з ходового містка судна", + "LOOK STBD {bearing}°": "ПОГЛЯД ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ПОГЛЯД ЛІВ. БОРТ {bearing}°", + "LOOK ASTERN": "ПОГЛЯД ЗА КОРМУ", + "UP {angle}°": "ВГОРУ {angle}°", + "DOWN {angle}°": "ВНИЗ {angle}°", "LEVEL": "ГОРИЗОНТ", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "ПОГЛЯД {elevation}", "Estimated destination direction": "Орієнтовний напрямок до пункту призначення", "DEST ---°": "ПРИЗН. ---°", + "BRIDGE WINDOW · FWD": "ВІКНО МІСТКА · ПО НОСІ", "OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01", + "HEADING HOLD · AIS": "УТРИМАННЯ КУРСУ · AIS", + "NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS", "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", "GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", + "SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ", "ALTITUDE · FT": "ВИСОТА · ФТ", "FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ", + "BRIDGE VIEW": "ВИГЛЯД З МІСТКА", "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КАМЕРА ЗА КУРСОМ", "Cockpit vision style": "Візуальний стиль у кабіні", "Previous cockpit vision style": "Попередній візуальний стиль у кабіні", @@ -2783,13 +2804,28 @@ "Next cockpit vision style": "Наступний візуальний стиль у кабіні", "Next vision style": "Наступний візуальний стиль", "Current aircraft heading": "Поточний курс повітряного судна", + "Look ahead: turn the bridge view back to the bow": "Погляд по носі: повернути вигляд з містка назад до носа", + "Look ahead (Home or 0)": "Погляд по носі (Home або 0)", + "LOOK AHEAD": "ПОГЛЯД ПО НОСІ", + "Current vessel heading": "Поточний курс судна", "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", + "SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ", + "COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ", "KTS": "ВУЗ", "ALTITUDE": "ВИСОТА", "FT": "ФТ", "Contact cockpit summary": "Зведення контакту в кабіні", "CONTACT": "КОНТАКТ", "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", + "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ", + "{count} CONTACTS": { + "one": "{count} КОНТАКТ", + "few": "{count} КОНТАКТИ", + "many": "{count} КОНТАКТІВ", + "other": "{count} КОНТАКТУ" + }, + "Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.", + "Bridge radar": "Радар ходового містка", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", "Nearby cohort counts": "Кількість контактів поблизу за типами", @@ -2801,7 +2837,9 @@ "WX": "ПОГОДА", "Cockpit briefing carousel": "Карусель брифінгу кабіни", "Estimated flight plan": "Орієнтовний план польоту", + "Reported voyage": "Заявлений рейс", "ESTIMATED FLIGHT PLAN": "ОРІЄНТОВНИЙ ПЛАН ПОЛЬОТУ", + "REPORTED VOYAGE": "ЗАЯВЛЕНИЙ РЕЙС", "FROM": "ЗВІДКИ", "TO": "КУДИ", "Cockpit briefing controls": "Керування брифінгом кабіни", @@ -2835,11 +2873,15 @@ "Cockpit Radio volume": "Гучність радіо кабіни", "ESC EXIT": "ESC ВИХІД", "C TOGGLE": "C ПЕРЕМК.", + "ARROWS LOOK": "СТРІЛКИ ОГЛЯД", + "HOME RESET": "HOME СКИДАННЯ", "View switcher": "Перемикач вигляду", "Exit cockpit and return to full globe view": "Вийти з кабіни та повернутися до вигляду всього глобуса", "RESET": "СКИНУТИ", "Exit cockpit view": "Вийти з вигляду кабіни", + "Exit bridge view": "Вийти з вигляду містка", "EXIT COCKPIT": "ВИЙТИ З КАБІНИ", + "EXIT BRIDGE": "ВИЙТИ З МІСТКА", "Navigation, voice, and visual preset controls": "Керування навігацією, голосом і візуальними пресетами", "Expand Visual Presets": "Розгорнути візуальні пресети", "Pin visual presets": "Закріпити візуальні пресети", @@ -2872,6 +2914,7 @@ "SELECT CONTEXT": "ВИБЕРІТЬ КОНТЕКСТ", "Contact Context actions": "Дії контексту контактів", "COCKPIT": "КАБІНА", + "BRIDGE": "МІСТОК", "SEARCH NEARBY SITES": "ПОШУК НАЙБЛИЖЧИХ ОБʼЄКТІВ", "Reclassify tracked contact as TR-3B": "Перекласифікувати відстежуваний контакт як TR-3B", "CONTACTS CONTEXT OFF": "КОНТЕКСТ КОНТАКТІВ ВИМКНЕНО", @@ -3334,5 +3377,6 @@ "Close camera preview": "Закрити перегляд камери", "Refresh map still": "Оновити кадр на карті", "The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", - "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу." + "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.", + "COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 89236f024..f8cce3a03 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1626,8 +1626,14 @@ "COMMERCIAL": "商用", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向对准", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "实时 AIS", "ARROW · ESTIMATED DIRECTION": "箭头 · 预估方向", "ROUTE DATA UNAVAILABLE": "航线数据不可用", + "CONTACT NAVIGATION OFF": "目标导航已关闭", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座舱天气效果", "Enable cockpit weather effects": "启用座舱天气效果", @@ -1672,6 +1678,8 @@ "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已获取航迹", "{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首向 {heading}°", + "{label} · NO HEADING": "{label} · 无船首向", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { @@ -2454,14 +2462,27 @@ "DONE": "完成", "HERE": "此处", "Aircraft cockpit view": "飞机座舱视图", + "Ship bridge view": "船舶驾驶台视图", + "LOOK STBD {bearing}°": "视线 右舷 {bearing}°", + "LOOK PORT {bearing}°": "视线 左舷 {bearing}°", + "LOOK ASTERN": "视线 船尾", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "视线 {elevation}", "Estimated destination direction": "预估目的地方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "驾驶台窗口 · 前方", "OPTICAL PLANE · 01": "光学平面 · 01", + "HEADING HOLD · AIS": "船首向保持 · AIS", + "NO HEADING · AIS": "无船首向 · AIS", "VISOR LOCK · ACTIVE": "面罩锁定 · 激活", "GROUND SPEED · KTS": "地速 · kn", + "SPEED OVER GROUND · KTS": "对地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人称", + "BRIDGE VIEW": "驾驶台视图", "LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准", "Cockpit vision style": "座舱视觉样式", "Previous cockpit vision style": "上一个座舱视觉样式", @@ -2470,13 +2491,25 @@ "Next cockpit vision style": "下一个座舱视觉样式", "Next vision style": "下一个视觉样式", "Current aircraft heading": "飞机当前航向", + "Look ahead: turn the bridge view back to the bow": "看向前方:将驾驶台视图转回船首", + "Look ahead (Home or 0)": "看向前方(Home 或 0)", + "LOOK AHEAD": "看向前方", + "Current vessel heading": "船舶当前船首向", "GROUND SPEED": "地速", + "SPEED OVER GROUND": "对地航速", + "COURSE OVER GROUND": "对地航向", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", "CONTACTS · 250 KM": "目标 · 250 km", + "RINGS {spacing} NM": "距标圈 {spacing}海里", + "{count} CONTACTS": { + "other": "{count} 个目标" + }, + "Radar range {range}. Select for the next range.": "雷达量程 {range}。选择以切换到下一量程。", + "Bridge radar": "驾驶台雷达", "Contact navigation": "目标导航", "CONTEXT ONLY": "仅供参考", "Nearby cohort counts": "附近各类目标数量", @@ -2488,7 +2521,9 @@ "WX": "天气", "Cockpit briefing carousel": "座舱简报轮播", "Estimated flight plan": "预估飞行计划", + "Reported voyage": "申报航次", "ESTIMATED FLIGHT PLAN": "预估飞行计划", + "REPORTED VOYAGE": "申报航次", "FROM": "出发", "TO": "到达", "Cockpit briefing controls": "座舱简报控件", @@ -2522,11 +2557,15 @@ "Cockpit Radio volume": "座舱电台音量", "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切换", + "ARROWS LOOK": "方向键 环视", + "HOME RESET": "HOME 复位", "View switcher": "视图切换", "Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图", "RESET": "重置", "Exit cockpit view": "退出座舱视图", + "Exit bridge view": "退出驾驶台视图", "EXIT COCKPIT": "退出座舱", + "EXIT BRIDGE": "退出驾驶台", "Navigation, voice, and visual preset controls": "导航、语音和视觉预设控件", "Expand Visual Presets": "展开视觉预设", "Pin visual presets": "固定视觉预设", @@ -2559,6 +2598,7 @@ "SELECT CONTEXT": "选择态势", "Contact Context actions": "目标态势操作", "COCKPIT": "座舱", + "BRIDGE": "驾驶台", "SEARCH NEARBY SITES": "搜索附近设施", "Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B", "CONTACTS CONTEXT OFF": "目标态势已关闭", @@ -3109,5 +3149,6 @@ "Close camera preview": "关闭摄像头预览", "Refresh map still": "刷新地图静止画面", "The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", - "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。" + "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。", + "COURSE HOLD · AIS": "对地航向保持 · AIS" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e3ce492df..a4c7c5460 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1616,8 +1616,14 @@ "COMMERCIAL": "商用", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "實時 AIS", "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", "ROUTE DATA UNAVAILABLE": "航線數據不可用", + "CONTACT NAVIGATION OFF": "目標導航已關閉", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座艙天氣效果", "Enable cockpit weather effects": "啟用座艙天氣效果", @@ -1662,6 +1668,8 @@ "Reclassify as TR-3B": "重新歸類為 TR-3B", "TRACK ACQUIRED": "已獲取航跡", "{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首向 {heading}°", + "{label} · NO HEADING": "{label} · 無船首向", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { @@ -2442,14 +2450,27 @@ "DONE": "完成", "HERE": "此處", "Aircraft cockpit view": "飛機座艙視圖", + "Ship bridge view": "船舶駕駛台視圖", + "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", + "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", + "LOOK ASTERN": "視線 船尾", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方", "OPTICAL PLANE · 01": "光學平面 · 01", + "HEADING HOLD · AIS": "船首向保持 · AIS", + "NO HEADING · AIS": "無船首向 · AIS", "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", "GROUND SPEED · KTS": "地速 · kn", + "SPEED OVER GROUND · KTS": "對地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人稱", + "BRIDGE VIEW": "駕駛台視圖", "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", "Cockpit vision style": "座艙視覺樣式", "Previous cockpit vision style": "上一個座艙視覺樣式", @@ -2458,13 +2479,25 @@ "Next cockpit vision style": "下一個座艙視覺樣式", "Next vision style": "下一個視覺樣式", "Current aircraft heading": "飛機當前航向", + "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首", + "Look ahead (Home or 0)": "看向前方(Home 或 0)", + "LOOK AHEAD": "看向前方", + "Current vessel heading": "船舶當前船首向", "GROUND SPEED": "地速", + "SPEED OVER GROUND": "對地航速", + "COURSE OVER GROUND": "對地航向", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", "CONTACTS · 250 KM": "目標 · 250 km", + "RINGS {spacing} NM": "距標圈 {spacing}海里", + "{count} CONTACTS": { + "other": "{count} 個目標" + }, + "Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。", + "Bridge radar": "駕駛台雷達", "Contact navigation": "目標導航", "CONTEXT ONLY": "僅供參考", "Nearby cohort counts": "附近各類目標數量", @@ -2476,7 +2509,9 @@ "WX": "天氣", "Cockpit briefing carousel": "座艙簡報輪播", "Estimated flight plan": "預估飛行計劃", + "Reported voyage": "申報航次", "ESTIMATED FLIGHT PLAN": "預估飛行計劃", + "REPORTED VOYAGE": "申報航次", "FROM": "出發", "TO": "到達", "Cockpit briefing controls": "座艙簡報控件", @@ -2510,11 +2545,15 @@ "Cockpit Radio volume": "座艙電臺音量", "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切換", + "ARROWS LOOK": "方向鍵 環視", + "HOME RESET": "HOME 復位", "View switcher": "視圖切換", "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", "RESET": "重置", "Exit cockpit view": "退出座艙視圖", + "Exit bridge view": "退出駕駛台視圖", "EXIT COCKPIT": "退出座艙", + "EXIT BRIDGE": "退出駕駛台", "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", "Expand Visual Presets": "展開視覺預設", "Pin visual presets": "固定視覺預設", @@ -2547,6 +2586,7 @@ "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標態勢操作", "COCKPIT": "座艙", + "BRIDGE": "駕駛台", "SEARCH NEARBY SITES": "搜索附近設施", "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", "CONTACTS CONTEXT OFF": "目標態勢已關閉", @@ -3109,5 +3149,6 @@ "Close camera preview": "關閉攝影機預覽", "Refresh map still": "重新整理地圖靜態畫面", "The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", - "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。" + "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", + "COURSE HOLD · AIS": "對地航向保持 · AIS" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0dc9841f7..860bc740d 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -35,6 +35,7 @@ "hPa", "iNaturalist", "iNaturalist ↗", + "{direction} · {elevation}", "{source} HTTP {status}", "▲ {frp} MW" ], @@ -202,6 +203,7 @@ "view band|GLOBAL", "{city} CCTV", "{county} County", + "{direction} · {elevation}", "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -335,6 +337,7 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{direction} · {elevation}", "{rate} msg/s", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -532,9 +535,11 @@ "standard", "station", "voice|OFF", + "{count} CONTACTS", "{count} MSGS", "{count} SOURCES", "{count} messages", + "{direction} · {elevation}", "{n} cyclones", "{n} satellites", "{rate} msg/s", @@ -707,6 +712,7 @@ "{count} MSGS", "{count} feeds live", "{count} scenes", + "{direction} · {elevation}", "{rate} msg/s", "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}", "{source} HTTP {status}", @@ -773,6 +779,7 @@ "kt", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", @@ -906,6 +913,7 @@ "mph", "nominal", "view band|GLOBAL", + "{direction} · {elevation}", "{rate} msg/s", "{type} SPD: {speed} HDG: {heading}", "{value} KM", @@ -942,7 +950,8 @@ "WHOT/BHOT", "acartia.io ↗", "iNaturalist", - "iNaturalist ↗" + "iNaturalist ↗", + "{direction} · {elevation}" ], "uk": [ "AIS: --", @@ -960,7 +969,8 @@ "WHOT/BHOT", "acartia.io ↗", "iNaturalist", - "iNaturalist ↗" + "iNaturalist ↗", + "{direction} · {elevation}" ], "zh-CN": [ "1090 MHz", @@ -1009,6 +1019,7 @@ "kts", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", @@ -1051,9 +1062,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -1072,6 +1083,7 @@ "kts", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", diff --git a/src/layers/vessels/cockpitSubject.js b/src/layers/vessels/cockpitSubject.js new file mode 100644 index 000000000..d92689a64 --- /dev/null +++ b/src/layers/vessels/cockpitSubject.js @@ -0,0 +1,142 @@ +/** + * The selected vessel as a Cockpit bridge subject: identity, motion, its + * displayed type and a representative bridge eye height from the AIS type. + * The Cockpit owns camera placement; this module owns what AIS says. + */ +import { normalizeVesselType } from '../../data/vesselTypes.js'; +import { displayVesselType } from '../../data/vesselLabels.js'; + +/** The layer id a vessel subject carries through Cockpit events and state. */ +export const VESSEL_COCKPIT_LAYER_ID = 'ais-live-vessels'; + +/** Knots to metres per second, as the AIS source uses. */ +const KNOTS_TO_MPS = 0.514444; + +/** + * Bridge eye height above the waterline by normalized AIS type family. AIS + * reports no hull height, so these are representative values: deep-sea + * cargo and tanker bridges, passenger and cruise ship bridges, harbour + * craft, small craft. River and harbour ferries, matched by name in the + * patterns below, sit low. Text types from non-numeric feeds fall through + * to the patterns. + */ +const EYE_HEIGHT_BY_FAMILY = Object.freeze({ + CARGO: 30, + TANKER: 30, + PASSENGER: 25, + MILITARY: 18, + 'HIGH-SPEED': 8, + DREDGER: 10, + TOWING: 7, + TUG: 7, + PILOT: 6, + SAR: 6, + 'PORT TENDER': 6, + 'ANTI-POLLUTION': 7, + 'LAW ENFORCE': 7, + MEDICAL: 7, + 'DIVE OPS': 5, + FISHING: 6, + SAILING: 3, + PLEASURE: 3, + // AIS "Other" (90-99) is mostly small craft such as river boats. Only + // that type takes it (see isAisOther). + OTHER: 4, +}); + +const EYE_HEIGHT_PATTERNS = Object.freeze([ + { pattern: /tanker|cargo|container|bulk|carrier/i, heightM: 30 }, + { pattern: /passenger|cruise|ro-?ro/i, heightM: 25 }, + // River and harbour ferries: a low wheelhouse just above the water. + { pattern: /ferry/i, heightM: 4 }, + { pattern: /tug|tow|pilot|supply|service/i, heightM: 7 }, + { pattern: /fishing|trawler/i, heightM: 6 }, + { pattern: /sail|pleasure|yacht/i, heightM: 3 }, +]); + +/** + * Whether a raw type is AIS "Other" proper: codes 90 to 99, or a feed's text + * type. normalizeVesselType also files the reserved, wing-in-ground and + * spare codes (1-29, 38-39, 56-57, 59) under OTHER, but they name no family. + */ +function isAisOther(type) { + const text = String(type ?? '').trim(); + if (!/^\d+$/.test(text)) return true; + const code = Number(text); + return code >= 90 && code <= 99; +} + +/** + * Representative bridge eye height for an AIS vessel type. + * @param {string|number} type Raw AIS type: a numeric code or feed text. + * @returns {number|null} Metres above the waterline, or null when the type + * names no known family (the Cockpit then uses its default). + */ +export function vesselEyeHeightM(type) { + const family = normalizeVesselType(type).toUpperCase(); + if (family === 'OTHER' && !isAisOther(type)) return null; + if (Object.hasOwn(EYE_HEIGHT_BY_FAMILY, family)) + return EYE_HEIGHT_BY_FAMILY[family]; + const matched = EYE_HEIGHT_PATTERNS.find((entry) => + entry.pattern.test(family), + ); + return matched ? matched.heightM : null; +} + +function finiteOrNull(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +/** + * Normalize a selected vessel record into Cockpit subject info. + * + * `track` is where the camera looks and `motionCourse` is where the hull + * moves: a ship faces its true heading while current and wind set it along + * its course over ground. Either falls back to the other when one is missing. + * @param {object|null} record Vessel record from the vessel layer. + * @returns {object|null} Subject info, or null without a usable fix. + */ +export function vesselCockpitInfo(record) { + if (!record?.mmsi) return null; + const latitude = Number(record.lat); + const longitude = Number(record.lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + const heading = finiteOrNull(record.heading); + const course = finiteOrNull(record.course); + const speedKt = finiteOrNull(record.speed); + const epochSec = finiteOrNull(record.lastPositionEpoch); + const feedEpochSec = finiteOrNull(record.fixFeedEpoch); + return { + kind: 'vessel', + layerId: VESSEL_COCKPIT_LAYER_ID, + id: String(record.mmsi), + mmsi: String(record.mmsi), + name: String(record.name || record.mmsi), + type: String(record.type || ''), + typeLabel: displayVesselType(record.type), + destination: String(record.destination || '').trim(), + latitude, + longitude, + heading, + course, + speedKt, + velocityMps: speedKt === null ? null : Math.max(0, speedKt) * KNOTS_TO_MPS, + track: heading ?? course, + // What `track` is: the true heading, or course over ground standing in + // for a ship that reports none. The HUD says which. + trackSource: + heading !== null ? 'heading' : course !== null ? 'course' : null, + motionCourse: course ?? heading, + fixEpochMs: epochSec === null ? null : epochSec * 1000, + // When this client first received the fix (its own clock) and the + // newest fix in that refresh (the feed's clock): the fix's age without + // clock skew, still ageing while the feed re-serves it. + receivedAtMs: finiteOrNull(record.fixReceivedAtMs), + feedEpochMs: feedEpochSec === null ? null : feedEpochSec * 1000, + eyeHeightM: vesselEyeHeightM(record.type), + stale: (record.missedRefreshes || 0) > 0, + onGround: false, + }; +} diff --git a/src/layers/vessels/cockpitSubject.test.mjs b/src/layers/vessels/cockpitSubject.test.mjs new file mode 100644 index 000000000..572ff199d --- /dev/null +++ b/src/layers/vessels/cockpitSubject.test.mjs @@ -0,0 +1,113 @@ +// src/layers/vessels/cockpitSubject.test.mjs — bridge eye heights by AIS +// type and the selected vessel as Cockpit subject info. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + VESSEL_COCKPIT_LAYER_ID, + vesselCockpitInfo, + vesselEyeHeightM, +} from './cockpitSubject.js'; + +test('bridge eye height follows the AIS type family', () => { + assert.equal(vesselEyeHeightM('71'), 30, 'cargo'); + assert.equal(vesselEyeHeightM('84'), 30, 'tanker'); + assert.equal(vesselEyeHeightM('60'), 25, 'passenger'); + assert.equal(vesselEyeHeightM('52'), 7, 'tug'); + assert.equal(vesselEyeHeightM('30'), 6, 'fishing'); + assert.equal(vesselEyeHeightM('37'), 3, 'pleasure'); + assert.equal(vesselEyeHeightM('36'), 3, 'sailing'); +}); + +test('text vessel types resolve through the same families', () => { + assert.equal(vesselEyeHeightM('Container Ship'), 30); + assert.equal(vesselEyeHeightM('Cruise ship'), 25); + assert.equal(vesselEyeHeightM('Ferry'), 4); + assert.equal(vesselEyeHeightM(99), 4, 'AIS Other'); + assert.equal(vesselEyeHeightM('90'), 4, 'AIS Other'); + assert.equal(vesselEyeHeightM('Other'), 4, 'a feed text type Other'); + assert.equal(vesselEyeHeightM('Yacht'), 3); +}); + +test('codes filed under OTHER that are not AIS Other keep the default', () => { + // Reserved (1-19), wing in ground (20-29), reserved (38-39), spare + // (56-57) and local (59) codes name no family. + for (const type of ['1', '19', '20', '29', '38', '39', '56', '57', '59']) + assert.equal(vesselEyeHeightM(type), null, type); +}); + +test('an unknown or missing type has no eye height of its own', () => { + for (const type of ['', '0', '255', 'Research vessel', null]) { + assert.equal(vesselEyeHeightM(type), null, String(type)); + } +}); + +test('vessel info faces the true heading and moves along course over ground', () => { + const info = vesselCockpitInfo({ + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + destination: ' KIEL ', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + lastPositionEpoch: 1_700_000_000, + missedRefreshes: 0, + }); + assert.equal(info.kind, 'vessel'); + assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID); + assert.equal(info.id, '211000000'); + assert.equal(info.typeLabel, 'CARGO'); + assert.equal(info.track, 90); + assert.equal(info.motionCourse, 95); + assert.equal(info.destination, 'KIEL'); + assert.equal(info.eyeHeightM, 30); + assert.equal(info.fixEpochMs, 1_700_000_000_000); + assert.ok(Math.abs(info.velocityMps - 12 * 0.514444) < 1e-9); + assert.equal(info.stale, false); + assert.equal(info.onGround, false); +}); + +test('a missing heading or course falls back to the other', () => { + const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 }; + assert.equal(vesselCockpitInfo({ ...base, course: 40 }).track, 40); + assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50); + const neither = vesselCockpitInfo(base); + assert.equal(neither.track, null); + assert.equal(neither.motionCourse, null); +}); + +test('the subject says whether its track is the heading or the course', () => { + const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 }; + assert.equal( + vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource, + 'heading', + ); + assert.equal( + vesselCockpitInfo({ ...base, course: 40 }).trackSource, + 'course', + ); + // A heading of 0 is due north, not a missing heading. + assert.equal( + vesselCockpitInfo({ ...base, heading: 0 }).trackSource, + 'heading', + ); + assert.equal(vesselCockpitInfo(base).trackSource, null); +}); + +test('a vessel without identity or position is not a subject', () => { + assert.equal(vesselCockpitInfo(null), null); + assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), null); + assert.equal(vesselCockpitInfo({ mmsi: '1', lat: 'x', lon: 1 }), null); +}); + +test('a vessel missing from recent refreshes reads as stale', () => { + const info = vesselCockpitInfo({ + mmsi: '1', + lat: 1, + lon: 1, + missedRefreshes: 2, + }); + assert.equal(info.stale, true); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d4d9ef796..4fd53c35c 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -1,5 +1,8 @@ import * as Cesium from 'cesium'; import { t, msg } from '../../i18n/index.js'; +import { vesselCockpitInfo } from './cockpitSubject.js'; +import { vesselTypeCss } from '../../data/vesselLabels.js'; +import { greatCircleMeters } from '../../geoDistance.js'; // ingestion.js is a portable export (package.json) and writes English labels // into state; translate the known ones here, pass anything else through. @@ -20,6 +23,7 @@ import { AIS_STATUS_REASON, REFRESH_MS, FOCUS_EVIDENCE_DEV, + VESSEL_LIFT_M, } from './policy.js'; export function createQueries({ @@ -346,6 +350,58 @@ export function createQueries({ return entries.slice(0, cap); }, + /** + * Vessels around a point for the Cockpit bridge radar, nearest first. + * The ship whose bridge the Cockpit occupies is left out. Distances are + * great-circle from the AIS positions, so no rendered visual is needed. + * @param {number} lat Latitude of the centre, degrees. + * @param {number} lon Longitude of the centre, degrees. + * @param {number} rangeM Max distance in metres. + * @param {number} [maxCount=60] Maximum entries to return. + * @returns {Array<{ id: string, name: string, latitude: number, + * longitude: number, course: number|null, heading: number|null, + * type: string, color: string, stale: boolean }>} Empty while the + * layer is off. + */ + getRadarContacts(lat, lon, rangeM, maxCount = 60) { + const records = state.records.all; + if (!state.feed.enabled || !Array.isArray(records) || !records.length) + return []; + if (!Number.isFinite(rangeM) || rangeM <= 0) return []; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60; + const center = { lat, lon }; + const subject = state.cockpitSubjectMmsi; + const finite = (value) => { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; + }; + + const entries = []; + for (const record of records) { + if (subject && String(record.mmsi) === subject) continue; + const distanceM = greatCircleMeters(center, { + lat: record.lat, + lon: record.lon, + }); + if (!(distanceM <= rangeM)) continue; + entries.push({ record, distanceM }); + } + entries.sort((a, b) => a.distanceM - b.distanceM); + return entries.slice(0, cap).map(({ record }) => ({ + id: String(record.mmsi), + name: String(record.name || record.mmsi), + latitude: record.lat, + longitude: record.lon, + course: finite(record.course), + heading: finite(record.heading), + type: String(record.type || ''), + color: vesselTypeCss(record.type), + stale: (record.missedRefreshes || 0) > 0, + })); + }, + /** * Get positions of all currently loaded vessels. * @param {number} [maxCount=800] Maximum entries to return. @@ -462,6 +518,52 @@ export function createQueries({ }; }, + /** + * Describe the selected vessel for the Cockpit bridge view. + * @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM` + * (the local geoid height the sprite datum uses) and `markHeightM` (the + * height the sprite is drawn at), or null when the layer is off or + * nothing is selected. + */ + getCockpitSubject() { + if (!state.feed.enabled) return null; + const info = vesselCockpitInfo(state.selectedRecord); + if (!info) return null; + const geoidN = components.tracking.currentGeoidN( + info.latitude, + info.longitude, + ); + return { + ...info, + seaSurfaceHeightM: vesselDatumHeightM(geoidN, 0), + markHeightM: vesselDatumHeightM(geoidN, VESSEL_LIFT_M), + }; + }, + + /** + * Mark the vessel whose bridge the Cockpit camera occupies, or release it. + * Its sprite, card and detection bracket stay hidden while it is held. + * @param {string|number|null} mmsi Subject MMSI, or null to release. + * @returns {boolean} Always true. + */ + setCockpitSubject(mmsi) { + const next = normalizeSelectionMmsi(mmsi); + if (state.cockpitSubjectMmsi === next) return true; + state.cockpitSubjectMmsi = next; + components.rendering.updateVisibility(true); + return true; + }, + + /** + * Return the camera to the selected vessel's map view, as a click on it does. + * @returns {boolean} True when a selected vessel took the camera. + */ + focusSelectedVessel() { + const record = state.selectedRecord; + if (!record || !state.feed.enabled) return false; + return components.selection.selectAndFocusVessel(record); + }, + /** * Return a subset of vessels for the universal detection overlay. * Deterministic stride sampling distributes selections evenly across the diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs new file mode 100644 index 000000000..9366e7e3a --- /dev/null +++ b/src/layers/vessels/radarContacts.test.mjs @@ -0,0 +1,118 @@ +// src/layers/vessels/radarContacts.test.mjs — the vessels the Cockpit bridge +// radar draws: within range, nearest first, without the ship it rides. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { vesselTypeCss } from '../../data/vesselLabels.js'; + +const noop = () => {}; +function services() { + return { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, + }; +} + +/** A vessel `northNm` nautical miles north of 54.5°N 10.2°E. */ +function record(mmsi, northNm, extra = {}) { + return { + mmsi, + name: `SHIP ${mmsi}`, + type: '70', + lat: 54.5 + northNm / 60, + lon: 10.2, + speed: 10, + course: 45, + heading: 40, + missedRefreshes: 0, + ...extra, + }; +} + +function layerWith(records, options = {}) { + const layer = createVesselLayer({ + source: { getSnapshot: noop }, + services: services(), + }); + layer.testing._setVesselStateForTest({ + viewer: {}, + records, + billboardCollection: { add: (entry) => ({ ...entry }), remove: noop }, + ...options, + }); + return layer; +} + +test('radar contacts are within range, nearest first and capped', () => { + const layer = layerWith([ + record('3', 5), + record('1', 1), + record('far', 20), + record('2', 3, { course: null, heading: '', missedRefreshes: 2 }), + ]); + const contacts = layer.getRadarContacts(54.5, 10.2, 6 * 1852, 60); + assert.deepEqual( + contacts.map((entry) => entry.id), + ['1', '2', '3'], + ); + assert.deepEqual(contacts[0], { + id: '1', + name: 'SHIP 1', + latitude: 54.5 + 1 / 60, + longitude: 10.2, + course: 45, + heading: 40, + type: '70', + color: vesselTypeCss('70'), + stale: false, + }); + assert.equal(contacts[1].course, null); + assert.equal(contacts[1].heading, null); + assert.equal(contacts[1].stale, true); + assert.deepEqual( + layer.getRadarContacts(54.5, 10.2, 6 * 1852, 2).map((entry) => entry.id), + ['1', '2'], + ); +}); + +test('the ship whose bridge the Cockpit rides is not its own contact', () => { + const layer = layerWith([record('own', 0), record('other', 2)]); + layer.setCockpitSubject('own'); + assert.deepEqual( + layer.getRadarContacts(54.5, 10.2, 6 * 1852).map((entry) => entry.id), + ['other'], + ); + layer.setCockpitSubject(null); + assert.equal(layer.getRadarContacts(54.5, 10.2, 6 * 1852).length, 2); +}); + +test('a disabled layer has no radar contacts', () => { + const layer = layerWith([record('1', 1)], { enabled: false }); + assert.deepEqual(layer.getRadarContacts(54.5, 10.2, 6 * 1852), []); +}); diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..2a00fca3c 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -26,6 +26,18 @@ export function normalizeVessel(row) { }; } +/** + * Note when a fix was first received (`fixReceivedAtMs`, this client's + * clock) and the newest fix in that refresh (`fixFeedEpoch`, the feed's + * clock): its age is then read on each clock separately, so neither a + * skewed client clock nor a re-served cached row hides how old it is. + * `receivedAtMs` stays the time the row was last seen, for retention. + */ +function stampFix(record, receivedAtMs, feedLatestEpoch) { + record.fixReceivedAtMs = receivedAtMs; + record.fixFeedEpoch = feedLatestEpoch; +} + function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -46,6 +58,18 @@ export class VesselRecords { effects, ) { const receivedAtMs = this.now(); + // The newest fix in the batch: the feed's own "now", so a fix's age can + // be read on the feed's clock rather than against this client's (see + // stampFix). + let feedLatestEpoch = null; + for (const row of rows) { + const epoch = finiteNumber(row?.last_position_epoch); + if ( + epoch !== null && + (feedLatestEpoch === null || epoch > feedLatestEpoch) + ) + feedLatestEpoch = epoch; + } for (const record of this.unkeyed) effects.remove(record, false); this.unkeyed = []; const seen = new Set(); @@ -53,6 +77,7 @@ export class VesselRecords { const next = normalizeVessel(row); if (!next) continue; next.receivedAtMs = receivedAtMs; + stampFix(next, receivedAtMs, feedLatestEpoch); if (!next.mmsi) { effects.add(next); this.unkeyed.push(next); @@ -63,6 +88,12 @@ export class VesselRecords { const record = this.byMmsi.get(next.mmsi); if (record) { const before = effects.beforeUpdate(record); + // A feed re-serving a cached row repeats its fix: the fix keeps the + // time it was first received, so it goes on ageing. + if (next.lastPositionEpoch !== record.lastPositionEpoch) { + record.fixReceivedAtMs = next.fixReceivedAtMs; + record.fixFeedEpoch = next.fixFeedEpoch; + } record.reference = next.reference; record.receivedAtMs = next.receivedAtMs; record.lat = next.lat; diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs index d3e19dcd6..9efa91130 100644 --- a/src/layers/vessels/records.test.mjs +++ b/src/layers/vessels/records.test.mjs @@ -111,3 +111,33 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown assert.equal(probe.store.all.length, 1); assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null); }); + +test("each fix keeps when it was first received and its refresh's newest fix", () => { + const probe = setup(); + probe.reconcile([ + row('111', { last_position_epoch: 1_800_000_000 }), + row('222', { last_position_epoch: 1_800_000_030 }), + row('333'), + ]); + for (const mmsi of ['111', '222', '333']) { + const record = probe.store.byMmsi.get(mmsi); + assert.equal(record.fixFeedEpoch, 1_800_000_030, mmsi); + assert.equal(record.fixReceivedAtMs, 1000, mmsi); + } + // A new fix is stamped afresh. + probe.advance(60_000); + probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]); + const record = probe.store.byMmsi.get('111'); + assert.equal(record.fixFeedEpoch, 1_800_000_050); + assert.equal(record.fixReceivedAtMs, 61_000); + // The same fix re-served by later polls keeps its first receipt, while + // the row itself counts as seen for retention. + probe.advance(30_000); + probe.reconcile([ + row('111', { last_position_epoch: 1_800_000_050 }), + row('222', { last_position_epoch: 1_800_000_090 }), + ]); + assert.equal(record.fixReceivedAtMs, 61_000); + assert.equal(record.fixFeedEpoch, 1_800_000_050); + assert.equal(record.receivedAtMs, 91_000); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 328dd4aee..aefc43bed 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -286,7 +286,9 @@ export function createRendering({ const visual = getVisual(record); const visible = isVisible(visual.surfacePosition, occluder); if (visual.billboard) { - visual.billboard.show = visible; + // The bridge camera sits on this sprite; showing it would fill the view. + visual.billboard.show = + visible && record.mmsi !== state.cockpitSubjectMmsi; if (visible && doRotations && scene) { const rot = screenProjectedRotation( scene, @@ -422,7 +424,11 @@ export function createRendering({ const viewer = state.viewer; const scene = viewer?.scene; const selected = state.selectedRecord; - const entries = selected + // From the bridge, the selected vessel is the viewer; its card would be + // pinned to the camera position. + const selectedCarded = + selected && selected.mmsi !== state.cockpitSubjectMmsi; + const entries = selectedCarded ? [components.cards.buildSelectedVesselCard(selected)] : []; const maxLabels = labelRowLimit(); @@ -458,7 +464,7 @@ export function createRendering({ // Greedy min-separation pass over the priority-ranked cell winners: the // selected card's anchor seeds the accepted set so ambient cards keep clear. const accepted = []; - if (selected) { + if (selectedCarded) { const screen = Cesium.SceneTransforms.worldToWindowCoordinates( scene, getVisual(selected).billboard?.position || getVisual(selected).position, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 96c5df6eb..4b801b9c9 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -37,6 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; + // The bridge view owns the selection: a click on open water must not + // deselect the vessel the camera is riding. + if (state.cockpitSubjectMmsi) return; const picked = pickContact ? pickContact(viewer.scene, click.position) : viewer.scene.pick(click.position); diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 3ccaf08d9..3cd769c3e 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -72,6 +72,8 @@ export function createVesselState({ source, services }) { activeFocusCount: 0, activeLabelCount: 0, selectedRecord: null, + /** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */ + cockpitSubjectMmsi: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ trail: null, /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */ diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c1effdd7c..13303410f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -361,6 +361,7 @@ export class StyleManager extends ShellFacade { services: { flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, + aisLiveVesselsLayer: services.aisLiveVesselsLayer, isTr3b: services.isTr3b, toggleTr3b: services.toggleTr3b, militaryAwarenessLayer: services.militaryAwarenessLayer, @@ -400,6 +401,8 @@ export class StyleManager extends ShellFacade { useCockpitStore(applicationPinia()).publishVision(view), signals: (rows) => useCockpitStore(applicationPinia()).publishSignals(rows), + radar: (view) => + useCockpitStore(applicationPinia()).publishRadar(view), utilityLayout: (layout) => useCockpitStore(applicationPinia()).publishUtilityLayout(layout), }, diff --git a/src/ui/cockpitBodyClasses.test.mjs b/src/ui/cockpitBodyClasses.test.mjs index adf897ce8..8636c83e8 100644 --- a/src/ui/cockpitBodyClasses.test.mjs +++ b/src/ui/cockpitBodyClasses.test.mjs @@ -52,7 +52,7 @@ function cockpitBodyClasses() { test('the Cockpit adds its subject classes to ', () => { const classes = cockpitBodyClasses(); - for (const name of ['cockpit-mode']) { + for (const name of ['cockpit-mode', 'cockpit-bridge']) { assert.ok(classes.includes(name), `${name} is not added to `); } }); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs new file mode 100644 index 000000000..3da1d72e5 --- /dev/null +++ b/src/ui/cockpitBridge.test.mjs @@ -0,0 +1,1319 @@ +// src/ui/cockpitBridge.test.mjs — a selected vessel's bridge view: entry, +// the vessel layer's sprite hold, the bridge readouts, refused contact +// navigation and the ways the bridge ends, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { CockpitViewController } from './cockpitController.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; +import ContextView from './views/ContextView.vue'; +import * as Cesium from 'cesium'; +import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; +import { VesselRecords } from '../layers/vessels/records.js'; + +const RECORD = Object.freeze({ + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + destination: 'KIEL', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + lastPositionEpoch: null, + missedRefreshes: 0, +}); + +/** A ship 2 NM north of RECORD, inside the radar's default range. */ +const NEIGHBOUR = Object.freeze({ + id: '211000001', + name: 'BALTIC TERN', + latitude: RECORD.lat + 2 / 60, + longitude: RECORD.lon, + course: 270, + heading: 270, + type: '30', + color: '#ffd166', + stale: false, +}); + +function vesselLayer(record) { + const calls = []; + const radarQueries = []; + let selected = record; + let subject = null; + let enabled = true; + return { + calls, + radarQueries, + deselect() { + selected = null; + }, + /** Live AIS Vessels turned off: like the layer, it then has no subject. */ + disable() { + enabled = false; + }, + getCockpitSubject: () => + enabled && selected + ? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 } + : null, + setCockpitSubject(mmsi) { + calls.push(['subject', mmsi]); + subject = mmsi; + return true; + }, + // Like the layer, leaves out the ship whose bridge the Cockpit rides. + getRadarContacts(lat, lon, rangeM, maxCount) { + radarQueries.push({ lat, lon, rangeM, maxCount }); + const own = { + ...NEIGHBOUR, + id: record.mmsi, + latitude: record.lat, + longitude: record.lon, + }; + return [own, NEIGHBOUR].filter((contact) => contact.id !== subject); + }, + focusSelectedVessel() { + calls.push(['focus']); + return true; + }, + }; +} + +function fixture( + t, + { bridgeAllowed = true, record = RECORD, aircraftInfo = null } = {}, +) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(CockpitView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const listeners = () => ({ addEventListener: () => () => {} }); + const aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000); + const aircraft = { + position: { + getValue: (time, result) => + Cesium.Cartesian3.clone(aircraftPosition, result), + }, + show: true, + gevTrackedId: aircraftInfo ? `flights:${aircraftInfo.icao24}` : '', + }; + const views = []; + // Each placed camera: where it is and where it looks. + const shots = []; + const viewer = { + trackedEntity: undefined, + clock: { currentTime: null }, + camera: { + cancelFlight() {}, + setView({ destination, orientation }) { + views.push(Cesium.Cartesian3.clone(destination)); + shots.push({ + destination: Cesium.Cartesian3.clone(destination), + direction: Cesium.Cartesian3.clone(orientation.direction), + up: Cesium.Cartesian3.clone(orientation.up), + }); + }, + }, + entities: { contains: (candidate) => candidate === aircraft }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + const vessels = vesselLayer(record); + const navigations = []; + // Turning Live Flights off clears its tracking, as the layer does. + let flightsEnabled = true; + const disableFlights = () => { + flightsEnabled = false; + }; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { + getTrackedInfo: () => (flightsEnabled ? aircraftInfo : null), + }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + aisLiveVesselsLayer: vessels, + militaryAwarenessLayer: { + navigateNext: () => navigations.push('next') > 0, + navigatePrevious: () => navigations.push('previous') > 0, + }, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + // Open water: no rendered floor above the sea surface. + cachedGroundFloor: () => null, + GROUND_FLOOR_LIFT_M: 0, + }, + // The aircraft gate stays shut: the bridge must not depend on Contacts. + isEntryAllowed: () => Boolean(aircraftInfo), + isBridgeEntryAllowed: () => bridgeAllowed, + publish: { + presence: (view) => store.publishPresence(view), + instruments: (view) => store.publishInstruments(view), + route: (view) => store.publishRoute(view), + radar: (view) => store.publishRadar(view), + }, + }); + // The briefing and signals have their own tests; the bridge keeps only + // what it shows and owns. + Object.assign(cockpit, { + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal() {}, + scheduleContextLayout() {}, + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + }); + // An aircraft session's Contact card and flight plan have their own tests. + if (aircraftInfo) + Object.assign(cockpit, { updateContext() {}, updateRoute() {} }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove('cockpit-mode'); + }); + const $ = (selector) => document.querySelector(selector); + return { + $, + cockpit, + viewer, + vessels, + aircraft, + disableFlights, + navigations, + views, + shots, + store, + }; +} + +/** Replace both clocks the bridge reads; returns a stepper and a restore. */ +function manualClock(t, startMs) { + const originalNow = performance.now; + const originalDateNow = Date.now; + let nowMs = startMs; + performance.now = () => nowMs; + Date.now = () => nowMs; + t.after(() => { + performance.now = originalNow; + Date.now = originalDateNow; + }); + return { + advance(ms) { + nowMs += ms; + }, + set(ms) { + nowMs = ms; + }, + }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); +} + +test('a selected vessel offers a BRIDGE entry without Contacts', async (t) => { + const { $, cockpit } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + assert.equal(entry.hidden, false); + assert.match(entry.textContent, /directions_boat/); + assert.match(entry.textContent, /BRIDGE/); + assert.equal($('#tr3b-toggle').hidden, true, 'TR-3B stays aircraft-only'); +}); + +test('a shut bridge gate keeps the entry hidden', async (t) => { + const { $, cockpit } = fixture(t, { bridgeAllowed: false }); + cockpit.syncEntry(); + await nextTick(); + assert.equal($('#cockpit-entry').hidden, true); + assert.equal(cockpit.enter(), false); +}); + +test('entering the bridge hides the vessel and shows bridge readouts', async (t) => { + const { $, cockpit, vessels } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'vessel'); + assert.deepEqual(vessels.calls, [['subject', RECORD.mmsi]]); + assert.equal(document.body.classList.contains('cockpit-mode'), true); + + assert.equal( + $('#cockpit-hud').getAttribute('aria-label'), + 'Ship bridge view', + ); + assert.match($('.cockpit-kicker').textContent, /BRIDGE VIEW/); + assert.equal($('#cockpit-callsign').textContent, 'NORDIC STAR'); + assert.match($('#cockpit-aircraft-meta').textContent, /CARGO · LIVE AIS/); + assert.match($('#cockpit-aircraft-meta').textContent, /EYE 30/); + assert.equal($('#cockpit-altitude-rim').hidden, true); + assert.equal($('#cockpit-altitude-value'), null); + assert.ok($('#cockpit-course-value'), 'course over ground replaces altitude'); + assert.match($('#map-view-switch').textContent, /EXIT BRIDGE/); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Exit bridge view', + ); + + const route = $('#cockpit-route'); + assert.equal(route.hidden, false); + assert.match(route.textContent, /REPORTED VOYAGE/); + assert.equal($('#cockpit-route-to').textContent, 'KIEL'); + assert.equal($('#cockpit-route-from'), null, 'AIS reports no origin'); + assert.equal($('#cockpit-route-direction').hidden, true); +}); + +test('entering the bridge names Live AIS Vessels as the ridden layer', async (t) => { + const { cockpit } = fixture(t); + const rides = []; + cockpit.onRide = (layerId) => rides.push(layerId); + assert.equal(cockpit.enter(), true); + await settle(); + assert.deepEqual(rides, ['ais-live-vessels']); +}); + +test('exiting the bridge releases the vessel and returns to its map view', async (t) => { + const { $, cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + $('#map-view-switch').click(); + await settle(); + assert.equal(cockpit.active, false); + assert.equal(cockpit.subjectKind, null); + assert.deepEqual(vessels.calls, [ + ['subject', RECORD.mmsi], + ['subject', null], + ['focus'], + ]); + assert.match($('#map-view-switch').textContent, /EXIT COCKPIT/); +}); + +test('the bridge radar shows nearby ships without its own and steps its range', async (t) => { + const { $, cockpit, vessels, store } = fixture(t); + assert.equal(store.radar, null, 'no radar before the bridge'); + cockpit.enter(); + await settle(); + const radar = store.radar; + assert.ok(radar, 'entering the bridge publishes a radar'); + assert.equal(radar.rangeNm, 3); + assert.equal(radar.rangeLabel, '3 NM'); + assert.deepEqual(radar.ringsNm, [0.75, 1.5, 2.25, 3]); + assert.deepEqual( + radar.contacts.map((contact) => contact.id), + [NEIGHBOUR.id], + 'the cockpit subject is not its own contact', + ); + const [neighbour] = radar.contacts; + assert.ok(Math.abs(neighbour.distanceNm - 2) < 0.02); + // Heading 090: a ship to the north is on the port beam, heading west. + assert.ok(neighbour.x < -0.3 && Math.abs(neighbour.y) < 0.02); + assert.equal(neighbour.relativeCourseDeg, -180); + assert.equal(radar.ownRelativeCourseDeg, 5, 'COG 095 from heading 090'); + assert.equal(vessels.radarQueries.at(-1).rangeM, 3 * 1852); + + assert.equal($('#cockpit-radar').hidden, false); + assert.equal($('#cockpit-radar-range').textContent.trim(), '3 NM'); + assert.equal($('.cockpit-radar-count').textContent.trim(), '1 CONTACT'); + assert.equal(document.querySelectorAll('.cockpit-radar-contact').length, 1); + + $('#cockpit-radar-range').click(); + await settle(); + assert.equal(store.radar.rangeNm, 6); + assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852); + assert.equal($('#cockpit-radar-range').textContent.trim(), '6 NM'); + assert.match( + $('#cockpit-radar-range').getAttribute('aria-label'), + /Radar range 6 NM/, + ); + + cockpit.exit(); + await settle(); + assert.equal(store.radar, null, 'the radar ends with the bridge'); + assert.ok(!$('#cockpit-radar') || $('#cockpit-radar').hidden); +}); + +test('contact navigation does not jump ship from the bridge', async (t) => { + const { cockpit, navigations } = fixture(t); + cockpit.enter(); + await settle(); + assert.equal(cockpit.navigateContext(1, { origin: 'user' }), false); + assert.equal(cockpit.navigateContext(-1, { origin: 'user' }), false); + assert.deepEqual(navigations, []); + assert.equal(cockpit.subjectKind, 'vessel'); +}); + +test('tracking an aircraft from the bridge ends it without refocusing the vessel', async (t) => { + const { cockpit, viewer, vessels, aircraft } = fixture(t); + cockpit.enter(); + await settle(); + viewer.trackedEntity = aircraft; + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking'); + assert.equal(aircraft.show, true, 'the aircraft was never adopted'); + assert.deepEqual(vessels.calls, [ + ['subject', RECORD.mmsi], + ['subject', null], + ]); +}); + +test('a vessel that leaves the selection ends the bridge', async (t) => { + const { cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + vessels.deselect(); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(vessels.calls.at(-1), ['subject', null]); +}); + +test('turning off Live AIS Vessels ends the bridge', async (t) => { + const { cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + assert.equal(cockpit.active, true); + vessels.disable(); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(vessels.calls.at(-1), ['subject', null]); +}); + +test("turning off the ridden aircraft's layer ends the cockpit", async (t) => { + const aircraftInfo = { + icao24: 'abc123', + callsign: 'TEST1', + latitude: 54.5, + longitude: 10.2, + altitudeM: 3000, + velocityMps: 200, + track: 45, + onGround: false, + }; + const { cockpit, viewer, aircraft, disableFlights } = fixture(t, { + aircraftInfo, + }); + viewer.trackedEntity = aircraft; + assert.equal(cockpit.enter({ kind: 'aircraft' }), true); + await settle(); + cockpit.update(); + assert.equal(cockpit.active, true); + disableFlights(); + cockpit.update(); + assert.equal(cockpit.active, false); +}); + +test('C enters the selected vessel bridge and aircraft-only entry refuses it', async (t) => { + const { cockpit } = fixture(t); + assert.equal(cockpit.enter({ kind: 'aircraft' }), false); + const event = { + key: 'c', + target: document.body, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopImmediatePropagation() {}, + }; + cockpit.onKeyDown(event); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'vessel'); + assert.equal(event.defaultPrevented, true); +}); + +test('a slow-frame bridge keeps pace with its ship', async (t) => { + // One rendered frame a second, as software rendering manages. The bridge + // must still cover the ship's distance, not a capped fraction of it. + const startMs = 1_800_000_000_000; + const clock = manualClock(t, startMs); + const record = { ...RECORD, lastPositionEpoch: startMs / 1000 }; + const { cockpit, views } = fixture(t, { record }); + cockpit.enter(); + await settle(); + for (let frame = 0; frame <= 20; frame += 1) { + cockpit.update(); + clock.advance(1000); + } + assert.ok( + views.length >= 20, + `expected a view per frame, got ${views.length}`, + ); + const travelledM = Cesium.Cartesian3.distance(views[0], views.at(-1)); + const expectedM = (views.length - 1) * RECORD.speed * 0.514444; + assert.ok( + Math.abs(travelledM - expectedM) < expectedM * 0.05, + `travelled ${travelledM.toFixed(1)} m, expected ~${expectedM.toFixed(1)} m`, + ); +}); + +/** Press a key through the Cockpit's keydown handler. */ +function press( + cockpit, + key, + { shiftKey = false, repeat = false, target = document.body } = {}, +) { + const event = { + key, + shiftKey, + repeat, + target, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopImmediatePropagation() {}, + }; + cockpit.onKeyDown(event); + return event; +} + +/** A placed camera's view heading and pitch in its local east-north-up frame. */ +function viewAngles({ destination, direction }) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(destination); + const toLocal = Cesium.Matrix4.inverseTransformation( + enu, + new Cesium.Matrix4(), + ); + const local = Cesium.Matrix4.multiplyByPointAsVector( + toLocal, + direction, + new Cesium.Cartesian3(), + ); + const headingDeg = + ((Cesium.Math.toDegrees(Math.atan2(local.x, local.y)) % 360) + 360) % 360; + const pitchDeg = Cesium.Math.toDegrees( + Math.asin(Math.max(-1, Math.min(1, local.z))), + ); + return { headingDeg, pitchDeg }; +} + +/** Run `count` camera updates 50 ms apart; returns the last view. */ +function frames(cockpit, clock, shots, count = 20) { + for (let frame = 0; frame < count; frame += 1) { + clock.advance(50); + cockpit.update(); + } + return viewAngles(shots.at(-1)); +} + +const near = (actual, expected, label, tolerance = 0.2) => + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: ${actual.toFixed(3)} not within ${tolerance} of ${expected}`, + ); + +const START_MS = 1_800_000_000_000; + +async function bridgeAt(t, clock) { + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const scene = fixture(t, { record }); + scene.cockpit.enter(); + await settle(); + scene.cockpit.update(); + return scene; +} + +test('arrows turn the bridge view without moving the eye off its path', async (t) => { + const clock = manualClock(t, START_MS); + // A bridge left alone, for the eye's path. + const still = await bridgeAt(t, clock); + frames(still.cockpit, clock, still.shots, 30); + const stillPath = still.shots.map((shot) => shot.destination); + still.cockpit.stop(); + + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + let view = viewAngles(shots.at(-1)); + near(view.headingDeg, RECORD.heading, 'starts over the bow'); + near(view.pitchDeg, -2, 'at the rest pitch'); + assert.equal(store.instruments.look.offset, false); + + const event = press(cockpit, 'ArrowRight', { shiftKey: true }); + assert.equal(event.defaultPrevented, true); + press(cockpit, 'ArrowRight', { shiftKey: true }); + // Partway through the ease the view is between the bow and the beam. + clock.advance(50); + cockpit.update(); + const midway = viewAngles(shots.at(-1)).headingDeg; + assert.ok(midway > 100 && midway < 175, `midway heading ${midway}`); + view = frames(cockpit, clock, shots, 9); + near(view.headingDeg, RECORD.heading + 90, 'starboard beam'); + press(cockpit, 'ArrowUp'); + press(cockpit, 'ArrowUp'); + press(cockpit, 'ArrowUp'); + view = frames(cockpit, clock, shots, 20); + near(view.pitchDeg, 10, 'pitched up'); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 90, pitchDeg: 10 }); + + // The eye followed exactly the path of the bridge left alone. + assert.equal(shots.length, stillPath.length); + shots.forEach((shot, index) => + assert.ok( + Cesium.Cartesian3.distance(shot.destination, stillPath[index]) < 1e-6, + `frame ${index} moved the eye`, + ), + ); + + await settle(); + assert.equal(store.instruments.look.offset, true); + assert.equal(store.instruments.look.yawDeg, 90); + assert.equal($('.cockpit-look').hidden, false); + assert.equal( + $('#cockpit-look-readout').textContent, + 'LOOK STBD 090° · UP 10°', + ); + assert.ok( + $('.cockpit-radar-look'), + 'the radar draws the look line while the view is turned', + ); + assert.match($('.cockpit-help').textContent, /ARROWS LOOK · HOME RESET/); + // The heading readout keeps the ship's heading. + assert.equal(store.instruments.heading.text, '090'); +}); + +test('the bridge view pitches no further than straight up or down', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store } = await bridgeAt(t, clock); + for (let index = 0; index < 4; index += 1) + press(cockpit, 'ArrowUp', { shiftKey: true }); + assert.equal(cockpit.lookTarget.pitchDeg, 90); + let view = frames(cockpit, clock, shots); + near(view.pitchDeg, 90, 'straight up', 0.5); + const up = viewAngles({ + destination: shots.at(-1).destination, + direction: shots.at(-1).up, + }); + near(up.headingDeg, (RECORD.heading + 180) % 360, 'up stays valid', 1); + + for (let index = 0; index < 5; index += 1) + press(cockpit, 'ArrowDown', { shiftKey: true }); + assert.equal(cockpit.lookTarget.pitchDeg, -90); + view = frames(cockpit, clock, shots); + near(view.pitchDeg, -90, 'straight down', 0.5); + await settle(); + assert.equal(store.instruments.look.readout, 'LOOK DOWN 90°'); +}); + +test('the heading tape follows the bridge look; bow and stern marks stay on the ship', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + const tape = async () => { + frames(cockpit, clock, shots, 40); + await settle(); + const { compass, compassMarkers, heading } = store.instruments; + return { + centre: compass.find((division) => division.active).label, + heading: heading.value, + ...compassMarkers, + }; + }; + // Over the bow: the tape centres on the ship's heading, east. + let reading = await tape(); + assert.equal(reading.centre, 'E'); + assert.equal(reading.prograde, 0); + assert.equal(reading.retrograde, null, 'the stern is off the tape'); + assert.equal($('.cockpit-compass-retrograde'), null); + const bow = $('.cockpit-compass-prograde'); + assert.equal(bow.getAttribute('aria-hidden'), 'true'); + assert.equal(bow.style.getPropertyValue('--slot'), '0'); + // Turned to port onto north: the tape follows the camera, the HDG readout + // keeps the ship's heading and both marks sit at their relative bearings. + press(cockpit, 'ArrowLeft', { shiftKey: true }); + press(cockpit, 'ArrowLeft', { shiftKey: true }); + reading = await tape(); + assert.equal(reading.centre, 'N'); + near(reading.heading, RECORD.heading, 'the HDG readout', 1e-6); + near(reading.prograde, 3, 'the bow, 90° to starboard', 1e-6); + near(reading.retrograde, -3, 'the stern, 90° to port', 1e-6); + assert.equal( + $('.cockpit-compass-retrograde').getAttribute('aria-hidden'), + 'true', + ); + // Further round, onto 315°: the bow leaves the tape, the stern comes + // under W, 45° to port. + press(cockpit, 'ArrowLeft', { shiftKey: true }); + reading = await tape(); + assert.equal(reading.prograde, null); + near(reading.retrograde, -1.5, 'the stern, under W', 1e-6); + const west = store.instruments.compass.find(({ label }) => label === 'W'); + near(west.slot, reading.retrograde, 'W over the stern mark', 1e-6); + assert.equal($('.cockpit-compass-prograde'), null); + assert.equal( + $('.cockpit-compass-retrograde').style.getPropertyValue('--slot'), + String(reading.retrograde), + ); +}); + +test('a held arrow keeps turning the bridge view', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit } = await bridgeAt(t, clock); + press(cockpit, 'ArrowLeft'); + for (let index = 0; index < 5; index += 1) { + const event = press(cockpit, 'ArrowLeft', { repeat: true }); + assert.equal(event.defaultPrevented, true); + } + assert.equal(cockpit.lookTarget.yawDeg, -30); + // A held Home or C still acts only once. + press(cockpit, 'Home', { repeat: true }); + assert.equal(cockpit.lookTarget.yawDeg, -30); + press(cockpit, 'c', { repeat: true }); + assert.equal(cockpit.active, true); +}); + +test('Home, 0 and LOOK AHEAD bring the bridge view back over the bow', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + const turnAway = () => { + press(cockpit, 'ArrowLeft', { shiftKey: true }); + press(cockpit, 'ArrowDown'); + frames(cockpit, clock, shots); + }; + const resets = [ + () => press(cockpit, 'Home'), + () => press(cockpit, '0'), + () => $('#cockpit-look-ahead').click(), + ]; + for (const reset of resets) { + turnAway(); + await settle(); + assert.equal($('.cockpit-look').hidden, false); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead: turn the bridge view back to the bow', + ); + reset(); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + // It eases back rather than snapping. + clock.advance(50); + cockpit.update(); + const easing = viewAngles(shots.at(-1)).headingDeg; + assert.ok(easing > 46 && easing < 89, `easing heading ${easing}`); + const view = frames(cockpit, clock, shots); + near(view.headingDeg, RECORD.heading, 'back over the bow'); + near(view.pitchDeg, -2, 'back at the rest pitch'); + await settle(); + assert.equal(store.instruments.look.offset, false); + assert.equal($('.cockpit-look').hidden, true); + assert.equal($('.cockpit-radar-look'), null); + } +}); + +test('arrows keep their meaning in sliders, fields and open utilities', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit } = await bridgeAt(t, clock); + const slider = document.body.appendChild(document.createElement('div')); + slider.setAttribute('role', 'slider'); + const thumb = slider.appendChild(document.createElement('span')); + const field = document.body.appendChild(document.createElement('input')); + for (const target of [slider, thumb, field]) { + for (const key of ['ArrowLeft', 'ArrowUp', 'Home', '0']) { + const event = press(cockpit, key, { target }); + assert.equal( + event.defaultPrevented, + false, + `${key} in ${target.tagName}`, + ); + } + } + cockpit.isUtilityDisclosureOpen = () => true; + assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + + // A plain HUD button, where focus sits after entry, still turns the view. + cockpit.isUtilityDisclosureOpen = () => false; + const exit = document.getElementById('map-view-switch'); + const event = press(cockpit, 'ArrowRight', { target: exit }); + assert.equal(event.defaultPrevented, true); + assert.equal(cockpit.lookTarget.yawDeg, 5); +}); + +test('the bridge view opens over the bow on every entry', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store } = await bridgeAt(t, clock); + press(cockpit, 'ArrowRight', { shiftKey: true }); + press(cockpit, 'ArrowUp'); + frames(cockpit, clock, shots, 5); + cockpit.exit(); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + assert.deepEqual(cockpit.lookView, { yawDeg: 0, pitchDeg: -2 }); + assert.equal(store.instruments.look.offset, false); + assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false); + + cockpit.enter(); + await settle(); + clock.advance(50); + cockpit.update(); + const view = viewAngles(shots.at(-1)); + near(view.headingDeg, RECORD.heading, 're-entry looks over the bow'); + near(view.pitchDeg, -2, 're-entry at the rest pitch'); +}); + +test('arrows do nothing new in an aircraft cockpit', async (t) => { + const clock = manualClock(t, START_MS); + const aircraftInfo = { + icao24: 'abc123', + callsign: 'TEST1', + latitude: 54.5, + longitude: 10.2, + altitudeM: 3000, + velocityMps: 200, + track: 45, + onGround: false, + }; + const { cockpit, viewer, aircraft, shots, store } = fixture(t, { + aircraftInfo, + }); + viewer.trackedEntity = aircraft; + assert.equal(cockpit.enter({ kind: 'aircraft' }), true); + await settle(); + assert.equal(cockpit.subjectKind, 'aircraft'); + const before = frames(cockpit, clock, shots, 3); + near(before.headingDeg, 45, 'the aircraft looks along its track'); + const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', '0']; + for (const key of keys) { + for (const shiftKey of [false, true]) { + const event = press(cockpit, key, { shiftKey }); + assert.equal(event.defaultPrevented, false, `${key} in an aircraft`); + } + } + const held = press(cockpit, 'ArrowLeft', { repeat: true }); + assert.equal(held.defaultPrevented, false); + const after = frames(cockpit, clock, shots, 3); + // The aircraft flies on, so its local frame shifts by a hair. + near(after.headingDeg, before.headingDeg, 'the aircraft heading', 1e-6); + near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + assert.equal(store.instruments.look.offset, false); + assert.equal(document.querySelector('.cockpit-look'), null); + // The tape stays on the aircraft's heading, with no bow or stern marks. + const centre = store.instruments.compass.find( + (division) => division.slot === 0, + ); + assert.equal(centre.label, '060'); + assert.deepEqual(store.instruments.compassMarkers, { + prograde: null, + retrograde: null, + }); + assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.doesNotMatch( + document.querySelector('.cockpit-help').textContent, + /ARROWS LOOK/, + ); +}); + +/** The eye's ellipsoidal height in a placed camera, metres. */ +const eyeHeightM = (shot) => + Cesium.Cartographic.fromCartesian(shot.destination).height; + +test('a bridge on a river above sea level rides the water, not the geoid', async (t) => { + const clock = manualClock(t, START_MS); + // Sea level, as every ship is floated today. + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const river = fixture(t, { record }); + const probed = []; + // The ship's own model reads 40 m; the ring finds the river at 27 m and + // a quay at 33 m. + river.viewer.scene.sampleHeightMostDetailed = async (positions) => { + probed.push(positions.length); + return positions.map((position, index) => + Object.assign(position, { + height: index === 0 ? 40 : index % 2 ? 27 : 33, + }), + ); + }; + river.cockpit.enter(); + await settle(); + river.cockpit.update(); + await settle(); + frames(river.cockpit, clock, river.shots, 60); + near( + eyeHeightM(river.shots.at(-1)) - seaEyeM, + 27, + 'raised by the river', + 0.5, + ); + assert.equal(probed[0], 9, 'the ship and eight probes around it'); + const reads = probed.length; + frames(river.cockpit, clock, river.shots, 20); + await settle(); + assert.equal(probed.length, reads, 'no new read inside five seconds'); + frames(river.cockpit, clock, river.shots, 80); + await settle(); + assert.ok(probed.length > reads, 'a fresh read every five seconds'); +}); + +test('a bridge at sea keeps the geoid when the probes find no raised water', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const open = fixture(t, { record }); + // Swell and tiles a metre off the geoid, and a probe that found nothing. + open.viewer.scene.sampleHeightMostDetailed = async (positions) => + positions.map((position, index) => + Object.assign(position, { height: index === 3 ? undefined : 1 }), + ); + open.cockpit.enter(); + await settle(); + open.cockpit.update(); + await settle(); + frames(open.cockpit, clock, open.shots, 60); + near(eyeHeightM(open.shots.at(-1)), seaEyeM, 'still on the geoid', 0.5); +}); + +test('a bridge rides the river at once from the tiles already loaded', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const river = fixture(t, { record }); + // The full-detail read is still loading tiles; the loaded ones already + // show the river at 27 m beside a quay at 33 m. + river.viewer.scene.sampleHeightMostDetailed = () => new Promise(() => {}); + let index = 0; + river.viewer.scene.sampleHeight = () => (index++ % 2 ? 27 : 33); + river.cockpit.enter(); + await settle(); + river.cockpit.update(); + frames(river.cockpit, clock, river.shots, 60); + near( + eyeHeightM(river.shots.at(-1)) - seaEyeM, + 27, + 'on the river before the detailed read', + 0.5, + ); +}); + +/** + * A scene that draws a world point at (400, 300 − its height in metres): a + * mark's height above the ellipsoid moves it on screen. + */ +function heightSensitiveProjection(viewer, { x = 400 } = {}) { + viewer.scene.cartesianToCanvasCoordinates = (position, result) => { + const { height } = Cesium.Cartographic.fromCartesian(position); + return Object.assign(result, { x, y: 300 - height }); + }; +} + +test('a vessel double-click aims at the sprite as drawn, on the geoid', async (t) => { + const { cockpit, viewer, store } = fixture(t); + // The Baltic geoid sits ~35 m above the ellipsoid; the sprite 3 m above it. + const subject = cockpit.services.aisLiveVesselsLayer.getCockpitSubject; + cockpit.services.aisLiveVesselsLayer.getCockpitSubject = () => ({ + ...subject(), + seaSurfaceHeightM: 35, + markHeightM: 38, + }); + heightSensitiveProjection(viewer); + // Where an ellipsoid-height projection would put it: no hit. + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 262 }), true); + await settle(); + assert.equal(store.presence.active, true); +}); + +test('a vessel double-click lands even when the first click moved the ship', async (t) => { + const { cockpit, viewer, store } = fixture(t); + heightSensitiveProjection(viewer); + // The first press finds the ship at (400, 300); selecting it again + // re-centres the camera, so by the double-click it is drawn at (600, 300). + cockpit.onScenePointerDown(1000); + heightSensitiveProjection(viewer, { x: 600 }); + // Too late to count as one gesture. + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1800), false); + cockpit.onScenePointerDown(2000); + heightSensitiveProjection(viewer, { x: 800 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 600, y: 300 }, 2300), true); + await settle(); + assert.equal(store.presence.active, true); +}); + +test('re-entering a ship reads its water afresh, never the last session height', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + const scene = ship.viewer.scene; + // First visit: on a river 27 m up. + scene.sampleHeightMostDetailed = async (positions) => + positions.map((position) => Object.assign(position, { height: 27 })); + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5); + ship.cockpit.exit(); + await settle(); + // Back on the same ship, now at sea; the detailed read is slow, the + // loaded tiles already show the sea. + let late; + scene.sampleHeightMostDetailed = () => + new Promise((resolve) => { + late = resolve; + }); + scene.sampleHeight = () => 0.5; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'at sea, not on the river', 0.5); + // A read from before the exit, landing now, changes nothing. + ship.cockpit.exit(); + ship.cockpit.enter(); + await settle(); + late?.([{ height: 27 }]); + await settle(); + ship.cockpit.update(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'the late read is dropped', 0.5); +}); + +test('a failed water read keeps the sea and tries again', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + let reads = 0; + ship.viewer.scene.sampleHeightMostDetailed = async () => { + reads += 1; + throw new Error('tiles unavailable'); + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'still on the sea', 0.5); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + assert.ok(reads >= 2, `retried: ${reads} reads`); +}); + +test('the water is read where the ship is now, not at its last report', async (t) => { + const clock = manualClock(t, START_MS); + // Reported a minute ago at 12 kn on course 095: since then ~370 m east. + const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 }; + const ship = fixture(t, { record }); + const centres = []; + ship.viewer.scene.sampleHeightMostDetailed = async (positions) => { + centres.push(Cesium.Math.toDegrees(positions[0].longitude)); + return positions; + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + assert.ok(centres.length > 0, 'a read was made'); + const eastM = + (centres[0] - RECORD.lon) * + 111_320 * + Math.cos((RECORD.lat * Math.PI) / 180); + assert.ok(eastM > 300 && eastM < 450, `probed ${eastM.toFixed(0)} m east`); +}); + +test('the radar look line turns the short way across astern', async (t) => { + const clock = manualClock(t, START_MS); + const { $, store } = await bridgeAt(t, clock); + const lookAt = async (yawDeg) => { + store.publishInstruments({ + ...store.instruments, + look: { ...store.instruments.look, offset: true, yawDeg }, + }); + await settle(); + return $('.cockpit-radar-look')?.getAttribute('style') ?? ''; + }; + assert.match(await lookAt(170), /rotate\(170deg\)/); + // From 170° to -170° is 20° through astern, not 340° back over the bow. + assert.match(await lookAt(-170), /rotate\(190deg\)/); +}); + +test('a ship with no true heading is held, and labelled, on its course', async (t) => { + const clock = manualClock(t, START_MS); + clock.set(START_MS); + const record = { + ...RECORD, + heading: null, + lastPositionEpoch: START_MS / 1000, + }; + const { $, cockpit } = fixture(t, { record }); + const signals = []; + cockpit.pushCockpitSignal = (...args) => signals.push(args); + cockpit.enter(); + await settle(); + cockpit.update(); + await settle(); + assert.match( + $('.cockpit-visor-status-right').textContent, + /COURSE HOLD · AIS/, + ); + assert.ok( + signals.some(([, , , detail]) => /COURSE 095°/.test(detail)), + JSON.stringify(signals), + ); +}); + +test('both entries name the subject the same way', async () => { + const { cockpitEntryFace } = await import('./cockpitPresentation.js'); + assert.deepEqual(cockpitEntryFace('vessel'), { + icon: 'directions_boat', + label: 'BRIDGE', + }); + assert.deepEqual(cockpitEntryFace('aircraft'), { + icon: 'flight', + label: 'COCKPIT', + }); + assert.deepEqual(cockpitEntryFace(null), cockpitEntryFace('aircraft')); +}); + +test('a ship with neither heading nor course makes up no direction', async (t) => { + const clock = manualClock(t, START_MS); + const record = { + ...RECORD, + heading: null, + course: null, + lastPositionEpoch: START_MS / 1000, + }; + const { $, cockpit, viewer, shots, store } = fixture(t, { record }); + // The map camera looks south-south-west as the bridge opens. + viewer.camera.heading = Cesium.Math.toRadians(200); + const signals = []; + cockpit.pushCockpitSignal = (...args) => signals.push(args); + cockpit.enter(); + await settle(); + cockpit.update(); + const start = shots.at(-1).destination; + // Ten seconds at 12 kn would carry a made-up course some 60 m. + const view = frames(cockpit, clock, shots, 200); + await settle(); + near(view.headingDeg, 200, 'keeps the entry camera heading'); + const driftM = Cesium.Cartesian3.distance(start, shots.at(-1).destination); + assert.ok(driftM < 1, `the eye stays on the fix, drifted ${driftM} m`); + assert.equal(store.instruments.heading.text, '---'); + assert.deepEqual(store.instruments.compassMarkers, { + prograde: null, + retrograde: null, + }); + assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.match( + $('.cockpit-visor-status-right').textContent, + /NO HEADING · AIS/, + ); + const details = signals.map(([, , , detail]) => detail); + assert.ok(details.includes('NORDIC STAR · NO HEADING'), details.join(' | ')); + assert.ok(!details.some((detail) => /000°/.test(detail)), details.join()); +}); + +test('the bridge tape reads the exact heading at the caret', async (t) => { + const clock = manualClock(t, START_MS); + const record = { + ...RECORD, + heading: 100, + lastPositionEpoch: START_MS / 1000, + }; + const { cockpit, shots, store } = fixture(t, { record }); + cockpit.enter(); + await settle(); + frames(cockpit, clock, shots, 40); + await settle(); + const { compass, compassMarkers } = store.instruments; + // Over the bow at 100°: the bow mark sits under the caret, and the tape + // has scrolled a third of a division past 090. + near(compassMarkers.prograde, 0, 'the bow under the caret', 1e-3); + const nearest = compass.find((division) => division.active); + assert.equal(nearest.label, 'E'); + near(nearest.slot, -1 / 3, 'E a third of a division to port', 1e-3); + const tape = document.querySelector('#cockpit-compass-tape'); + near( + Number(tape.style.getPropertyValue('--tape-shift')), + -1 / 3, + 'the minor ticks scroll with it', + 1e-3, + ); + const east = [...tape.querySelectorAll('span')].find( + (span) => span.textContent === 'E', + ); + assert.equal(east.className, 'active'); + near(Number(east.style.getPropertyValue('--slot')), -1 / 3, 'E drawn', 1e-3); +}); + +test('a slow water read is never stacked, and lands however late', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + const reads = []; + // Every full-detail read hangs until the test answers it. + ship.viewer.scene.sampleHeightMostDetailed = (positions) => + new Promise((resolve) => reads.push({ positions, resolve })); + let loadedReads = 0; + ship.viewer.scene.sampleHeight = () => { + loadedReads += 1; + return 0; + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + assert.equal(reads.length, 1); + const loadedAtStart = loadedReads; + // Twelve seconds on, still one read in flight; the refreshes read the + // loaded tiles meanwhile. + frames(ship.cockpit, clock, ship.shots, 240); + await settle(); + assert.equal(reads.length, 1, 'no second read while the first runs'); + assert.ok(loadedReads > loadedAtStart, 'the loaded tiles are read again'); + // The 12 s read lands (a river at 27 m) and applies. + const [slow] = reads; + slow.resolve( + slow.positions.map((position) => Object.assign(position, { height: 27 })), + ); + await settle(); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'raised by the late read', + 0.5, + ); + // Settled, the next refresh starts a new read. + assert.equal(reads.length, 2); + // It hangs over tiles not loaded yet: the refreshes sample nothing, and the + // river height stands rather than falling back to the sea. + ship.viewer.scene.sampleHeight = () => undefined; + frames(ship.cockpit, clock, ship.shots, 240); + await settle(); + assert.equal(reads.length, 2); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'kept through refreshes that sample nothing', + 0.5, + ); +}); + +test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => { + // The feed's newest fix is this ship's; the client clock runs 200 s + // ahead of the feed and received the refresh just now. + const skewMs = 200_000; + const clock = manualClock(t, START_MS + skewMs); + const record = { + ...RECORD, + lastPositionEpoch: START_MS / 1000, + fixFeedEpoch: START_MS / 1000, + fixReceivedAtMs: START_MS + skewMs, + }; + const { $, cockpit, shots } = fixture(t, { record }); + cockpit.enter(); + await settle(); + cockpit.update(); + frames(cockpit, clock, shots, 20); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/); + assert.doesNotMatch($('#cockpit-aircraft-meta').textContent, /STALE/); +}); + +test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (t) => { + const clock = manualClock(t, START_MS); + // The layer's own records, polled every 30 s with the same fix, as a feed + // serving its cache does. + const records = new VesselRecords({ now: () => Date.now() }); + const effects = { + add() {}, + remove() {}, + beforeUpdate() {}, + updated() {}, + staleSelected() {}, + removed() {}, + }; + const poll = () => + records.reconcile( + [ + { + ...RECORD, + last_position_epoch: START_MS / 1000, + }, + ], + { complete: true }, + effects, + ); + poll(); + const record = records.byMmsi.get(RECORD.mmsi); + const { $, cockpit, shots } = fixture(t, { record }); + cockpit.enter(); + await settle(); + cockpit.update(); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/); + // Seven polls later (210 s), the fix is past the 180 s projection. + for (let polls = 0; polls < 7; polls += 1) { + frames(cockpit, clock, shots, 600); + poll(); + } + frames(cockpit, clock, shots, 20); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /STALE FEED/); +}); + +test("a double-click's second press keeps the first press's picture of the ship", async (t) => { + const { cockpit, viewer, store } = fixture(t); + heightSensitiveProjection(viewer); + // The first press finds the ship at (400, 300); the first click + // re-centres the camera, so the second press finds it at (600, 300). + cockpit.onScenePointerDown(1000); + heightSensitiveProjection(viewer, { x: 600 }); + cockpit.onScenePointerDown(1200); + // By the double-click it has moved again; the gesture began at (400, 300). + heightSensitiveProjection(viewer, { x: 800 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), true); + await settle(); + assert.equal(store.presence.active, true); +}); diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index a5c6d8c5a..b701ddab0 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -156,7 +156,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 0a2d1f372..50a43510f 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -7,6 +7,15 @@ import { cockpitUiUpdateDue, slewHeading, } from '../cockpitMath.js'; +import { + VESSEL_DEAD_RECKON_MAX_S, + VESSEL_WATERLINE_REFRESH_MS, + deadReckonVesselFix, + vesselCameraProfile, + vesselWaterlineM, + waterlineProbePoints, +} from '../cockpitSubjects.js'; +import { easeLook } from '../cockpitLook.js'; import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, @@ -44,6 +53,10 @@ export function update() { } const nowMs = performance.now(); + if (this.subjectKind === 'vessel') { + updateVessel.call(this, nowMs); + return; + } const info = this.readAircraftInfo(); // Adopt a newly selected track before the camera cadence gate. A context @@ -86,17 +99,208 @@ export function update() { placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE); } +/** + * The surface the ridden ship floats on: the layer's geoid sea surface, or + * the water read off the rendered scene when that sits higher (a river or + * lake above sea level). The read runs in the background every + * VESSEL_WATERLINE_REFRESH_MS. The full-detail read can take many seconds + * from cold tiles, so a new ship's first read starts from the tiles already + * on screen; until any read lands the sea surface stands. Only one + * full-detail read runs at a time (each is nine picks Cesium traverses + * every frame until it settles): while one is still running, a refresh + * reads the loaded tiles instead, and the slow read applies whenever it + * lands unless a newer one has. + * @param {{latitude: number, longitude: number}} [at] Where to read the + * water: the dead-reckoned position the camera rides. + * @returns {number} Ellipsoidal metres. + */ +function vesselSurfaceM(info, nowMs, at = info) { + const seaM = Number.isFinite(info.seaSurfaceHeightM) + ? info.seaSurfaceHeightM + : 0; + const scene = this.viewer.scene; + let read = + this.vesselWaterline?.mmsi === info.mmsi ? this.vesselWaterline : null; + if ( + typeof scene?.sampleHeightMostDetailed === 'function' && + (!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS) + ) { + const probes = waterlineProbePoints(at.latitude, at.longitude).map( + ({ lat, lon }) => Cesium.Cartographic.fromDegrees(lon, lat), + ); + if (!read) { + read = { + mmsi: info.mmsi, + heightM: loadedWaterlineM(scene, probes, seaM), + sampledMs: nowMs, + // Full-detail reads by number: the last started, the last applied. + startedSeq: 0, + landedSeq: 0, + inFlight: false, + }; + this.vesselWaterline = read; + } else { + read.sampledMs = nowMs; + if (read.inFlight) { + const loadedM = loadedWaterlineM(scene, probes, seaM); + if (Number.isFinite(loadedM)) read.heightM = loadedM; + } + } + if (!read.inFlight) readWaterline.call(this, read, scene, probes, seaM); + } + return read && Number.isFinite(read.heightM) + ? Math.max(seaM, read.heightM) + : seaM; +} + +/** Start one full-detail water read for `read`; its answer lands on it. */ +function readWaterline(read, scene, probes, seaM) { + const seq = ++read.startedSeq; + read.inFlight = true; + Promise.resolve() + .then(() => scene.sampleHeightMostDetailed(probes)) + .then( + (samples) => { + if (this.vesselWaterline !== read) return; + read.inFlight = false; + // However late it lands, it applies unless a newer read has. + if (seq <= read.landedSeq) return; + read.landedSeq = seq; + read.heightM = vesselWaterlineM( + seaM, + (samples || []).map((sample) => sample?.height), + ); + }, + () => { + if (this.vesselWaterline === read) read.inFlight = false; + }, + ); +} + +/** + * The waterline from the tiles already loaded, read at once: a first answer + * while the full-detail read loads. Null when the scene cannot sample or no + * probe met loaded geometry: nothing sampled is no answer, not the sea, so it + * never undoes a river height a full read set. + */ +function loadedWaterlineM(scene, probes, seaM) { + if (typeof scene.sampleHeight !== 'function') return null; + if (scene.sampleHeightSupported === false) return null; + try { + const heights = probes.map((probe) => scene.sampleHeight(probe)); + if (!heights.some(Number.isFinite)) return null; + return vesselWaterlineM(seaM, heights); + } catch { + return null; + } +} + +/** + * Drive the bridge camera from the selected vessel. The vessel layer redraws + * a ship only when its feed refreshes, so the target is the last fix advanced + * along course over ground; the shared anchor smoothing then absorbs the + * correction each new fix brings. + */ +function updateVessel(nowMs) { + // Tracking an aircraft from the bridge clears the vessel selection; end the + // bridge and leave Cesium tracking with that aircraft. + if (this.viewer.trackedEntity?.position) { + this.exit({ restoreTracking: false }); + return; + } + const info = this.readVesselInfo(); + if (!info || info.mmsi !== this.subjectId) { + this.exit({ restoreTracking: false }); + return; + } + if ( + !cockpitUiUpdateDue( + nowMs, + this.lastCameraUpdateMs, + COCKPIT_CAMERA_UPDATE_MS, + ) + ) + return; + this.lastCameraUpdateMs = nowMs; + + const fix = deadReckonVesselFix( + { + latitude: info.latitude, + longitude: info.longitude, + courseDeg: info.motionCourse, + speedMps: info.velocityMps, + fixEpochMs: info.fixEpochMs, + receivedAtMs: info.receivedAtMs, + feedEpochMs: info.feedEpochMs, + }, + Date.now(), + ); + const target = Cesium.Cartesian3.fromDegrees( + fix.longitude, + fix.latitude, + // Read the water where the ship is now, not at its last report. + vesselSurfaceM.call(this, info, nowMs, fix), + Cesium.Ellipsoid.WGS84, + this.scratchTarget, + ); + // The target advances in wall-clock time, so the anchor must too: the + // aircraft path's 0.1 s step cap would leave a slow-frame or hitching bridge + // falling ever further behind its own ship. + const dtSec = Math.min( + VESSEL_DEAD_RECKON_MAX_S, + Math.max(0, (nowMs - this.lastFrameMs) / 1000), + ); + this.lastFrameMs = nowMs; + if (Number.isFinite(info.track)) { + this.heading = slewHeading( + this.heading ?? info.track, + info.track, + COCKPIT_HEADING_SLEW_DPS * dtSec, + ); + } + // Past the projection limit the target stops advancing; hold on it rather + // than let the inertial anchor sail on alone. + const projectionExpired = + Number.isFinite(fix.ageSec) && fix.ageSec > VESSEL_DEAD_RECKON_MAX_S; + // The look eases on its own clock: the anchor's step is capped differently. + this.lookView = easeLook( + this.lookView, + this.lookTarget, + nowMs - (this.lookLastMs || nowMs), + ); + this.lookLastMs = nowMs; + // A ship reporting neither course nor heading has no direction to move + // in: its anchor stays on the fix, as the dead reckoning does. + const ridden = Number.isFinite(info.motionCourse) + ? info + : { ...info, velocityMps: 0 }; + placeCamera.call( + this, + target, + projectionExpired ? { ...ridden, stale: true } : ridden, + nowMs, + dtSec, + vesselCameraProfile(info), + this.lookView, + ); +} + /** * Place the camera for one update: advance the inertial anchor, clamp it and * the eye against the rendered surface, and look along the slewed heading. * @param {Cesium.Cartesian3} target Authoritative subject position. - * @param {object} info Subject info. + * @param {object} info Subject info; `motionCourse` (when present) is the + * direction the subject moves, otherwise it moves where it faces. * @param {number} nowMs Frame time. * @param {number} dtSec Seconds since the previous camera update. * @param {{forwardM: number, upM: number, pitchDeg: number, * minClearanceM: number}} profile Eye offsets for the subject kind. + * @param {?{yawDeg: number, pitchDeg: number}} [look] A bridge view turned + * off the bow: yaw relative to the heading and absolute pitch. It turns + * only the view; the eye and its motion keep the heading and course. + * Aircraft pass none and look along the heading at the profile pitch. */ -function placeCamera(target, info, nowMs, dtSec, profile) { +function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; @@ -110,7 +314,15 @@ function placeCamera(target, info, nowMs, dtSec, profile) { // 15/30-second interpolation and per-frame cache without exposing its // sample-boundary corrections to the camera. const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(profile.pitchDeg); + const courseRad = Number.isFinite(info.motionCourse) + ? Cesium.Math.toRadians(info.motionCourse) + : headingRad; + const pitchRad = Cesium.Math.toRadians( + look ? look.pitchDeg : profile.pitchDeg, + ); + const viewHeadingRad = look + ? Cesium.Math.toRadians((this.heading ?? 0) + look.yawDeg) + : headingRad; const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; @@ -126,8 +338,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { Cesium.Ellipsoid.WGS84, this.scratchEnu, ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); + this.scratchLocal.x = Math.sin(courseRad); + this.scratchLocal.y = Math.cos(courseRad); this.scratchLocal.z = 0; Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, @@ -296,8 +508,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { ); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); + this.scratchLocal.x = Math.sin(viewHeadingRad) * Math.cos(pitchRad); + this.scratchLocal.y = Math.cos(viewHeadingRad) * Math.cos(pitchRad); this.scratchLocal.z = Math.sin(pitchRad); Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, @@ -306,8 +518,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { ); Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); + this.scratchLocal.x = -Math.sin(viewHeadingRad) * Math.sin(pitchRad); + this.scratchLocal.y = -Math.cos(viewHeadingRad) * Math.sin(pitchRad); this.scratchLocal.z = Math.cos(pitchRad); Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 6010c3ce7..1fd4b7733 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,6 +1,7 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ import { readAircraftInfo, + readVesselInfo, readSubjectLayerId, resolveEntryKind, dispatchCockpitModeChanged, @@ -19,6 +20,8 @@ import { publishRouteView, updateHud, updateRoute, + updateRadar, + presentLook, syncWeatherToggle, setVisionMode, cycleVisionMode, @@ -67,10 +70,21 @@ import { import { onKeyDown, onSceneDoubleClick, + onScenePointerDown, routeSceneDoubleClick, } from './cockpitInput.js'; +import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js'; +import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js'; +import { stepLook } from '../cockpitLook.js'; import * as Cesium from 'cesium'; +/** Whether the user asked for reduced motion: look turns then jump. */ +function prefersReducedMotion() { + return Boolean( + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches, + ); +} + export class CockpitViewController { constructor( viewer, @@ -80,6 +94,7 @@ export class CockpitViewController { getInitialVisionMode = null, onCameraTakeover = null, isEntryAllowed = null, + isBridgeEntryAllowed = null, isUtilityDisclosureOpen = null, onEntered = null, onExited = null, @@ -93,6 +108,10 @@ export class CockpitViewController { this.destroyed = false; this.disposed = false; this.active = false; + /** 'aircraft' or 'vessel' while active; which subject the camera rides. */ + this.subjectKind = null; + /** The vessel subject's MMSI; aircraft are identified by trackedEntity. */ + this.subjectId = null; this.trackedEntity = null; this.trackedEntityWasShown = true; this.heading = null; @@ -105,6 +124,14 @@ export class CockpitViewController { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; + /** + * Where the bridge view is turned to (`lookTarget`) and where the camera + * looks on its way there (`lookView`): yaw off the bow, starboard + * positive, and absolute pitch. Aircraft never turn it. + */ + this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + this.lookView = { ...this.lookTarget }; + this.lookLastMs = 0; this.entry = document.getElementById('cockpit-entry'); this.tr3bToggle = document.getElementById('tr3b-toggle'); this._tr3bSignature = null; @@ -130,6 +157,11 @@ export class CockpitViewController { : () => false; this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; + // Without a supplied gate there is no vessel layer to ride either. + this.isBridgeEntryAllowed = + typeof isBridgeEntryAllowed === 'function' + ? isBridgeEntryAllowed + : () => false; this.isUtilityDisclosureOpen = typeof isUtilityDisclosureOpen === 'function' ? isUtilityDisclosureOpen @@ -164,9 +196,15 @@ export class CockpitViewController { this.publishVision = publish.vision ?? (() => {}); this.publishSignals = publish.signals ?? (() => {}); this.publishUtilityLayout = publish.utilityLayout ?? (() => {}); + this.publishRadar = publish.radar ?? (() => {}); + /** The bridge radar's range in nautical miles; kept across bridges. */ + this.radarRangeNm = DEFAULT_RADAR_RANGE_NM; this.presence = { active: false, entryAvailable: false, + // Which subject the entry button would enter, and the active subject. + entryKind: null, + subjectKind: null, tr3b: null, context: false, // The Contact and briefing cards' collapse, which their toggles show. @@ -216,9 +254,15 @@ export class CockpitViewController { ); this._listen(this.entry, 'click', () => this.enter()); // Double-clicking the followed track is a third way in. + this._listen( + viewer.scene?.canvas, + 'pointerdown', + () => this.onScenePointerDown(), + true, + ); this._listenerRemovers.push(routeSceneDoubleClick.call(this)); // The readout's entry renders inside the Intel HUD, which may mount after - // this controller, so its clicks are delegated. + // this controller; delegated like the radar's range button. this._listen(document, 'click', (event) => { if (event.target?.closest?.('#cockpit-entry-readout')) this.enter(); }); @@ -248,6 +292,15 @@ export class CockpitViewController { this._listen(window, 'gev:cockpit-weather-state', (event) => { this.syncWeatherToggle(event?.detail?.enabled !== false); }); + // The radar mounts only on a bridge, so its range button is delegated. + this._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-radar-range')) + this.cycleRadarRange(); + }); + // The look-ahead button shows only on a turned bridge view; delegated. + this._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-look-ahead')) this.lookAhead(); + }); this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed(!this.signalCollapsed, { user: true }), ); @@ -289,6 +342,9 @@ export class CockpitViewController { readAircraftInfo() { return readAircraftInfo.call(this); } + readVesselInfo() { + return readVesselInfo.call(this); + } readSubjectLayerId() { return readSubjectLayerId.call(this); } @@ -316,8 +372,8 @@ export class CockpitViewController { _adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); } - enter() { - return enter.call(this); + enter(options) { + return enter.call(this, options); } exit(options) { return exit.call(this, options); @@ -337,6 +393,58 @@ export class CockpitViewController { updateRoute(info) { return updateRoute.call(this, info); } + updateRadar(info) { + return updateRadar.call(this, info); + } + /** Step the bridge radar to its next range and redraw it at once. */ + cycleRadarRange() { + this.radarRangeNm = nextRadarRange(this.radarRangeNm); + if (this.active && this.subjectKind === 'vessel' && this.lastAircraftInfo) + this.updateRadar(this.lastAircraftInfo); + } + presentLook() { + return presentLook.call(this); + } + /** + * Turn the bridge view one arrow step (see `lookKeyStep`). The camera + * eases there unless the user prefers reduced motion. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step + * @returns {boolean} True when a bridge view was turned. + */ + turnLook(step) { + if (!this.active || this.subjectKind !== 'vessel' || !step) return false; + this.lookTarget = stepLook(this.lookTarget, step); + if (prefersReducedMotion()) this.lookView = { ...this.lookTarget }; + this.presentLook(); + this.publishInstrumentsView(); + return true; + } + /** + * Point the bridge view back over the bow at the rest pitch. + * @param {{immediate?: boolean}} [options] Jump there instead of easing, + * as entry and exit do. + */ + resetLook({ immediate = false } = {}) { + this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + if (immediate || prefersReducedMotion()) + this.lookView = { ...this.lookTarget }; + this.lookLastMs = performance.now(); + this.presentLook(); + this.publishInstrumentsView(); + } + /** + * The LOOK AHEAD control and the Home and 0 keys: ease the bridge view + * back over the bow. Focus leaves the button before it hides. + * @returns {boolean} True on a bridge. + */ + lookAhead() { + if (!this.active || this.subjectKind !== 'vessel') return false; + const button = document.getElementById('cockpit-look-ahead'); + const hadFocus = !!button && button.contains(document.activeElement); + this.resetLook(); + if (hadFocus) this.mapViewButton?.focus({ preventScroll: true }); + return true; + } syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); } @@ -426,8 +534,11 @@ export class CockpitViewController { onKeyDown(event) { return onKeyDown.call(this, event); } - onSceneDoubleClick(position) { - return onSceneDoubleClick.call(this, position); + onSceneDoubleClick(position, nowMs) { + return onSceneDoubleClick.call(this, position, nowMs); + } + onScenePointerDown(nowMs) { + return onScenePointerDown.call(this, nowMs); } handleSignalClick(event) { diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 3839aa3af..9a0eb258a 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -2,6 +2,7 @@ import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; import { COCKPIT_AIRCRAFT_LAYER_IDS, + bridgeEntryAllowed, cockpitEntryAllowed, } from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; @@ -40,6 +41,7 @@ export class CockpitCoordinator { const { flightsLayer, militaryFlightsLayer, + aisLiveVesselsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, @@ -60,6 +62,7 @@ export class CockpitCoordinator { services: { flightsLayer, militaryFlightsLayer, + aisLiveVesselsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, @@ -93,6 +96,11 @@ export class CockpitCoordinator { contextModeChanging: this._contextModeChanging, }); }, + isBridgeEntryAllowed: () => + bridgeEntryAllowed({ + vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), + contextModeChanging: this._contextModeChanging, + }), publish: this.publishCockpit, isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(), onEntered: () => this.enterPanels(), @@ -129,15 +137,21 @@ export class CockpitCoordinator { return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId); } /** - * Make the ridden aircraft's layer the user's own while Contacts is on. - * Contacts may have turned that layer on, and it releases the layers it - * owns when it goes off; the Cockpit must outlive Contacts, so riding an - * aircraft adopts its layer as a direct selection does. Contacts then stops - * owning it and the Context restore keeps it (session `userAdded`). - * @param {string|null} layerId The ridden aircraft's layer. + * Make the ridden layer the user's own while Contacts is on. Contacts may + * have turned that layer on (an aircraft layer, or Live AIS Vessels), and + * it releases the layers it owns when it goes off; the Cockpit must outlive + * Contacts, so riding an aircraft or a vessel adopts its layer as a direct + * selection does. Contacts then stops owning it and the Context restore + * keeps it (session `userAdded`). + * @param {string|null} layerId The ridden aircraft's layer, or + * 'ais-live-vessels' on a bridge. */ _adoptRiddenLayer(layerId) { - if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return; + if ( + !COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId) && + layerId !== 'ais-live-vessels' + ) + return; if (!this._isLayerEnabled('military-awareness')) return; this._dataManager?.adoptLayerVisibility?.(layerId, true, { origin: 'user', @@ -167,6 +181,11 @@ export class CockpitCoordinator { const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); const info = this.cockpitView?.readAircraftInfo?.(); const active = Boolean(this.cockpitView?.active); + // Voice enters aircraft only; a bridge in use is still reported as it is. + const vessel = + active && this.cockpitView?.subjectKind === 'vessel' + ? this.cockpitView.readVesselInfo?.() + : null; const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off @@ -195,13 +214,22 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: info + subject: vessel ? { - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.mmsi, + layerId: vessel.layerId, + callsign: null, + name: vessel.name, } - : null, + : info + ? { + kind: 'aircraft', + id: info.icao24 || info.id || null, + layerId: info.layerId || null, + callsign: info.callsign || null, + } + : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index 834f1866f..d7e8fd226 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -86,6 +86,7 @@ function fixture( services: { flightsLayer: { getTrackedInfo: trackedFrom('flights') }, militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, + aisLiveVesselsLayer: { getCockpitSubject: () => null }, militaryAwarenessLayer: { getContextSnapshot: () => null }, isTr3b: () => false, holdContinuousRender() {}, @@ -120,7 +121,7 @@ function fixture( t.after(() => { coordinator.stop(); app.unmount(); - document.body.classList.remove('cockpit-mode'); + document.body.classList.remove('cockpit-mode', 'cockpit-bridge'); }); /** Turn a layer off and tell the Context owner, as the data manager does. */ const disable = (layerId) => { @@ -157,6 +158,7 @@ test('a civilian aircraft enters with Live Flights alone: no Contacts, no Milita assert.equal(state.entryAllowed, true); assert.equal(state.entryBlockedReason, null); assert.equal(cockpit.enter(), true); + assert.equal(cockpit.subjectKind, 'aircraft'); }); test('a military aircraft enters with Military Flights alone', (t) => { @@ -439,6 +441,22 @@ test('a ride handed to an aircraft on a layer Contacts turned on keeps that laye assert.deepEqual(on(manager), ['flights', 'military']); }); +test('a bridge ride keeps Live AIS Vessels when Contacts had turned it on', async (t) => { + const live = await liveFixture(t, { + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, coordinator, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + assert.equal(manager.isEnabled('ais-live-vessels'), true); + // The bridge reports its layer on entry (cockpitBridge.test.mjs). + coordinator._adoptRiddenLayer('ais-live-vessels'); + await controls._selectContextMode(null); + await settle(); + assert.equal(controls.getContextModeState().mode, null); + assert.deepEqual(on(manager), ['ais-live-vessels']); +}); + test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => { const live = await liveFixture(t, { tracked: { layerId: 'flights', info: CIVIL }, @@ -465,3 +483,19 @@ test('without a Cockpit, ending Contacts restores the layers it found', async (t assert.equal(controls.getContextModeState().mode, null); assert.deepEqual(on(manager), [], 'Live Flights goes back off'); }); + +test('the readout entry reads BRIDGE for a selected vessel', async (t) => { + const { $, store } = fixture(t); + store.publishPresence({ + active: false, + entryAvailable: true, + entryKind: 'vessel', + subjectKind: null, + tr3b: null, + }); + await nextTick(); + const readoutEntry = $('#cockpit-entry-readout'); + assert.equal(readoutEntry.hidden, false); + assert.match(readoutEntry.textContent, /directions_boat/); + assert.match(readoutEntry.textContent, /BRIDGE/); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 15b4207e1..a5c1146c2 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,5 +1,6 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ import * as Cesium from 'cesium'; +import { lookKeyStep } from '../cockpitLook.js'; import { isPointerFree } from '../data/inputOwnership.js'; /** How far from the followed track a double-click may land, CSS pixels. */ @@ -22,14 +23,33 @@ export function doubleClickHitsSubject( return Number.isFinite(distance) && distance <= radiusPx; } +/** + * How long the first press of a double-click keeps its picture of the track, + * ms: a click on a selected ship re-centres the camera, so the ship can move + * on screen before the second click lands. + */ +export const DOUBLE_CLICK_MEMORY_MS = 700; + /** Where the track `kind` would ride is drawn now, as a world position. */ function subjectWorldPosition(kind) { - if (kind !== 'aircraft') return null; - return ( - this.viewer.trackedEntity?.position?.getValue?.( - this.viewer.clock?.currentTime, - ) ?? null - ); + if (kind === 'aircraft') { + return ( + this.viewer.trackedEntity?.position?.getValue?.( + this.viewer.clock?.currentTime, + ) ?? null + ); + } + const info = this.readVesselInfo(); + if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) + return null; + // The sprite floats on the geoid, not on the ellipsoid (tens of metres + // apart): project the height it is drawn at. + const heightM = Number.isFinite(info.markHeightM) + ? info.markHeightM + : Number.isFinite(info.seaSurfaceHeightM) + ? info.seaSurfaceHeightM + : 0; + return Cesium.Cartesian3.fromDegrees(info.longitude, info.latitude, heightM); } /** The followed track's canvas position now, or null off screen. */ @@ -44,21 +64,43 @@ function subjectCanvasPosition() { ); } +/** + * Note where the followed track is drawn as a press begins, for a + * double-click whose first click moves it. The second press of the gesture + * keeps the first one's picture: by then the first click may have moved it. + * @param {number} [nowMs] + */ +export function onScenePointerDown(nowMs = performance.now()) { + if (this.active || this.destroyed) return; + const start = this.doubleClickStart; + if (start && nowMs - start.atMs <= DOUBLE_CLICK_MEMORY_MS) return; + const at = subjectCanvasPosition.call(this); + this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs } : null; +} + /** * Enter first person when the followed track itself is double-clicked: the - * same entry, and the same gates, as C and the entry buttons. A + * same entry, and the same gates, as C and the entry buttons. The click may + * land where the track is drawn now or where it was as the gesture began. A * double-click anywhere else, or one a tool owns (drawing ends a shape with * one), is left alone. * @param {{x: number, y: number}|null|undefined} position Canvas position. + * @param {number} [nowMs] * @returns {boolean} True when it landed on the followed track, entered or * refused by the gate: the caller does nothing else with it. */ -export function onSceneDoubleClick(position) { +export function onSceneDoubleClick(position, nowMs = performance.now()) { + const start = this.doubleClickStart; + this.doubleClickStart = null; if (this.active || this.destroyed || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this))) - return false; + const hit = + doubleClickHitsSubject(position, subjectCanvasPosition.call(this)) || + (Boolean(start) && + nowMs - start.atMs <= DOUBLE_CLICK_MEMORY_MS && + doubleClickHitsSubject(position, start)); + if (!hit) return false; this.enter(); return true; } @@ -91,12 +133,55 @@ export function routeSceneDoubleClick() { } /** - * The Cockpit's capture-phase keydown: Escape and C. + * Focus inside one of these keeps its arrows (and Home): text entry, sliders, + * tabs, radio groups, lists and menus all move with them. + */ +const LOOK_KEY_OWNERS = [ + 'input', + 'textarea', + 'select', + '[contenteditable]', + '[role=slider]', + '[role=tab]', + '[role=tablist]', + '[role=radio]', + '[role=radiogroup]', + '[role=listbox]', + '[role=menu]', + '[role=menuitem]', + '[role=option]', +].join(', '); + +/** + * Turn the bridge view with the arrow keys (Shift for 45° steps) and bring + * it back over the bow with Home or 0. Only a bridge looks around, and only + * while focus is on the scene or a plain HUD button. + * @returns {boolean} True when the key was taken. + */ +function handleLookKey(event) { + if (!this.active || this.subjectKind !== 'vessel') return false; + if (event.metaKey || event.ctrlKey || event.altKey) return false; + const step = lookKeyStep(event.key, event.shiftKey); + const reset = !event.repeat && (event.key === 'Home' || event.key === '0'); + if (!step && !reset) return false; + if (event.target?.closest?.(LOOK_KEY_OWNERS)) return false; + if (this.isUtilityDisclosureOpen?.()) return false; + event.preventDefault(); + if (step) this.turnLook(step); + else this.lookAhead(); + return true; +} + +/** + * The Cockpit's capture-phase keydown: Escape and C, and on a bridge the + * look-around keys. * @param {KeyboardEvent} event */ export function onKeyDown(event) { if (this.destroyed) return false; - if (event.repeat || event.isComposing) return; + if (event.isComposing) return; + // Held arrows turn a bridge view smoothly; every other key acts once. + if (event.repeat && !lookKeyStep(event.key)) return; if (event.key === 'Escape' && this.active) { // The credit lightbox owns Escape while its Close control or links hold // focus. Its target handler closes the overlay and restores attribution @@ -139,19 +224,28 @@ export function onKeyDown(event) { } if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; + if (handleLookKey.call(this, event)) return; const key = event.key?.toLowerCase(); if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (!this.active) { const cockpitAttempt = !!( this.readAircraftInfo() && this.viewer.trackedEntity?.position ); - if (!cockpitAttempt) return; + // With no tracked aircraft, C takes the selected vessel's bridge. + const bridgeAttempt = !cockpitAttempt && !!this.readVesselInfo(); + if (!cockpitAttempt && !bridgeAttempt) return; + if (bridgeAttempt) { + event.preventDefault(); + event.stopImmediatePropagation(); + if (this.isBridgeEntryAllowed()) this.enter({ kind: 'vessel' }); + return; + } } event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; if (this.active) this.exit(); - else this.enter(); + else this.enter({ kind: 'aircraft' }); return; } } diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 90b553ea5..0ba28d89a 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,15 +52,28 @@ function createRimState(value) { */ export function createInstrumentState() { return { + // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), meta: { text: t('LIVE TRACK · COURSE ALIGNED'), emergency: false }, speed: null, altitude: null, + // A vessel's course over ground, shown where an aircraft shows altitude. + course: null, heading: null, + // A vessel's `heading` readout is its true heading, or its course over + // ground when it reports none: 'heading' | 'course' | null. + trackSource: null, speedRim: createRimState('---'), altitudeRim: createRimState('-----'), compass: [], + // A bridge tape's bow (prograde) and stern (retrograde) marks, in + // division slots off its centre; null when off the tape or not a bridge. + compassMarkers: { prograde: null, retrograde: null }, + // A bridge view turned off the bow: whether it is, the target yaw off + // the bow (degrees, -180..180, starboard positive) and its worded readout. + look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', }; @@ -73,6 +86,8 @@ export function createInstrumentState() { */ export function createRouteState() { return { + // A vessel's card shows only the free-text AIS destination. + kind: 'aircraft', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index dc9964ff8..1d32a4cd4 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -11,6 +11,8 @@ import { cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, + compassMarkerSlot, + scrollingCompassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatCockpitEmergency, @@ -22,10 +24,23 @@ import { } from '../cockpitMath.js'; import { COCKPIT_CONTEXT_UPDATE_MS, + cockpitUnitCase, compassPointLabel, formatCockpitCoordinate, } from './cockpitPresentation.js'; import { createVisionState } from './cockpitInstrumentState.js'; +import { unitLabel } from './unitLabels.js'; +import { + VESSEL_BRIDGE_PITCH_DEG, + vesselCameraProfile, +} from '../cockpitSubjects.js'; +import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { + RADAR_MAX_CONTACTS, + radarPicture, + radarRangeM, + radarRangeNm, +} from '../cockpitRadar.js'; /** A transponder status name (src/data/aircraftEmergency.js) for the HUD. */ const emergencyLabel = ({ name }) => t(name).toUpperCase(); @@ -68,20 +83,68 @@ export function publishInstrumentsView() { meta: { ...view.meta }, speed: view.speed && { ...view.speed }, altitude: view.altitude && { ...view.altitude }, + course: view.course && { ...view.course }, heading: view.heading && { ...view.heading }, speedRim: rim(view.speedRim), altitudeRim: rim(view.altitudeRim), compass: view.compass.map((division) => ({ ...division })), + compassMarkers: { ...view.compassMarkers }, + look: { ...view.look }, }); } +/** Word a bridge look's readout parts (see `lookReadoutParts`). */ +function wordLook({ side, bearingDeg, elevation, angleDeg }) { + const bearing = formatHeadingReadout(bearingDeg); + const angle = formatNumber(angleDeg); + let direction = ''; + if (side === 'starboard') direction = t('LOOK STBD {bearing}°', { bearing }); + else if (side === 'port') direction = t('LOOK PORT {bearing}°', { bearing }); + else if (side === 'astern') direction = t('LOOK ASTERN'); + let tilt = ''; + if (elevation === 'up') tilt = t('UP {angle}°', { angle }); + else if (elevation === 'down') tilt = t('DOWN {angle}°', { angle }); + else if (elevation === 'level') tilt = t('LEVEL'); + if (direction && tilt) + return t('{direction} · {elevation}', { direction, elevation: tilt }); + if (tilt) return t('LOOK {elevation}', { elevation: tilt }); + return direction; +} + +/** + * Present the bridge look target: whether the view is turned off the bow, + * its yaw for the radar and its worded readout. Outside a bridge the look + * always rests. The caller publishes. + */ +export function presentLook() { + const target = this.lookTarget; + const offset = + this.active && + this.subjectKind === 'vessel' && + lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG); + this.instruments.look = offset + ? { + offset: true, + yawDeg: wrapLookYaw(target.yawDeg), + readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), + } + : { offset: false, yawDeg: 0, readout: '' }; +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { + if (info?.kind === 'vessel') { + updateVesselHud.call(this, info, nowMs, forceContext); + return; + } this.lastAircraftInfo = info; const view = this.instruments; + view.kind = 'aircraft'; + view.trackSource = null; + view.course = null; const heading = normalizeHeading(this.heading ?? info.track ?? 0); view.callsign = info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); @@ -128,6 +191,7 @@ export function updateHud( slot: index - 3, label: compassPointLabel(formatCompassDivision(division)), })); + view.compassMarkers = { prograde: null, retrograde: null }; view.clock = new Date().toISOString().slice(11, 19) + 'Z'; const lat = Number.isFinite(info.latitude) ? formatCockpitCoordinate(info.latitude, 'N', 'S') @@ -175,6 +239,153 @@ export function updateHud( } } +/** + * Present the bridge readouts: speed and course over ground, true heading, + * the vessel type and its assumed eye height. Contact navigation and Live + * Signals stay aircraft-only, so the Contact card is withdrawn. + */ +function updateVesselHud(info, nowMs, forceContext) { + this.lastAircraftInfo = info; + const view = this.instruments; + view.kind = 'vessel'; + view.trackSource = info.trackSource ?? null; + // A ship reporting neither heading nor course has no heading to read out + // and no bow to mark; the tape still follows the camera. + const headingKnown = Number.isFinite(info.track); + const cameraHeading = normalizeHeading(this.heading ?? info.track ?? 0); + const heading = headingKnown ? cameraHeading : null; + view.callsign = info.name; + const speedKt = Number.isFinite(info.speedKt) + ? Math.max(0, info.speedKt) + : null; + view.speed = { + text: formatSpeedRulerTick(speedKt), + value: speedKt, + immediate: forceContext, + }; + view.speedRim.unavailable = speedKt === null; + view.speedRim.value = formatSpeedRulerTick(speedKt); + placeRimTicks( + view.speedRim.ticks, + speedRulerTicks(speedKt, view.speedRim.ticks.length), + (tick) => formatSpeedRulerTick(tick.valueKt), + ); + view.altitude = null; + view.altitudeRim.unavailable = true; + const course = Number.isFinite(info.course) + ? normalizeHeading(info.course) + : null; + view.course = { + text: formatHeadingReadout(course), + value: course, + immediate: forceContext, + }; + view.heading = { + text: formatHeadingReadout(heading), + value: heading, + immediate: forceContext, + }; + // Looking around turns the tape with the camera; the readout above it + // keeps the ship's heading, and the bow and stern marks stay on the ship. + const viewHeading = normalizeHeading( + cameraHeading + (this.lookView?.yawDeg ?? 0), + ); + // The tape scrolls continuously, so the caret reads the view's exact + // heading and the marks sit at their true bearings from it. + view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({ + slot: division.slot, + heading: division.heading, + active: division.nearest, + label: compassPointLabel(formatCompassDivision(division.heading)), + })); + view.compassMarkers = headingKnown + ? { + prograde: compassMarkerSlot(heading, viewHeading), + retrograde: compassMarkerSlot(heading + 180, viewHeading), + } + : { prograde: null, retrograde: null }; + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`; + const eyeHeightM = vesselCameraProfile(info).upM; + view.meta.text = t('{vesselType} · {feedState} · EYE {height}', { + vesselType: info.typeLabel || t('VESSEL'), + feedState: info.stale ? t('STALE FEED') : t('LIVE AIS'), + height: `${formatNumber(Math.round(eyeHeightM))} ${cockpitUnitCase(unitLabel('m'))}`, + }); + view.meta.emergency = false; + view.layer = info.layerId; + // Reworded here too, so a language switch reaches the look readout. + this.presentLook(); + this.publishInstrumentsView(); + updateVesselRoute.call(this, info); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.updateRadar(info); + this.showContextCard(false); + this.pushCockpitSignal( + 'context-status', + 'info', + t('CONTACT NAVIGATION OFF'), + t('THE BRIDGE STAYS WITH THE SELECTED VESSEL'), + ); + } +} + +/** + * Publish the bridge radar: nearby AIS vessels drawn heading-up from the + * camera's current heading, within the chosen range. + * @param {object} info The vessel subject (`readVesselInfo`). + */ +export function updateRadar(info) { + const layer = this.services.aisLiveVesselsLayer; + if (!info || typeof layer?.getRadarContacts !== 'function') { + this.publishRadar(null); + return; + } + const rangeNm = radarRangeNm(this.radarRangeNm); + const contacts = layer.getRadarContacts( + info.latitude, + info.longitude, + radarRangeM(rangeNm), + RADAR_MAX_CONTACTS, + ); + const picture = radarPicture( + { + latitude: info.latitude, + longitude: info.longitude, + headingDeg: normalizeHeading(this.heading ?? info.track ?? 0), + courseDeg: info.course, + }, + contacts, + rangeNm, + ); + this.publishRadar({ + ...picture, + rangeLabel: t('{range} NM', { range: formatNumber(rangeNm) }), + }); +} + +/** Show the AIS voyage destination; it is crew-entered text, not a place. */ +function updateVesselRoute(info) { + const route = this.routeView; + route.kind = 'vessel'; + route.from = ''; + route.to = info.destination || t('UNKNOWN'); + route.status = t('AIS DESTINATION · CREW REPORTED'); + route.visible = Boolean(info.destination); + // No coordinates, so no arrow; clearing also publishes the card. + this.clearPredictiveRoute(); +} + /** Publish the route card and destination arrow when either changed. */ export function publishRouteView() { const signature = JSON.stringify(this.routeView); @@ -188,6 +399,7 @@ export function publishRouteView() { export function updateRoute(info) { const route = this.routeView; + route.kind = 'aircraft'; const origin = info?.route?.origin; const destination = info?.route?.destination; const validDestination = diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index b56a81892..d6088d559 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -146,16 +146,18 @@ export function formatCockpitWindDirection(value) { } /** - * The Cockpit entry for each subject it can ride: an aircraft's cockpit. - * Both entry buttons (Contacts and the Intel HUD readout) show it. + * The Cockpit entry for each subject it can ride: an aircraft's cockpit or a + * vessel's bridge. Both entry buttons (Contacts and + * the Intel HUD readout) show it. */ const COCKPIT_ENTRY_FACES = Object.freeze({ aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }), + vessel: Object.freeze({ icon: 'directions_boat', label: msg('BRIDGE') }), }); /** * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|null|undefined} kind + * @param {'aircraft'|'vessel'|null|undefined} kind * @returns {{icon: string, label: string}} */ export function cockpitEntryFace(kind) { diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 8675f3908..38a24ef8a 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -6,6 +6,12 @@ import { resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +/** The camera's own heading in degrees, or 0 when it has none. */ +function cameraHeadingDeg(camera) { + const heading = camera?.heading; + return Number.isFinite(heading) ? (heading * 180) / Math.PI : 0; +} + export function readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` // (see update()), so the cockpit's own handle is the tracked identity there. @@ -17,14 +23,20 @@ export function readAircraftInfo() { }); } +/** The selected vessel the bridge view would ride, or rides; null without one. */ +export function readVesselInfo() { + return this.services.aisLiveVesselsLayer?.getCockpitSubject?.() || null; +} + /** - * The layer the active view rides: the aircraft's own layer. It is read from - * the tracked entity's id once the layer has dropped its tracking, so a layer - * turned off still names itself. - * @returns {'flights'|'military'|null} + * The layer the active view rides: Live AIS Vessels on a bridge, else the + * aircraft's own layer. It is read from the tracked entity's id once the + * layer has dropped its tracking, so a layer turned off still names itself. + * @returns {'flights'|'military'|'ais-live-vessels'|null} */ export function readSubjectLayerId() { if (!this.active) return null; + if (this.subjectKind === 'vessel') return 'ais-live-vessels'; const layerId = this.readAircraftInfo()?.layerId; if (layerId) return layerId; const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':'); @@ -32,23 +44,26 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft. - * @returns {'aircraft'|null} + * The subject entry would take: a Cesium-tracked aircraft first, else the + * selected vessel. Vessels never take Cesium tracking, so an aircraft and a + * vessel cannot be live through the same owner. + * @returns {'aircraft'|'vessel'|null} */ export function resolveEntryKind() { - return this.readAircraftInfo() && this.viewer.trackedEntity?.position - ? 'aircraft' - : null; + if (this.readAircraftInfo() && this.viewer.trackedEntity?.position) + return 'aircraft'; + return this.readVesselInfo() ? 'vessel' : null; } export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active - ? String(info?.icao24 || '') + ? String(info?.icao24 || info?.mmsi || '') .trim() .toLowerCase() || null : null; const layerId = - active && ['flights', 'military'].includes(info?.layerId) + active && + ['flights', 'military', 'ais-live-vessels'].includes(info?.layerId) ? info.layerId : null; window.dispatchEvent( @@ -103,17 +118,29 @@ export function syncEntry() { const info = this.readAircraftInfo(); const trackedContact = !!(info && this.viewer.trackedEntity?.position); this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); + const bridgeContact = !trackedContact && !!this.readVesselInfo(); + let entryKind = null; + if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null; + else if (bridgeContact) + entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null; + const available = entryKind !== null; // Change-only publishes: this runs on a preUpdate cadence, and a fresh // presence every frame would re-render the views even when nothing // changed. (perf item 9) - if (this.presence.entryAvailable === available) return; + if ( + this.presence.entryAvailable === available && + this.presence.entryKind === entryKind + ) + return; this.presence.entryAvailable = available; + this.presence.entryKind = entryKind; this.publishPresenceView(); } export function navigateContext(direction, options = {}) { if (this.destroyed) return false; + // Contact navigation moves between aircraft; a bridge does not jump ship. + if (this.subjectKind === 'vessel') return false; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; @@ -132,6 +159,9 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { + // An aircraft tracked from the bridge ends it (see the vessel camera + // update); it is never adopted as the bridge's subject. + if (this.subjectKind === 'vessel') return false; const nextEntity = this.viewer.trackedEntity; if ( !this.active || @@ -160,13 +190,24 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { return true; } -export function enter() { +/** + * Enter first person on a tracked aircraft or, with none tracked, the + * selected vessel's bridge. + * @param {{kind?: 'aircraft'|'vessel'|null}} [options] Restrict + * entry to one subject kind; voice entry is aircraft-only. + * @returns {boolean} True when the view was entered. + */ +export function enter({ kind = null } = {}) { if (this.destroyed) return false; if (this.active) return false; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; + const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft'; + const vessel = subjectKind === 'vessel'; + const allowed = vessel ? this.isBridgeEntryAllowed() : this.isEntryAllowed(); + if (!allowed) return false; + const info = vessel ? this.readVesselInfo() : this.readAircraftInfo(); + // Only an aircraft is ridden through its Cesium entity. + const entity = vessel ? null : this.viewer.trackedEntity; + if (!info || (!vessel && !entity?.position)) return false; this.entryFocusOrigin = document.activeElement instanceof HTMLElement ? document.activeElement @@ -174,10 +215,26 @@ export function enter() { // Retire deferred navigation before cancelFlight can run its callbacks. this.onCameraTakeover?.(); this.viewer.camera.cancelFlight(); + this.subjectKind = subjectKind; + // Each session reads its own water: never a height left from the last. + this.vesselWaterline = null; + this.subjectId = vessel ? info.mmsi : null; this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); + if (entity) { + this.trackedEntityWasShown = entity.show; + entity.show = false; + } else { + this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.mmsi); + } + // A ship reporting neither heading nor course keeps the camera's own + // heading: no direction is made up for it. + this.heading = normalizeHeading( + Number.isFinite(info.track) + ? info.track + : vessel + ? cameraHeadingDeg(this.viewer.camera) + : 0, + ); this.lastFrameMs = performance.now(); this.lastCameraUpdateMs = 0; this.lastHudUpdateMs = 0; @@ -189,6 +246,8 @@ export function enter() { this.surfaceFallback = false; this.cockpitAnchorValid = false; this.lastCameraUpdateMs = 0; + // Every bridge opens looking over the bow. + this.resetLook({ immediate: true }); this.active = true; // Cockpit animates the camera from preUpdate every frame — preUpdate only // runs on rendered frames, so idle mode would freeze the cockpit solid. @@ -197,31 +256,58 @@ export function enter() { this.viewer.trackedEntity = undefined; this.viewer.scene.screenSpaceCameraController.enableInputs = false; document.body.classList.add('cockpit-mode'); + // Bridge styling reaches surfaces outside the HUD (exit row, side panels). + if (vessel) document.body.classList.add('cockpit-bridge'); // Activation hides the entry and TR-3B chip; clearing the chip's signature // lets the exit-path syncEntry publish it again. this._tr3bSignature = null; Object.assign(this.presence, { active: true, entryAvailable: false, + entryKind: null, + subjectKind, tr3b: null, }); this.publishPresenceView(); + // Only a bridge has a radar. + if (!vessel) this.publishRadar(null); this.signalItems = []; this.signalSignatures.clear(); this.showBriefPage(0); this.startBriefRotation(); - const trackLabel = - info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); const trackHeading = formatHeadingReadout(info.track ?? 0); - this.pushCockpitSignal( - 'track', - 'track', - t('TRACK ACQUIRED'), - t('{label} · COURSE {heading}°', { - label: trackLabel, - heading: trackHeading, - }), - ); + if (vessel) { + this.pushCockpitSignal( + 'track', + 'track', + t('TRACK ACQUIRED'), + // A ship with no true heading is held on its course over ground; one + // with neither is named without a direction. + info.trackSource === 'course' + ? t('{label} · COURSE {heading}°', { + label: info.name, + heading: trackHeading, + }) + : info.trackSource === 'heading' + ? t('{label} · HEADING {heading}°', { + label: info.name, + heading: trackHeading, + }) + : t('{label} · NO HEADING', { label: info.name }), + ); + } else { + const trackLabel = + info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); + this.pushCockpitSignal( + 'track', + 'track', + t('TRACK ACQUIRED'), + t('{label} · COURSE {heading}°', { + label: trackLabel, + heading: trackHeading, + }), + ); + } this.updateHud(info, performance.now(), true); this.setVisionMode(this.getInitialVisionMode()); this.scheduleContextLayout(); @@ -239,10 +325,15 @@ export function enter() { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null; this.active = false; + this.subjectKind = null; + this.vesselWaterline = null; + this.subjectId = null; this.services.releaseContinuousRender('cockpit'); this.trackedEntity = null; this.heading = null; + this.resetLook({ immediate: true }); this.cockpitAnchorValid = false; this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; @@ -255,22 +346,29 @@ export function exit({ restoreTracking = true } = {}) { this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); + document.body.classList.remove('cockpit-mode', 'cockpit-bridge'); this.onExited?.(); this.publishUtilityLayout(null); this.presence.active = false; + this.presence.subjectKind = null; this.publishPresenceView(); this.routeView.visible = false; this.clearPredictiveRoute(); + this.publishRadar(null); this.setVisionMode('optical'); this.viewer.scene.screenSpaceCameraController.enableInputs = true; if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; this.trackedEntityWasShown = true; + if (vesselSubject) + this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null); this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; this.restoreTrackingFrame(entity); + } else if (restoreTracking && vesselSubject) { + // Back to the selected vessel's map view, through the layer's own focus. + this.services.aisLiveVesselsLayer?.focusSelectedVessel?.(); } this.syncEntry(); // Back to whichever entry was used: the Contacts button or the readout's. diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index 03f38f911..2f4bfdb08 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -34,7 +34,13 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { context.value = next && markRaw(next); } - /** The presented instruments; null until the first HUD update. */ + /** + * The presented instruments; null until the first HUD update. Besides the + * readouts, `look: { offset, yawDeg, readout }` says whether a bridge view + * is turned off the bow, the yaw it is turned to (degrees in -180..180, + * starboard positive; the radar draws its look line there) and the worded + * readout. An aircraft's look always rests (`offset: false`). + */ const instruments = shallowRef(null); /** @param {?object} next A fresh presentation of the instruments. */ @@ -66,6 +72,21 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { signals.value = next && markRaw(next); } + /** + * The bridge radar: null outside a vessel bridge. Shape: + * `{ rangeNm, rangeLabel, ringsNm: number[], headingDeg, + * ownRelativeCourseDeg|null, + * contacts: [{ id, name, x, y, distanceNm, relativeBearingDeg, + * relativeCourseDeg|null, color, stale }] }` where x/y are heading-up + * screen fractions in [-1, 1] (x right, y up) of the range circle. + */ + const radar = shallowRef(null); + + /** @param {?object} next A fresh presentation of the bridge radar. */ + function publishRadar(next) { + radar.value = next && markRaw(next); + } + /** * The utility strip's measured layout: its top and height limits as CSS * lengths (null leaves the stylesheet's), whether only the expanded control @@ -101,6 +122,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { publishVision, signals, publishSignals, + radar, + publishRadar, utilityLayout, publishUtilityLayout, }; diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d7ad845b6..ae842d593 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -283,6 +283,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) color: var(--cockpit-accent); opacity: 0.9; } +/* A bridge has no altitude tape; course over ground replaces the readout. */ +#cockpit-altitude-rim[hidden] { + display: none; +} .cockpit-altitude-rim-label { position: absolute; top: 20%; @@ -2249,3 +2253,453 @@ body.cockpit-mode #intel-hud .hud-right-edge { animation: none; } } + +/* Ship bridge + A vessel's bridge reads as a wheelhouse window rather than an aircraft + visor: straight mullions, a straight speed tape and heading tape, square + panels, and a radar console in the bottom-right corner. Every rule is + scoped to the bridge, so the aircraft cockpit keeps its keyhole. */ +#cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 240px; + --cockpit-radar-label-height: 28px; + --cockpit-radar-right: clamp(24px, 4vw, 58px); + /* Above the position readout, which sits above the language picker. */ + --cockpit-radar-bottom: calc(max(clamp(22px, 4vh, 42px), 3.5rem) + 40px); + /* From the viewport bottom to clear above the console (border + gap). */ + --cockpit-radar-clearance: calc( + var(--cockpit-radar-bottom) + var(--cockpit-radar-size) + + var(--cockpit-radar-label-height) + 14px + ); +} +:root[data-ui-theme='cyber'] #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-right: 30px; +} +:root[data-ui-theme='cyber'] #cockpit-hud[data-subject='vessel']::before { + clip-path: none; +} +:root:not([data-ui-theme='cyber']) body.cockpit-bridge { + --panel-radius: 2px; + --btn-radius: 2px; +} +#cockpit-hud[data-subject='vessel'] .cockpit-bridge-frame { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: color-mix(in srgb, var(--cockpit-accent) 32%, transparent); + stroke-width: 1; +} +#cockpit-hud[data-subject='vessel'] .cockpit-bridge-frame path { + vector-effect: non-scaling-stroke; +} +#cockpit-hud[data-subject='vessel'] .cockpit-reticle { + width: 36px; + height: 14px; + border: 0; + border-radius: 0; + color: transparent; + background: + linear-gradient(var(--cockpit-accent), var(--cockpit-accent)) 50% 0 / 1px + 100% no-repeat, + linear-gradient(90deg, var(--cockpit-accent), var(--cockpit-accent)) 0 50% / + 100% 1px no-repeat; + font-size: 0; + box-shadow: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-reticle::before, +#cockpit-hud[data-subject='vessel'] .cockpit-reticle::after { + display: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-horizon-guide { + transform: translate(-50%, -50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-horizon-guide span, +#cockpit-hud[data-subject='vessel'] .cockpit-visor-status { + transform: none; +} + +/* Speed over ground: a straight vertical tape beside the left pane post. */ +#cockpit-hud[data-subject='vessel'] .cockpit-speed-rim { + top: 22vh; + right: auto; + left: calc(27vw + 14px); + width: 150px; + height: 46vh; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-label { + top: 0; + right: auto; + left: 0; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + :is(.cockpit-altitude-rim-rail, .cockpit-altitude-rim-scale) { + inset: 18px 0 0; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-rail { + background: linear-gradient(90deg, rgba(0, 7, 12, 0.3), transparent 70%); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-rail::before { + top: 0; + bottom: 0; + left: 0; + width: 0; + height: auto; + border: 0; + border-left: 2px solid + color-mix(in srgb, var(--cockpit-accent) 72%, transparent); + border-radius: 0; + box-shadow: -3px 0 12px + color-mix(in srgb, var(--cockpit-accent) 18%, transparent); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-scale + > span { + left: 2px; + transform: translateY(-50%); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-pointer { + z-index: 1; + top: calc(50% + 9px); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-pointer + strong { + min-width: 0; + padding: 2px 5px; + border: 1px solid var(--cockpit-accent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); +} + +/* Heading: a straight horizontal tape under flat readouts. */ +#cockpit-hud[data-subject='vessel'] .cockpit-compass { + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] :is(.cockpit-speed, .cockpit-altitude) { + transform: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape { + height: 44px; + margin-top: 0; + overflow: hidden; + /* The minor ticks below scroll by a fraction of the tape's width. */ + container-type: inline-size; + transform-style: flat; + -webkit-mask-image: linear-gradient( + 90deg, + transparent, + #000 12% 88%, + transparent + ); + mask-image: linear-gradient(90deg, transparent, #000 12% 88%, transparent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape::before { + inset: 0 0 auto; + height: 26px; + border-top: 1px solid + color-mix(in srgb, var(--cockpit-accent) 78%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--cockpit-accent) 22%, transparent); + border-radius: 0; + /* Minor ticks every 10 degrees, centred on the 30-degree divisions. */ + background: + linear-gradient( + 90deg, + transparent calc(50% - 0.5px), + color-mix(in srgb, var(--cockpit-accent) 46%, transparent) + calc(50% - 0.5px) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ) + 50% 0 / calc(14% / 3) 6px repeat-x, + rgba(0, 7, 12, 0.34); + /* Scrolled with the divisions (--tape-shift slots of 14% each). */ + background-position: + calc(50% + var(--tape-shift, 0) * 14cqw) 0, + 0 0; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span { + top: 0; + left: calc(50% + (var(--slot) * 14%)); + font-size: 11px; + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span::before { + height: 10px; + margin: 0 auto 5px; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span.active { + font-size: 15px; +} +/* The tape follows the look; the bow (prograde: a ring and centre dot) and + stern (retrograde: a ring and cross) marks sit under it at the ship's + heading and its reciprocal. */ +#cockpit-hud[data-subject='vessel'] .cockpit-compass-marker { + position: absolute; + top: 30px; + left: calc(50% + (var(--slot) * 14%)); + box-sizing: border-box; + width: 11px; + height: 11px; + border: 1.5px solid var(--cockpit-accent); + border-radius: 50%; + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-marker::before, +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + background: var(--cockpit-accent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-prograde::before { + width: 3px; + height: 3px; + transform: translate(-50%, -50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::before, +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + width: 1.5px; + height: 9px; + transform: translate(-50%, -50%) rotate(45deg); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +/* Square panels and buttons. */ +#cockpit-hud[data-subject='vessel'] + :is( + .cockpit-vision-controls, + .cockpit-vision-controls button, + .cockpit-card-actions button, + .cockpit-utility-glyph, + .cockpit-radio-transport button, + .cockpit-route-direction small, + #cockpit-brief-auto + ) { + border-radius: 2px; +} +body.cockpit-bridge #view-switcher button { + border-radius: 2px; +} + +/* The briefing card sits flat and shorter, above the radar console. Its top + leaves the utility strip room below the REC readout. */ +#cockpit-hud[data-subject='vessel'] .cockpit-signal-window { + bottom: var(--cockpit-radar-clearance); + max-height: max( + 44px, + min(30vh, 280px, calc(100vh - var(--cockpit-radar-clearance) - 290px)) + ); + border-radius: 2px; + transform: none; +} +/* Cyber anchors the card's top from the layout pass; bound its height. */ +:root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-signal-window { + bottom: auto; + max-height: max( + 44px, + min( + 30vh, + 280px, + calc( + 100vh - var(--cockpit-signal-top, clamp(255px, 28vh, 300px)) - + var(--cockpit-radar-clearance) + ) + ) + ); + clip-path: none; +} + +/* The radar console: CockpitRadar fills it, label row included. */ +#cockpit-hud[data-subject='vessel'] .cockpit-radar-console { + position: absolute; + z-index: 4; + right: var(--cockpit-radar-right); + bottom: var(--cockpit-radar-bottom); + display: grid; + width: var(--cockpit-radar-size); + height: calc(var(--cockpit-radar-size) + var(--cockpit-radar-label-height)); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 30%, transparent); + border-top-color: color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.68); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 0 28px rgba(0, 0, 0, 0.26); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + pointer-events: auto; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-radar-console:has(> .cockpit-radar[hidden]) { + display: none; +} +:root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-radar-console { + border-color: rgba(105, 203, 209, 0.34); + border-top-color: rgba(196, 84, 84, 0.82); + background: + linear-gradient(135deg, rgba(105, 203, 209, 0.07), transparent 38%), + rgba(8, 23, 27, 0.78); +} + +@media (min-width: 761px) { + /* The heading tape narrows to stay clear of the console. */ + #cockpit-hud[data-subject='vessel'] .cockpit-compass { + width: min( + 760px, + 72vw, + calc( + 100vw - 2 * + (var(--cockpit-radar-right) + var(--cockpit-radar-size) + 24px) + ) + ); + } + :root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-compass { + width: min( + 650px, + 56vw, + calc( + 100vw - 2 * + (var(--cockpit-radar-right) + var(--cockpit-radar-size) + 24px) + ) + ); + } +} +@media (min-width: 761px) and (min-height: 601px) { + /* The console takes the intel HUD's bottom-right corner. */ + body.cockpit-bridge #intel-hud .hud-bottom-right { + display: none; + } +} +@media (max-width: 1280px), (max-height: 820px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 192px; + --cockpit-radar-label-height: 24px; + } +} +@media (max-width: 960px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 152px; + --cockpit-radar-label-height: 22px; + } +} +/* Too small for a console: the briefing card returns to its usual place. */ +@media (max-width: 760px), (max-height: 600px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-clearance: var(--cockpit-bottom-rail-safe); + } + #cockpit-hud[data-subject='vessel'] .cockpit-radar-console { + display: none; + } +} + +/* Look around: the readout and LOOK AHEAD control sit beside the radar + console, out of the forward view, while the view is turned off the bow; + the radar scope draws the matching look line. */ +#cockpit-hud[data-subject='vessel'] .cockpit-look { + position: absolute; + right: calc(var(--cockpit-radar-right) + var(--cockpit-radar-size) + 14px); + bottom: var(--cockpit-radar-bottom); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + opacity: 0.78; +} +#cockpit-hud[data-subject='vessel'] .cockpit-look:focus-within, +#cockpit-hud[data-subject='vessel'] .cockpit-look:hover { + opacity: 1; +} +/* Without the console, the controls take its corner. */ +@media (max-width: 760px), (max-height: 600px) { + #cockpit-hud[data-subject='vessel'] .cockpit-look { + right: var(--cockpit-radar-right); + } +} +#cockpit-hud[data-subject='vessel'] .cockpit-look[hidden] { + display: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-look-readout { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 46%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.62); + color: var(--cockpit-accent); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + white-space: nowrap; +} +#cockpit-hud[data-subject='vessel'] .cockpit-look-ahead { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); + color: #f4fbff; + font: inherit; + font-size: 10px; + letter-spacing: 0.14em; + cursor: pointer; + pointer-events: auto; +} +#cockpit-hud[data-subject='vessel'] .cockpit-look-ahead:hover { + border-color: var(--cockpit-accent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-look-ahead:focus-visible { + outline: 2px solid var(--cockpit-accent); + outline-offset: 2px; +} +/* A narrow wedge from own ship with a line along its centre, eased with the + camera's turn. Both point up (ahead) before rotation. */ +#cockpit-hud[data-subject='vessel'] .cockpit-radar-look { + position: absolute; + inset: 0; + border-radius: 50%; + background: conic-gradient( + from -6deg, + color-mix(in srgb, var(--cockpit-accent) 18%, transparent) 0deg 12deg, + transparent 12deg + ); + pointer-events: none; + transition: transform 180ms ease-out; +} +#cockpit-hud[data-subject='vessel'] .cockpit-radar-look::before { + position: absolute; + bottom: 50%; + left: 50%; + width: 0; + height: 50%; + border-left: 1px dashed + color-mix(in srgb, var(--cockpit-accent) 80%, transparent); + content: ''; +} +@media (prefers-reduced-motion: reduce) { + #cockpit-hud[data-subject='vessel'] .cockpit-radar-look { + transition: none; + } +} diff --git a/src/ui/styles/cockpitRadar.css b/src/ui/styles/cockpitRadar.css new file mode 100644 index 000000000..2377c24ad --- /dev/null +++ b/src/ui/styles/cockpitRadar.css @@ -0,0 +1,193 @@ +/* Bridge radar internals (src/ui/views/panels/CockpitRadar.vue). The + Cockpit view places and sizes the console; the radar fills it, with a + square scope as large as the smaller side allows. */ +.cockpit-radar { + --radar-line: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 34%, + transparent + ); + --radar-faint: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 16%, + transparent + ); + --radar-readout-height: 22px; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + color: rgba(244, 251, 255, 0.82); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; +} +.cockpit-radar[hidden] { + display: none; +} +.cockpit-radar-scope { + position: relative; + flex: none; + width: min(100cqw, calc(100cqh - var(--radar-readout-height) - 4px)); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--radar-line); + border-radius: 50%; + background: radial-gradient( + circle, + rgba(0, 24, 32, 0.72) 0%, + rgba(0, 9, 14, 0.82) 100% + ); + box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.5); +} +.cockpit-radar-ring, +.cockpit-radar-sweep, +.cockpit-radar-own, +.cockpit-radar-contact { + position: absolute; + top: 50%; + left: 50%; +} +.cockpit-radar-ring { + border: 1px solid var(--radar-faint); + border-radius: 50%; + transform: translate(-50%, -50%); +} +.cockpit-radar-tick { + position: absolute; + inset: 0; +} +.cockpit-radar-tick::before { + position: absolute; + top: 0; + left: 50%; + width: 1px; + height: 5px; + background: var(--radar-line); + content: ''; +} +.cockpit-radar-tick-major::before { + height: 8px; + background: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 62%, + transparent + ); +} +.cockpit-radar-sweep { + width: 100%; + height: 100%; + margin: -50% 0 0 -50%; + border-radius: 50%; + /* The bright edge leads clockwise; the trail fades behind it. */ + background: conic-gradient( + transparent 0deg 270deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 4%, transparent) 300deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 26%, transparent) 357deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 70%, transparent) 360deg + ); + will-change: transform; +} +.cockpit-radar-heading-line { + position: absolute; + bottom: 50%; + left: 50%; + width: 1px; + height: 50%; + background: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 72%, + transparent + ); +} +.cockpit-radar-own { + width: 7px; + height: 7px; + margin: -3.5px 0 0 -3.5px; + border: 1px solid var(--cockpit-accent, #00dcff); + border-radius: 1px; + background: rgba(0, 9, 14, 0.9); +} +.cockpit-radar-contact { + --radar-contact-color: #9fb3c8; + width: 5px; + height: 5px; + margin: -2.5px 0 0 -2.5px; + border-radius: 50%; + background: var(--radar-contact-color); + box-shadow: 0 0 4px var(--radar-contact-color); + will-change: opacity; +} +.cockpit-radar-contact-stale { + border: 1px solid var(--radar-contact-color); + background: transparent; + box-shadow: none; +} +/* A course vector starts at the mark's centre and points along the course. */ +.cockpit-radar-vector { + position: absolute; + bottom: 50%; + left: 50%; + width: 1px; + height: 9px; + margin-left: -0.5px; + background: var(--radar-contact-color); + transform-origin: 50% 100%; +} +.cockpit-radar-own-vector { + height: 14px; + background: #f4fbff; +} +.cockpit-radar-readout { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 6px; + box-sizing: border-box; + width: 100%; + height: var(--radar-readout-height); + overflow: hidden; + white-space: nowrap; +} +.cockpit-radar-range { + flex: none; + min-width: 44px; + height: 20px; + padding: 0 6px; + border: 1px solid var(--radar-line); + border-radius: 2px; + background: rgba(0, 7, 12, 0.6); + color: #f4fbff; + font: inherit; + font-weight: 700; + letter-spacing: 0.1em; + cursor: pointer; + pointer-events: auto; +} +.cockpit-radar-range:hover, +.cockpit-radar-range:focus-visible { + border-color: var(--cockpit-accent, #00dcff); + color: #fff; + outline: none; +} +.cockpit-radar-rings, +.cockpit-radar-count { + overflow: hidden; + text-overflow: ellipsis; +} +.cockpit-radar-count { + color: var(--cockpit-accent, #00dcff); +} +@media (prefers-reduced-motion: reduce) { + .cockpit-radar-sweep { + display: none; + } +} diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 32f2166ee..0ec7e675a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -12,6 +12,7 @@ import { useCockpitStore } from '../stores/cockpit.js'; import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; +import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -23,13 +24,32 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; const { presence, instruments, route, vision, utilityLayout } = storeToRefs(useCockpitStore()); const active = computed(() => Boolean(presence.value?.active)); +// A vessel subject swaps the aviation readouts for bridge ones. +const bridge = computed(() => presence.value?.subjectKind === 'vessel'); +const subject = computed(() => (bridge.value ? 'vessel' : 'aircraft')); +const hudLabel = computed(() => + bridge.value ? t('Ship bridge view') : t('Aircraft cockpit view'), +); +const exitLabel = computed(() => + bridge.value ? t('Exit bridge view') : t('Exit cockpit view'), +); +const exitText = computed(() => + bridge.value ? t('EXIT BRIDGE') : t('EXIT COCKPIT'), +); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); const initialRoute = createRouteState(); const initialVision = createVisionState(); const hud = computed(() => instruments.value ?? initialInstruments); +// The bridge tape scrolls continuously: its minor ticks shift with the +// division nearest the caret. +const tapeShift = computed( + () => hud.value.compass?.find((division) => division.active)?.slot ?? 0, +); const direction = computed(() => (route.value ?? initialRoute).direction); +// Where a bridge view is turned; an aircraft's always rests. +const look = computed(() => hud.value.look ?? initialInstruments.look); const v = computed(() => vision.value ?? initialVision); const visionLabel = computed(() => t('Current cockpit vision style: {style}. Activate for next style.', { @@ -54,11 +74,20 @@ const tickStyle = (tick) => diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index c40665fe4..93e05abe4 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -20,8 +20,8 @@ import { cockpitEntryFace } from '../cockpitPresentation.js'; // publishes them; until it does, the panel shows its initial markup. const { view: ctx } = storeToRefs(useContextStore()); const { presence: cockpit } = storeToRefs(useCockpitStore()); -// The entry rides the tracked aircraft (cockpitEntryFace). -const entry = computed(() => cockpitEntryFace('aircraft')); +// The entry names the subject it would ride (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind)); // While the Bhote Koshi event is on, its panel is the rail's only content. const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore()); // The Scene runtime chip SceneControls presents while a scene plays. diff --git a/src/ui/views/IntelHudView.vue b/src/ui/views/IntelHudView.vue index 6f60bce76..d49f9aa6f 100644 --- a/src/ui/views/IntelHudView.vue +++ b/src/ui/views/IntelHudView.vue @@ -16,8 +16,8 @@ const view = computed(() => hud.value ?? EMPTY); // button, so first person needs no Contacts panel. The Cockpit controller // answers its clicks. const { presence: cockpit } = storeToRefs(useCockpitStore()); -// The entry rides the tracked aircraft (cockpitEntryFace). -const entry = computed(() => cockpitEntryFace('aircraft')); +// The entry names the subject it would ride (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind));