From daed0be99a03d8f416c59617f77c7cea455616eb Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:17:20 +0700 Subject: [PATCH 001/262] 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 002/262] 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 003/262] 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 004/262] 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 81f49406b80f2f4c8076ed739360f79ab2e2c34f Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 12:31:59 -0500 Subject: [PATCH 005/262] perf(flights): skip dead-reckoning contacts that stay behind the horizon The fleet tick dead-reckoned, floored and distance-tested every contact on every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all of that work produced a hidden billboard. A contact that is already hidden and still behind the horizon at its last position now re-reckons only on its turn in a staggered one-in-12-ticks rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive proximity queries (getNearby includeHidden, getAllPositions) read positions at most about a second old, well inside the 30 s poll's uncertainty. Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over Colombia at 4 km, same view before and after: fleet tick 7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms frame 20.6 -> 14.0 ms, 42.9 -> 57.8 FPS Teleporting the camera to Madrid showed 975 contacts within 100 ms. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++ docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + .../flights/hiddenContactRotation.test.mjs | 42 +++++++++++++++++++ src/layers/flights/policy.js | 31 ++++++++++++++ src/layers/flights/rendering.js | 24 +++++++++++ src/layers/flights/state.js | 4 ++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/layers/flights/hiddenContactRotation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..4cc44d40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Stop dead-reckoning every flight hidden behind the horizon on every fleet + tick. With the worldwide feed (~13k contacts, ~56 on screen over Colombia at + 4 km), the fleet pass drops from 7.7 ms to 2.0 ms per frame and its longest + tick from 51 ms to about 16 ms; the scene goes from 43 to 58 FPS. Hidden + contacts re-reckon about once a second, staggered, and appear as soon as + their position is in view. + - 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..ee4269a63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3469,7 +3469,7 @@ silently demoting every later lookup for the session. ### Motion & Symbology Correctness (June 10, 2026) -- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-12-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..a12d5124f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -308,6 +308,7 @@ "src/layers/flights/tracking.js", "src/layers/flights/rendering.js", "src/layers/flights/testing.js", + "src/layers/flights/hiddenContactRotation.test.mjs", "src/layers/flights/evidence.js", "src/layers/flights/ingestion.js", "src/layers/flights/queries.js", diff --git a/src/layers/flights/hiddenContactRotation.test.mjs b/src/layers/flights/hiddenContactRotation.test.mjs new file mode 100644 index 000000000..23e81c45b --- /dev/null +++ b/src/layers/flights/hiddenContactRotation.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + FLEET_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, +} from './policy.js'; + +// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation +// instead of every tick. Their positions still feed hidden-inclusive proximity +// queries, so the rotation must reach every contact about once a second, and +// it must spread them across ticks so no single tick carries the whole fleet. + +const icao = (n) => n.toString(16).padStart(6, '0'); + +test('the rotation revisits each hidden contact about once a second', () => { + const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS; + assert.ok(HIDDEN_CONTACT_DR_TICKS > 1); + assert.ok( + Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS, + ); +}); + +test('a contact keeps the same slot, inside the rotation', () => { + for (let n = 0; n < 500; n++) { + const phase = hiddenContactDrPhase(icao(n * 7919)); + assert.ok(Number.isInteger(phase)); + assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS); + assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase); + } +}); + +test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => { + const fleet = 13000; + const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0); + // Real ICAO24 addresses cluster by national block; sample several blocks. + for (let n = 0; n < fleet; n++) + perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++; + const fair = fleet / HIDDEN_CONTACT_DR_TICKS; + for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..04313c367 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -200,6 +200,37 @@ export const FLEET_DR_INTERVAL_MS = 80; export const ROTATION_REFRESH_MS = 1000; +/** + * @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the + * horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows + * well under 1% of them; the rest still need positions for hidden-inclusive + * proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the + * 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in + * every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all. + */ + +export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000; + +export const HIDDEN_CONTACT_DR_TICKS = Math.max( + 1, + Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS), +); + +/** + * Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden + * contact re-reckons on the same tick of every rotation. + * @param {string} id Contact id (ICAO24). + * @returns {number} + */ + +export function hiddenContactDrPhase(id) { + const text = String(id); + let hash = 0; + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return hash % HIDDEN_CONTACT_DR_TICKS; +} + /** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..2048128dd 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -33,6 +33,8 @@ import { COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, } from './policy.js'; export function createRendering({ @@ -811,6 +813,12 @@ export function createRendering({ } } + /** The contact's hidden-rotation slot, cached on its billboard. */ + function _hiddenDrPhase(bb, icao24) { + bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24); + return bb._gevHiddenDrPhase; + } + function _fleetTick() { if ( !flightState._viewer || @@ -931,9 +939,25 @@ export function createRendering({ for (const icao of toRelease) _releaseModel(icao); } + const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS; + for (const [icao24, bb] of flightState._billboards) { if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + // A contact still behind the horizon at its last position would only be + // re-reckoned to be hidden again, so off its turn it keeps that position + // (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view, + // and any contact with a fleet model, takes the full pass below at once. + if ( + !bb.show && + _hiddenDrPhase(bb, icao24) !== hiddenDrPhase && + !flightState._models.has(icao24) && + !occluder.isPointVisible( + flightState._cullPositions.get(icao24) || bb.position, + ) + ) + continue; + const info = flightState.records.data.get(icao24); const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 2978ceba4..d1adec064 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -204,6 +204,10 @@ export function createFlightState({ source, services }) { flightState._lastFleetTickMs = 0; + /** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */ + + flightState._fleetTickSeq = 0; + /** @type {string} Camera pose signature at the last rotation pass */ flightState._lastCamPoseSig = ''; From aa745bcee5c4f76353c9426fb9055cb5737f0fc0 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:02:05 +0200 Subject: [PATCH 006/262] data: refresh Norway datacenters from Geofabrik extract Add a maintainer script that merges a pinned, MD5-verified Geofabrik Norway extract (osmium tags-filter telecom/building=data_center) into the bundled datacenter layer. Only features inside the Geofabrik Norway boundary are touched; unchanged features keep their original line. norway-261005 (OSM 2026-10-05) adds Troll Housing (w1551029155) and updates tags on four existing features. Provenance is recorded in the dataset README and the bundled count moves from 4,351 to 4,352. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + DATA_SOURCES.md | 2 +- README.md | 2 +- docs/INFRASTRUCTURE-LAYERS.md | 2 +- scripts/build-datacenters-norway.py | 196 ++++++++++++++++++ src/data/infrastructure.test.mjs | 2 +- src/data/local_data/datacenters/README.md | 16 +- .../datacenters/datacenters.geojsonl | 9 +- 8 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 scripts/build-datacenters-norway.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..031e36751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract + (2026-10-05). Troll Housing joins the layer (4,352 features) and four + Norwegian sites pick up current OSM tags; no site moved or was removed. + `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, + touching only features inside Geofabrik's Norway boundary, and the dataset + README records the extract, checksum and filter. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..b4f0dc0cc 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -112,7 +112,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | Dataset | Folder | License | Commercial use? | Attribution | | --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | -| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | +| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **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 | | **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" | diff --git a/README.md b/README.md index 724ea18cb..0500645af 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._ -**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712). +**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712). ![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif) diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 76567e605..1166a9e69 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -58,6 +58,6 @@ factory and supplies its existing functions. Other consumers should use the pack exports, which do not import standalone application globals. Dataset files and source/license notices remain under `src/data/local_data/`. -Their input counts are 4,351 datacenter features and 704 dam features. Cesium may +Their input counts are 4,352 datacenter features and 704 dam features. Cesium may expand multipart geometries into multiple entities; entity and stem counts are different measurements. This change does not refresh or relicense the datasets. diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py new file mode 100644 index 000000000..01a891a8b --- /dev/null +++ b/scripts/build-datacenters-norway.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Maintainer-only Norway refresh of the bundled datacenter extract (osmium-tool). + +Downloads a pinned Geofabrik Norway extract, verifies its MD5, filters +telecom=data_center / building=data_center with osmium, and merges the result +into datacenters.geojsonl: features inside the Geofabrik Norway boundary are +added, updated or removed; every other line is kept byte-for-byte. +""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import shutil +import subprocess +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +EXTRACT = 'norway-261005' +GEOFABRIK = 'https://download.geofabrik.de/europe/' +DATASET = ROOT / 'src/data/local_data/datacenters/datacenters.geojsonl' +CONTACT_TAGS = {'email', 'phone', 'fax', 'mobile', 'whatsapp', 'contact:email', 'contact:phone', + 'contact:fax', 'contact:mobile', 'contact:whatsapp'} +SAME_GEOMETRY_METERS = 0.05 + + +def fetch(url, target): + if not target.exists(): + with urllib.request.urlopen(url) as response, open(str(target) + '.part', 'wb') as out: + shutil.copyfileobj(response, out) + Path(str(target) + '.part').rename(target) + return target + + +def md5(path): + digest = hashlib.md5() + with open(path, 'rb') as handle: + for block in iter(lambda: handle.read(1 << 20), b''): + digest.update(block) + return digest.hexdigest() + + +def read_poly(path): + """Geofabrik .poly -> list of (ring, is_hole).""" + rings, ring, hole = [], None, False + for raw in path.read_text().splitlines()[1:]: + line = raw.strip() + if ring is None: + if line and line != 'END': + ring, hole = [], line.startswith('!') + elif line == 'END': + rings.append((ring, hole)) + ring = None + elif line: + lon, lat = (float(v) for v in line.split()) + ring.append((lon, lat)) + return rings + + +def inside(rings, lon, lat): + def in_ring(ring): + hit = False + for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]): + if (y1 > lat) != (y2 > lat) and lon < x1 + (lat - y1) * (x2 - x1) / (y2 - y1): + hit = not hit + return hit + return any(in_ring(r) for r, h in rings if not h) and not any(in_ring(r) for r, h in rings if h) + + +def points(coordinates): + if isinstance(coordinates[0], (int, float)): + return [coordinates] + return [p for part in coordinates for p in points(part)] + + +def osm_key(feature): + osm_id = feature['properties']['osm_id'] + if osm_id < 0: + return 'r%d' % -osm_id + return ('n' if feature['geometry']['type'] == 'Point' else 'w') + str(osm_id) + + +def export(osmium, pbf, cache): + filtered = cache / (EXTRACT + '-datacenters.osm.pbf') + exported = cache / (EXTRACT + '-datacenters.geojsonseq') + subprocess.run([osmium, 'tags-filter', '-O', '-o', str(filtered), str(pbf), + 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) + subprocess.run([osmium, 'export', '-O', '-f', 'geojsonseq', '--add-unique-id=type_id', + '-o', str(exported), str(filtered)], check=True) + fresh = {} + for line in exported.read_text().splitlines(): + line = line.lstrip('\x1e').strip() + if not line: + continue + feature = json.loads(line) + tags = {k: v for k, v in feature['properties'].items() if k not in CONTACT_TAGS} + if tags.get('telecom') != 'data_center' and tags.get('building') != 'data_center': + continue # untagged member ways exported only for geometry + key = feature['id'] + geometry = feature['geometry'] + if key[0] == 'a': # osmium area id: way * 2 or relation * 2 + 1 + number = int(key[1:]) + key = ('w%d' % (number // 2)) if number % 2 == 0 else ('r%d' % (number // 2)) + if key[0] == 'r': + tags = {**tags, 'type': 'multipolygon'} + if geometry['type'] == 'MultiPolygon' and len(geometry['coordinates']) == 1: + geometry = {'type': 'Polygon', 'coordinates': geometry['coordinates'][0]} + elif key in fresh or geometry['type'] == 'LineString': + continue # closed ways are taken from their area export + fresh[key] = {'geometry': geometry, 'tags': tags} + return fresh + + +def same_geometry(old, new): + """Same type and vertex set within tolerance; ring start and winding may differ.""" + if old['type'] != new['type']: + return False + a, b = points(old['coordinates']), points(new['coordinates']) + if len(a) != len(b): + return False + + def meters(p, q): + dx = (p[0] - q[0]) * 111320 * math.cos(math.radians(p[1])) + return math.hypot(dx, (p[1] - q[1]) * 110540) + return all(min(meters(p, q) for q in b) <= SAME_GEOMETRY_METERS for p in a) and \ + all(min(meters(q, p) for p in a) <= SAME_GEOMETRY_METERS for q in b) + + +def feature_line(key, sequence, fresh): + osm_id = int(key[1:]) * (-1 if key[0] == 'r' else 1) + feature = {'id': osm_id, 'type': 'Feature', 'geometry': fresh['geometry'], + 'properties': {'id': sequence, 'tags': fresh['tags'], 'type': 'data_center', + 'osm_id': osm_id}} + return json.dumps(feature, separators=(',', ':'), ensure_ascii=False) + + +def merge(lines, fresh, rings): + out, seen, sequence = [], set(), 0 + report = {'kept': 0, 'updated': [], 'removed': [], 'added': []} + for line in lines: + feature = json.loads(line) + sequence = max(sequence, feature['properties']['id']) + key = osm_key(feature) + if key in fresh: + seen.add(key) + old_tags = feature['properties']['tags'] + if old_tags == fresh[key]['tags'] and same_geometry(feature['geometry'], fresh[key]['geometry']): + out.append(line) + report['kept'] += 1 + else: + out.append(feature_line(key, feature['properties']['id'], fresh[key])) + report['updated'].append(key) + continue + lon, lat = points(feature['geometry']['coordinates'])[0][:2] + if inside(rings, lon, lat): + report['removed'].append(key) + continue + out.append(line) + for key in sorted(set(fresh) - seen, key=lambda k: (k[0], int(k[1:]))): + sequence += 1 + out.append(feature_line(key, sequence, fresh[key])) + report['added'].append(key) + if len({osm_key(json.loads(line)) for line in out}) != len(out): + raise ValueError('Duplicate OSM identity') + return out, report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache', type=Path, required=True) + parser.add_argument('--osmium', default=shutil.which('osmium') or 'osmium') + parser.add_argument('--verify', action='store_true') + args = parser.parse_args() + args.cache.mkdir(parents=True, exist_ok=True) + pbf = fetch(GEOFABRIK + EXTRACT + '.osm.pbf', args.cache / (EXTRACT + '.osm.pbf')) + expected = fetch(GEOFABRIK + EXTRACT + '.osm.pbf.md5', + args.cache / (EXTRACT + '.osm.pbf.md5')).read_text().split()[0] + actual = md5(pbf) + if actual != expected: + raise RuntimeError('MD5 mismatch for %s: %s != %s' % (pbf.name, actual, expected)) + rings = read_poly(fetch(GEOFABRIK + 'norway.poly', args.cache / 'norway.poly')) + fresh = export(args.osmium, pbf, args.cache) + current = [line for line in DATASET.read_text().splitlines() if line.strip()] + merged, report = merge(current, fresh, rings) + payload = '\n'.join(merged) + '\n' + if args.verify: + if payload != DATASET.read_text(): + raise SystemExit('datacenters.geojsonl is not the %s merge' % EXTRACT) + else: + DATASET.write_text(payload) + print(json.dumps({'extract': EXTRACT, 'md5': actual, 'norway': len(fresh), + 'features': len(merged), **report})) + + +if __name__ == '__main__': + main() diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 45fb3d317..c7f1b5a23 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi test('dataset URLs still name the complete bundled sources', () => { for (const [file, count] of [ - ['datacenters', 4351], + ['datacenters', 4352], ['dams', 704], ]) { const lines = readFileSync( diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 454a15007..ef0720ae4 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -5,7 +5,7 @@ layer. - Source: OpenStreetMap contributors - License: Open Database License (ODbL) 1.0 -- Feature count: 4,351 +- Feature count: 4,352 - Runtime file: `datacenters.geojsonl` The public-release snapshot removes contact-oriented tags such as email, phone, @@ -16,3 +16,17 @@ ODbL 1.0 with the required OpenStreetMap attribution. The original extraction date and query were not recorded alongside this snapshot. Future refreshes should record both before replacing the file. + +## Norway refresh + +Features inside the Geofabrik Norway boundary (`norway.poly`, which includes +Svalbard) were refreshed separately, so this file mixes two vintages: Norway +reflects OSM as of the extract below, everything else the original snapshot. + +- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data + 2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0` +- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` +- Contact tags are dropped as above; unchanged features keep their original + line, so geometry is only rewritten when tags or vertices actually changed. +- Rebuild or check: `python3 scripts/build-datacenters-norway.py --cache ` + (add `--verify` to check without writing; requires `osmium-tool`) diff --git a/src/data/local_data/datacenters/datacenters.geojsonl b/src/data/local_data/datacenters/datacenters.geojsonl index f7f5fc89f..b02bd57b3 100644 --- a/src/data/local_data/datacenters/datacenters.geojsonl +++ b/src/data/local_data/datacenters/datacenters.geojsonl @@ -3770,15 +3770,15 @@ {"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}} {"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}} {"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}} -{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}} +{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}} {"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}} {"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}} -{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}} +{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}} {"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}} {"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}} {"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}} -{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}} -{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}} +{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}} +{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}} {"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}} {"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}} {"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}} @@ -4349,3 +4349,4 @@ {"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}} {"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}} {"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}} +{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}} From 5b5d942d95dddd56b3bacb465c69098913688d1f Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:23:02 +0200 Subject: [PATCH 007/262] fix(data): make the Norway datacenter script portable and re-runnable Read and write files as UTF-8 with LF line endings so the script and --verify work on Windows, where the default codec is cp1252 (thanks to quanticsoul4772 for the report). Geofabrik keeps only its newest dated extract, so --extract now defaults to the current one and accepts a dated name for --verify while it is listed; the dataset README says so. Downloads time out instead of hanging. --- CHANGELOG.md | 1 + scripts/build-datacenters-norway.py | 52 +++++++++++++++-------- src/data/local_data/datacenters/README.md | 8 +++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a9da4e6..067ca16b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, touching only features inside Geofabrik's Norway boundary, and the dataset README records the extract, checksum and filter. + - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py index 01a891a8b..687395f7b 100644 --- a/scripts/build-datacenters-norway.py +++ b/scripts/build-datacenters-norway.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Maintainer-only Norway refresh of the bundled datacenter extract (osmium-tool). -Downloads a pinned Geofabrik Norway extract, verifies its MD5, filters +Downloads a dated Geofabrik Norway extract, verifies its MD5, filters telecom=data_center / building=data_center with osmium, and merges the result into datacenters.geojsonl: features inside the Geofabrik Norway boundary are added, updated or removed; every other line is kept byte-for-byte. + +Geofabrik keeps only its newest dated extract, so --extract defaults to the +current one and a recorded extract can be re-verified only while it is listed. """ import argparse import hashlib @@ -16,17 +19,28 @@ import subprocess import urllib.request ROOT = Path(__file__).resolve().parent.parent -EXTRACT = 'norway-261005' GEOFABRIK = 'https://download.geofabrik.de/europe/' DATASET = ROOT / 'src/data/local_data/datacenters/datacenters.geojsonl' CONTACT_TAGS = {'email', 'phone', 'fax', 'mobile', 'whatsapp', 'contact:email', 'contact:phone', 'contact:fax', 'contact:mobile', 'contact:whatsapp'} SAME_GEOMETRY_METERS = 0.05 +TIMEOUT_SECONDS = 60 + + +def latest_extract(): + """Resolve norway-latest to the dated name Geofabrik redirects it to.""" + request = urllib.request.Request(GEOFABRIK + 'norway-latest.osm.pbf', method='HEAD') + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: + name = response.geturl().rsplit('/', 1)[-1] + if not name.startswith('norway-') or not name.endswith('.osm.pbf') or name == 'norway-latest.osm.pbf': + raise RuntimeError('Unexpected Geofabrik latest target: %s' % name) + return name[:-len('.osm.pbf')] def fetch(url, target): if not target.exists(): - with urllib.request.urlopen(url) as response, open(str(target) + '.part', 'wb') as out: + with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response, \ + open(str(target) + '.part', 'wb') as out: shutil.copyfileobj(response, out) Path(str(target) + '.part').rename(target) return target @@ -43,7 +57,7 @@ def md5(path): def read_poly(path): """Geofabrik .poly -> list of (ring, is_hole).""" rings, ring, hole = [], None, False - for raw in path.read_text().splitlines()[1:]: + for raw in path.read_text(encoding='utf-8').splitlines()[1:]: line = raw.strip() if ring is None: if line and line != 'END': @@ -80,15 +94,15 @@ def osm_key(feature): return ('n' if feature['geometry']['type'] == 'Point' else 'w') + str(osm_id) -def export(osmium, pbf, cache): - filtered = cache / (EXTRACT + '-datacenters.osm.pbf') - exported = cache / (EXTRACT + '-datacenters.geojsonseq') +def export(osmium, pbf, cache, extract): + filtered = cache / (extract + '-datacenters.osm.pbf') + exported = cache / (extract + '-datacenters.geojsonseq') subprocess.run([osmium, 'tags-filter', '-O', '-o', str(filtered), str(pbf), 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) subprocess.run([osmium, 'export', '-O', '-f', 'geojsonseq', '--add-unique-id=type_id', '-o', str(exported), str(filtered)], check=True) fresh = {} - for line in exported.read_text().splitlines(): + for line in exported.read_text(encoding='utf-8').splitlines(): line = line.lstrip('\x1e').strip() if not line: continue @@ -169,26 +183,30 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--cache', type=Path, required=True) parser.add_argument('--osmium', default=shutil.which('osmium') or 'osmium') + parser.add_argument('--extract', default='latest', + help="dated Geofabrik name such as norway-261005, or 'latest'") parser.add_argument('--verify', action='store_true') args = parser.parse_args() args.cache.mkdir(parents=True, exist_ok=True) - pbf = fetch(GEOFABRIK + EXTRACT + '.osm.pbf', args.cache / (EXTRACT + '.osm.pbf')) - expected = fetch(GEOFABRIK + EXTRACT + '.osm.pbf.md5', - args.cache / (EXTRACT + '.osm.pbf.md5')).read_text().split()[0] + extract = latest_extract() if args.extract == 'latest' else args.extract + pbf = fetch(GEOFABRIK + extract + '.osm.pbf', args.cache / (extract + '.osm.pbf')) + expected = fetch(GEOFABRIK + extract + '.osm.pbf.md5', + args.cache / (extract + '.osm.pbf.md5')).read_text(encoding='utf-8').split()[0] actual = md5(pbf) if actual != expected: raise RuntimeError('MD5 mismatch for %s: %s != %s' % (pbf.name, actual, expected)) rings = read_poly(fetch(GEOFABRIK + 'norway.poly', args.cache / 'norway.poly')) - fresh = export(args.osmium, pbf, args.cache) - current = [line for line in DATASET.read_text().splitlines() if line.strip()] + fresh = export(args.osmium, pbf, args.cache, extract) + current = [line for line in DATASET.read_text(encoding='utf-8').splitlines() if line.strip()] merged, report = merge(current, fresh, rings) payload = '\n'.join(merged) + '\n' if args.verify: - if payload != DATASET.read_text(): - raise SystemExit('datacenters.geojsonl is not the %s merge' % EXTRACT) + if payload != DATASET.read_text(encoding='utf-8'): + raise SystemExit('datacenters.geojsonl is not the %s merge' % extract) else: - DATASET.write_text(payload) - print(json.dumps({'extract': EXTRACT, 'md5': actual, 'norway': len(fresh), + with open(DATASET, 'w', encoding='utf-8', newline='\n') as handle: + handle.write(payload) + print(json.dumps({'extract': extract, 'md5': actual, 'norway': len(fresh), 'features': len(merged), **report})) diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index ef0720ae4..8b803dcc7 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -28,5 +28,9 @@ reflects OSM as of the extract below, everything else the original snapshot. - Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` - Contact tags are dropped as above; unchanged features keep their original line, so geometry is only rewritten when tags or vertices actually changed. -- Rebuild or check: `python3 scripts/build-datacenters-norway.py --cache ` - (add `--verify` to check without writing; requires `osmium-tool`) +- Refresh: `python3 scripts/build-datacenters-norway.py --cache ` uses + Geofabrik's current Norway extract and prints the dated name it used; record + that name, its OSM timestamp and MD5 here. Requires `osmium-tool`. +- Check: add `--extract --verify` to compare without writing. + Geofabrik keeps only its newest dated extract, so a recorded extract can be + re-verified only while it is still listed; the MD5 above identifies it. 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/262] 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/262] 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/262] 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 69777700674fa5f4dfcc3729a553b96a9a91ffe0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:42 +0700 Subject: [PATCH 011/262] 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 4318f51de4114bfb68137ff48bedd070b976f771 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:03:23 +0200 Subject: [PATCH 012/262] docs: move the changelog entry to changelog.d --- CHANGELOG.md | 7 ------- changelog.d/953-datacenters-norway-refresh.md | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 changelog.d/953-datacenters-norway-refresh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a285bc555..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,13 +284,6 @@ ## [Unreleased] -- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract - (2026-10-05). Troll Housing joins the layer (4,352 features) and four - Norwegian sites pick up current OSM tags; no site moved or was removed. - `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, - touching only features inside Geofabrik's Norway boundary, and the dataset - README records the extract, checksum and filter. - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without diff --git a/changelog.d/953-datacenters-norway-refresh.md b/changelog.d/953-datacenters-norway-refresh.md new file mode 100644 index 000000000..48a019605 --- /dev/null +++ b/changelog.d/953-datacenters-norway-refresh.md @@ -0,0 +1,6 @@ +- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract + (2026-10-05). Troll Housing joins the layer (4,352 features) and four + Norwegian sites pick up current OSM tags; no site moved or was removed. + `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, + touching only features inside Geofabrik's Norway boundary, and the dataset + README records the extract, checksum and filter (trygvehhh, #953). 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/262] 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 25ec9e10a35ea3b850d078b9d3de36735078899d Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 16:53:24 +0200 Subject: [PATCH 014/262] feat(cctv): vendor the Bosch MIC PTZ datasheet records the Amsterdam register names Adds MIC-7522-Z30B, MIC-7502-Z30B and MIC-7130-PB4 (MIC IP starlight 7000 HD) from the CCTV Camera Database v2.29.0 model-enrichment extract. Colour siblings (B/G/W) share one datasheet, so they and the register's x wildcard (MIC-7522-Z30x) are aliases of one record. The MIC datasheets state FOV without an axis, so these records add specs but no horizontal FOV range. Refreshes the dataset size in the module header and DATA_SOURCES.md. --- DATA_SOURCES.md | 7 +-- src/layers/cctv/modelSpecs.js | 67 ++++++++++++++++++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 28 ++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..a9d676c85 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -208,7 +208,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) | | **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | -| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | +| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -236,11 +236,12 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under **CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is fetched at runtime. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 2d93166a7..89fcf58d2 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,18 +14,25 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV, as of v2.26.0). This module vendors the records that the currently integrated public + * of 29,000+ camera models transcribed from manufacturer datasheets (76% with + * FOV, as of v2.29.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data; packs that - * publish other hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC + * PTZs in the City of Amsterdam camera register; packs that publish other + * hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ +/** + * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the + * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour + * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * and the `x` wildcard registries write for the colour letter, are listed as + * aliases of the one vendored record. + */ const RECORDS = [ { id: 'axis-q6135-le', @@ -264,6 +271,56 @@ const RECORDS = [ autotracking: true, environment: ['outdoor'], }, + { + id: 'bosch-mic-7130-pb4', + brand: 'Bosch', + model: 'MIC-7130-PB4', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/', + aliases: [ + 'MIC IP starlight 7000 HD', + 'MIC-7130-PG4', + 'MIC-7130-PW4', + 'MIC-7130-Px4', + ], + fov_deg: '2.1-59', + resolution: { mp: 1, w: 1280, h: 720, label: '720p' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7502-z30b', + brand: 'Bosch', + model: 'MIC-7502-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/', + aliases: ['MIC-7502-Z30G', 'MIC-7502-Z30W', 'MIC-7502-Z30x'], + fov_deg: '2.3-63.7', + resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7522-z30b', + brand: 'Bosch', + model: 'MIC-7522-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/', + aliases: ['MIC-7522-Z30G', 'MIC-7522-Z30W', 'MIC-7522-Z30x'], + fov_deg: '2.1-58.3', + // i18n-ignore: datasheet label + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + ndaa_compliant: true, + }, { id: 'cohu-3920-idome', brand: 'Cohu', diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 9fbdb00dd..852c6ad64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -245,6 +245,34 @@ test('King County snapshot model strings still resolve', () => { ); }); +test('Amsterdam camera register model strings resolve', () => { + // The register writes the Bosch MIC colour letter as `x`. + assert.equal( + lookupModelSpec('Bosch MIC-7522-Z30x')?.id, + 'bosch-mic-7522-z30b', + ); + assert.equal( + lookupModelSpec('Bosch MIC-7502-Z30x')?.id, + 'bosch-mic-7502-z30b', + ); + assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4'); + assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b'); + // Its ANPR units are not in the table: unknown, never a guess. + assert.equal(lookupModelSpec('CSG-8800'), null); + // The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand + // is needed) stay apart. + assert.equal( + lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-mic-7130-pb4', + ); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-autodome-ip-starlight-7000-hd', + ); + // The MIC datasheets state FOV without an axis, so no horizontal range. + assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 6a7597d344960f7495fb3bdee2b34eed0a917c17 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 17:01:50 +0200 Subject: [PATCH 015/262] docs(changelog): fragment for #1319 --- changelog.d/1319-cctv-bosch-mic-specs.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1319-cctv-bosch-mic-specs.md diff --git a/changelog.d/1319-cctv-bosch-mic-specs.md b/changelog.d/1319-cctv-bosch-mic-specs.md new file mode 100644 index 000000000..bf1bd0b7f --- /dev/null +++ b/changelog.d/1319-cctv-bosch-mic-specs.md @@ -0,0 +1,3 @@ +- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera + register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour + wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319). From dec6cb937ec290d514f2c44d8cdb87a1b4f7fba7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 22 Oct 2026 23:27:00 +0100 Subject: [PATCH 016/262] test(cctv): verify Bosch MIC aliases and preserve estimated optics --- DATA_SOURCES.md | 10 ++++--- docs/CURRENT-STATE.md | 4 +++ src/layers/cctv/modelSpecs.js | 20 +++++++------- src/layers/cctv/modelSpecs.test.mjs | 41 +++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a9d676c85..1602cfa49 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -236,13 +236,15 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under -**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is -fetched at runtime. The records describe camera products (optics, resolution, night vision) +**CC0-1.0**. Only the records for hardware named by the integrated public catalogs (King +County, Sarasota County and Sioux Falls), plus the City of Amsterdam register proposed +in [#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251), are bundled, compiled into the module. Nothing is +fetched at runtime. The MIC specification fields are unchanged; product titles are shortened +to model codes and colour aliases are added locally. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9db91490..13174d759 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4585,6 +4585,10 @@ silently demoting every later lookup for the session. multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). + The table also includes Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam + register proposed in #251, including its `x` colour aliases (#1319). Their axis-less + datasheet FOV does not supply a horizontal capability range or replace the estimated + FOV. Adding these records does not enable the proposed source pack. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 89fcf58d2..ba6702584 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -12,24 +12,26 @@ * Cameras without an identified model are untouched — unknown never implies * anything. * - * Spec records are vendored verbatim from the CCTV Camera Database + * Specification fields are vendored from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 29,000+ camera models transcribed from manufacturer datasheets (76% with - * FOV, as of v2.29.0). This module vendors the records that the currently integrated public + * FOV, as of v2.29.0). This module vendors records that the integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC - * PTZs in the City of Amsterdam camera register; packs that publish other - * hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC + * PTZs for the City of Amsterdam camera register proposed in #251; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ /** - * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the - * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour - * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * Records from the dataset's model-enrichment extract (v2.26.0; the Bosch MIC + * records from v2.29.0). MIC specification fields are unchanged; product + * titles are shortened to model codes and colour aliases are added locally. + * Bosch MIC order codes end in a colour letter (B black, G grey, W white) + * on one shared datasheet; those siblings, * and the `x` wildcard registries write for the colour letter, are listed as * aliases of the one vendored record. */ @@ -488,7 +490,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => { * guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @param {string} [manufacturer] - The source's manufacturer, when published. - * @returns {Object|null} The verbatim spec record, or null when unknown. + * @returns {Object|null} The matched spec record, or null when unknown. */ export function lookupModelSpec(model, manufacturer) { const query = words(model).map(stripVariantSuffix); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 852c6ad64..d76f37c24 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -273,6 +273,47 @@ test('Amsterdam camera register model strings resolve', () => { assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); }); +test('Bosch MIC colour aliases resolve without accepting unknown variants', () => { + for (const [pattern, id] of [ + ['MIC-7130-P?4', 'bosch-mic-7130-pb4'], + ['MIC-7502-Z30?', 'bosch-mic-7502-z30b'], + ['MIC-7522-Z30?', 'bosch-mic-7522-z30b'], + ]) { + for (const colour of ['B', 'G', 'W', 'x']) { + const model = pattern.replace('?', colour); + assert.equal(lookupModelSpec(model)?.id, id, model); + assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id); + assert.equal(lookupModelSpec(model, 'Bosch')?.id, id); + assert.equal(lookupModelSpec(model, 'Axis'), null); + assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null); + } + assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null); + } +}); + +test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => { + for (const model of [ + 'Bosch MIC-7130-Px4', + 'Bosch MIC-7502-Z30x', + 'Bosch MIC-7522-Z30x', + ]) { + for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) { + const [camera] = browserCatalog().buildCatalogFromSources([ + { id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose }, + ]); + assert.ok(camera.spec, model); + assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model); + assert.equal(camera.fovSource, undefined); + assert.equal(camera.specFovMinDeg, undefined); + assert.equal(camera.specFovMaxDeg, undefined); + assert.equal(horizontalFovRangeDeg(camera.spec), null); + assert.equal(fixedHorizontalFovDeg(camera.spec), null); + assert.equal(specFovCapabilityToken(camera), null); + assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/); + } + } +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 38d29f2beb22b5200b794751b32f1f21a0409e25 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 13:09:32 +0400 Subject: [PATCH 017/262] feat(fires): FRP intensity filter, confidence switch and six-stop color ramp (HAL-676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color every FIRMS detection by fire radiative power on the Portolan cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), replacing the three warm stops that made a 2 MW and a 40 MW fire look alike. Heat cells, glow sprites and card accents all read the same ramp (src/data/firmsIntensity.js). Add a minimum-FRP log slider whose track is painted with the ramp (so it doubles as the legend), an All / Nominal+ / High confidence switch and an "N of M shown" counter to the Active Fires row. The filter runs once in ingestion.applyFilter(), so markers, cards, heat cells, the context store, the top-fires query and the analyst records all see one set. Default is 0 MW / All: many of the strongest fires are rated low. Persist both options in the layer state (token w: minFrp `f`, confidence `c`), including the short share codec, and give the agent's get_active_fires tool matching min_frp_mw / confidence arguments. Layer panel gains generic `slider` and `segmented` row descriptors (src/ui/rowFilterControls.js). New strings in es / ru / zh-CN. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 17 +++ src/data/firmsCards.test.mjs | 22 ++- src/data/firmsIntensity.js | 209 +++++++++++++++++++++++++++++ src/data/firmsIntensity.test.mjs | 207 ++++++++++++++++++++++++++++ src/data/firmsLabels.js | 23 ++-- src/data/firmsLabels.test.mjs | 15 ++- src/data/layerState.js | 21 ++- src/data/layerState.test.mjs | 83 +++++++++++- src/i18n/locales/es.json | 14 +- src/i18n/locales/ru.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/i18n/same-as-english.json | 5 +- src/layers/firms/ingestion.js | 32 ++++- src/layers/firms/lifecycle.js | 4 + src/layers/firms/model.js | 57 +++++--- src/layers/firms/policy.js | 19 ++- src/layers/firms/queries.js | 142 +++++++++++++++++++- src/layers/firms/rendering.js | 5 +- src/layers/firms/state.js | 28 ++++ src/share/viewCodec.js | 1 + src/tools/queries/hazards.js | 64 ++++++++- src/tools/queries/hazards.test.mjs | 36 +++++ src/ui/layerPanel.js | 54 +++++++- src/ui/rowFilterControls.js | 169 +++++++++++++++++++++++ src/ui/rowFilterControls.test.mjs | 207 ++++++++++++++++++++++++++++ src/ui/styles/radio.css | 102 ++++++++++++++ 27 files changed, 1500 insertions(+), 70 deletions(-) create mode 100644 src/data/firmsIntensity.js create mode 100644 src/data/firmsIntensity.test.mjs create mode 100644 src/ui/rowFilterControls.js create mode 100644 src/ui/rowFilterControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..c7ae4e3cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1117,5 +1117,9 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/data/firmsIntensity.js", + "src/data/firmsIntensity.test.mjs", + "src/ui/rowFilterControls.js", + "src/ui/rowFilterControls.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..ca2ecb542 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -495,6 +495,8 @@ "src/ui/railCards.js", "src/ui/railCardBlocks.js", "src/ui/slider.js", + "src/ui/rowFilterControls.js", + "src/ui/segmentedControl.js", "src/ui/chipGroup.js", "src/ui/rowList.js", "src/ui/railTimeline.js", @@ -670,6 +672,7 @@ "src/data/detectionPolicy.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -734,6 +737,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -954,6 +958,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1007,6 +1012,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1052,6 +1058,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1136,6 +1143,7 @@ "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1525,6 +1533,7 @@ "modules": [ "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1848,6 +1857,7 @@ "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2259,6 +2269,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -2687,6 +2698,7 @@ "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -3283,6 +3295,7 @@ "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3899,6 +3912,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/view/index.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", @@ -3975,6 +3989,8 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/data/firmsIntensity.js", + "src/data/firmsAdapt.js", "src/data/idSwitches.js", "src/data/vesselTypes.js", "src/data/layerState.js", @@ -4107,6 +4123,7 @@ "modules": [ "src/view/index.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index a0ee83c01..f82b8da80 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -68,11 +68,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.notEqual(card.accent, accentForSeverity('red')); + assert.equal(card.accent, accentForSeverity('blue')); +}); + +test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { + const accentAt = (frp) => + buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; + assert.equal(accentAt(4.9), accentForSeverity('blue')); + assert.equal(accentAt(5), accentForSeverity('teal')); + assert.equal(accentAt(20), accentForSeverity('lime')); + assert.equal(accentAt(50), accentForSeverity('amber')); + assert.equal(accentAt(100), accentForSeverity('orange')); + assert.equal(accentAt(300), accentForSeverity('red')); + // Confidence never moves the color: a low-confidence 864 MW fire stays red. + assert.equal( + buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, + accentForSeverity('red'), + ); }); test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => { @@ -125,7 +141,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow'); + assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js new file mode 100644 index 000000000..417566346 --- /dev/null +++ b/src/data/firmsIntensity.js @@ -0,0 +1,209 @@ +/** + * @module firmsIntensity + * @description Fire intensity policy shared by the Active Fires layer and the + * agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale + * slider that doubles as its legend, and the one filter every consumer + * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs + * before anything is rendered or selected. Pure — no Cesium, no DOM. + * + * Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not + * interpolated). The thresholds are GEV's own, set on a real week's + * percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW + * leaves one red fire a week (HAL-675). + */ + +/** + * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. + */ +export const FRP_RAMP = Object.freeze([ + Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }), + Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }), + Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }), + Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }), + Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }), + Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }), +]); + +/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ +export const FRP_SLIDER_TICKS = Object.freeze( + FRP_RAMP.map((stop) => stop.minFrp), +); + +/** Minimum-FRP values the share codec accepts, in whole megawatts. */ +export const MIN_FRP_MW_MIN = 0; +export const MIN_FRP_MW_MAX = 10_000; + +/** Confidence switch positions; VIIRS reports only low / nominal / high. */ +export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']); + +/** + * Normalized-confidence floor for a switch position. The thresholds match + * `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt). + */ +const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 }); + +export const DEFAULT_FIRE_FILTER = Object.freeze({ + minFrp: 0, + confidence: 'all', +}); + +/** + * The ramp stop for a fire radiative power. + * @param {number} frp Megawatts; non-finite or negative reads as 0. + * @returns {{name: string, minFrp: number, hex: string}} + */ +export function frpRampStop(frp) { + const value = Number.isFinite(frp) ? Math.max(0, frp) : 0; + let stop = FRP_RAMP[0]; + for (const candidate of FRP_RAMP) { + if (value >= candidate.minFrp) stop = candidate; + else break; + } + return stop; +} + +/** `#rrggbb` → `[r, g, b]` in 0..255. */ +export function hexToRgb(hex) { + const text = String(hex).replace('#', ''); + return [0, 2, 4].map((offset) => + Number.parseInt(text.slice(offset, offset + 2), 16), + ); +} + +/** + * Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest + * of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1 + * MW because the median detection is ~6 MW and a sub-megawatt floor hides + * nothing worth a tick. + */ +export const SLIDER_MAX_MW = 1000; +const LOG_MAX = Math.log10(SLIDER_MAX_MW); + +/** + * Track position in 0..1 for a minimum FRP. + * @param {number} minFrp Megawatts. + * @returns {number} + */ +export function sliderPositionForFrp(minFrp) { + if (!Number.isFinite(minFrp) || minFrp < 1) return 0; + return Math.min(1, Math.log10(minFrp) / LOG_MAX); +} + +/** + * Minimum FRP for a track position, rounded to whole megawatts so a slider + * value and its share-link form are the same number. + * @param {number} position 0..1. + * @returns {number} + */ +export function frpForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + if (p <= 0) return 0; + const frp = Math.pow(10, p * LOG_MAX); + return Math.max(1, Math.round(frp)); +} + +/** + * CSS `linear-gradient` painting the stepped ramp along the log axis, so the + * slider track IS the legend: the color under the thumb is the color of the + * weakest fire still shown. + * @returns {string} + */ +export function rampTrackGradient() { + const parts = []; + FRP_RAMP.forEach((stop, index) => { + const start = sliderPositionForFrp(stop.minFrp) * 100; + const next = FRP_RAMP[index + 1]; + const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100; + parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`); + }); + return `linear-gradient(90deg, ${parts.join(', ')})`; +} + +/** + * Minimum FRP → whole megawatts inside the codec bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeMinFrp(value) { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : NaN; + if (!Number.isFinite(number)) return null; + const rounded = Math.round(number); + return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX + ? rounded + : null; +} + +/** + * Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null. + * @param {*} value + * @returns {?string} + */ +export function normalizeConfidenceMode(value) { + const text = String(value ?? '') + .trim() + .toLowerCase(); + return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null; +} + +/** + * Sanitize a partial filter against the current one; unknown or invalid + * fields keep their current value. + * @param {Object} [params] + * @param {{minFrp: number, confidence: string}} [current] + * @returns {{minFrp: number, confidence: string}} + */ +export function normalizeFireFilter( + params = {}, + current = DEFAULT_FIRE_FILTER, +) { + const minFrp = normalizeMinFrp(params?.minFrp); + const confidence = normalizeConfidenceMode(params?.confidence); + return { + minFrp: minFrp === null ? current.minFrp : minFrp, + confidence: confidence === null ? current.confidence : confidence, + }; +} + +/** + * Whether one fire passes the filter. `confidence` is the normalized 0..1 + * value firmsAdapt produces; `frp` is megawatts. + * @param {{frp: number, confidence: number}} fire + * @param {{minFrp: number, confidence: string}} filter + * @returns {boolean} + */ +export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) { + const frp = Number.isFinite(fire?.frp) ? fire.frp : 0; + if (frp < (filter.minFrp || 0)) return false; + const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0; + if (floor <= 0) return true; + const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0; + return confidence >= floor; +} + +/** + * The fires that pass the filter, in input order. Returns the SAME array when + * the filter is the default, so callers can tell "nothing hidden" cheaply. + * @template T + * @param {Array} fires Internal records (normalized confidence). + * @param {{minFrp: number, confidence: string}} filter + * @returns {Array} + */ +export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) { + if (!Array.isArray(fires)) return []; + if (isDefaultFireFilter(filter)) return fires; + return fires.filter((fire) => firePassesFilter(fire, filter)); +} + +/** True when the filter hides nothing. */ +export function isDefaultFireFilter(filter) { + return ( + !(filter?.minFrp > 0) && + (filter?.confidence === 'all' || filter?.confidence == null) + ); +} diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs new file mode 100644 index 000000000..558068e67 --- /dev/null +++ b/src/data/firmsIntensity.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_FIRE_FILTER, + FIRMS_CONFIDENCE_MODES, + FRP_RAMP, + FRP_SLIDER_TICKS, + filterFires, + firePassesFilter, + frpForSliderPosition, + frpRampStop, + hexToRgb, + isDefaultFireFilter, + normalizeConfidenceMode, + normalizeFireFilter, + normalizeMinFrp, + rampTrackGradient, + sliderPositionForFrp, +} from './firmsIntensity.js'; + +test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { + assert.deepEqual( + FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), + [ + ['blue', 0, '#4a5bd4'], + ['teal', 5, '#39a0a8'], + ['lime', 20, '#c9cf4a'], + ['amber', 50, '#f2b134'], + ['orange', 100, '#e8722c'], + ['red', 300, '#d1382a'], + ], + ); + assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); + assert.ok(Object.isFrozen(FRP_RAMP)); +}); + +test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { + assert.equal(frpRampStop(0).name, 'blue'); + assert.equal(frpRampStop(4.99).name, 'blue'); + assert.equal(frpRampStop(5).name, 'teal'); + assert.equal(frpRampStop(19.9).name, 'teal'); + assert.equal(frpRampStop(20).name, 'lime'); + assert.equal(frpRampStop(50).name, 'amber'); + assert.equal(frpRampStop(100).name, 'orange'); + assert.equal(frpRampStop(299.9).name, 'orange'); + assert.equal(frpRampStop(300).name, 'red'); + assert.equal(frpRampStop(1246).name, 'red'); + assert.equal(frpRampStop(NaN).name, 'blue'); + assert.equal(frpRampStop(-3).name, 'blue'); + assert.equal(frpRampStop(undefined).name, 'blue'); +}); + +test('hex stops convert to the "r, g, b" accents the cards paint', () => { + assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); +}); + +test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => { + assert.equal(sliderPositionForFrp(0), 0); + assert.equal(sliderPositionForFrp(0.5), 0); + assert.equal(sliderPositionForFrp(1), 0); + assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9); + assert.equal(sliderPositionForFrp(1000), 1); + assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end'); + assert.equal(frpForSliderPosition(0), 0); + assert.equal(frpForSliderPosition(1), 1000); + assert.equal(frpForSliderPosition(-1), 0); + assert.equal(frpForSliderPosition(2), 1000); + for (const frp of FRP_SLIDER_TICKS) { + assert.equal( + frpForSliderPosition(sliderPositionForFrp(frp)), + frp, + `tick ${frp} MW round-trips through the axis`, + ); + } + // Ticks spread along the track instead of bunching at one end. + const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp); + for (let i = 1; i < positions.length; i += 1) { + assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`); + } +}); + +test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { + const gradient = rampTrackGradient(); + assert.match(gradient, /^linear-gradient\(90deg, /); + assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/); + assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/); + assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); +}); + +test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => { + assert.equal(normalizeMinFrp(0), 0); + assert.equal(normalizeMinFrp(19.6), 20); + assert.equal(normalizeMinFrp('100'), 100); + assert.equal(normalizeMinFrp(10000), 10000); + assert.equal(normalizeMinFrp(10001), null); + assert.equal(normalizeMinFrp(-1), null); + assert.equal(normalizeMinFrp('abc'), null); + assert.equal(normalizeMinFrp(''), null); + assert.equal(normalizeMinFrp(null), null); + assert.equal(normalizeMinFrp(NaN), null); +}); + +test('confidence modes are exactly all / nominal / high', () => { + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(normalizeConfidenceMode(' High '), 'high'); + assert.equal(normalizeConfidenceMode('nominal'), 'nominal'); + assert.equal(normalizeConfidenceMode('low'), null); + assert.equal(normalizeConfidenceMode(0.9), null); + assert.equal(normalizeConfidenceMode(undefined), null); +}); + +test('a partial filter patches the current one and invalid fields keep their value', () => { + assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER); + assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), { + minFrp: 20, + confidence: 'all', + }); + assert.deepEqual( + normalizeFireFilter( + { confidence: 'high' }, + { minFrp: 50, confidence: 'all' }, + ), + { minFrp: 50, confidence: 'high' }, + ); + assert.deepEqual( + normalizeFireFilter( + { minFrp: 'nope', confidence: 'medium' }, + { minFrp: 50, confidence: 'nominal' }, + ), + { minFrp: 50, confidence: 'nominal' }, + ); + assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true); + assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false); +}); + +test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => { + const low = { frp: 864, confidence: 0.3 }; + const nominal = { frp: 20, confidence: 0.6 }; + const high = { frp: 2, confidence: 0.9 }; + assert.equal(firePassesFilter(low), true, 'default shows everything'); + assert.equal( + firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }), + false, + ); + assert.equal( + firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }), + false, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }), + false, + ); + assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true); + assert.equal( + firePassesFilter(high, { minFrp: 5, confidence: 'high' }), + false, + ); + // Missing numbers read as 0, never as a pass-through. + assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false); + assert.equal( + firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }), + false, + ); +}); + +test('filterFires returns the same array for the default filter and preserves order otherwise', () => { + const fires = [ + { frp: 864, confidence: 0.3 }, + { frp: 20, confidence: 0.6 }, + { frp: 2, confidence: 0.9 }, + { frp: 120, confidence: 0.9 }, + ]; + assert.equal(filterFires(fires), fires); + assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires); + assert.deepEqual( + filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp), + [864, 20, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp), + [2, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map( + (f) => f.frp, + ), + [120], + ); + assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []); +}); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index b9e9d1765..3af6a15a5 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -4,6 +4,8 @@ * dedicated card canvas moved into the shared world-overlay host. */ +import { FRP_RAMP, hexToRgb } from './firmsIntensity.js'; + /** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */ export const FIRMS_AMBIENT_COHORT_LIMIT = 18; export const FIRMS_OVERLAY_SOURCE_ID = 'firms'; @@ -23,20 +25,23 @@ export function fireDetectionKey(fire) { return `firms:${lat}:${lon}:${acq}:${source}`; } -/** Severity accent palette — matches the FIRMS glow-sprite color stops. */ -const ACCENT_RGB = Object.freeze({ - red: '224, 82, 82', - orange: '240, 178, 62', - yellow: '244, 227, 108', -}); +/** + * Severity accent palette — the FIRMS glow-sprite color stops (the six-stop + * FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents. + */ +const ACCENT_RGB = Object.freeze( + Object.fromEntries( + FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]), + ), +); /** - * Severity stop name → "r, g, b" accent string (defaults to yellow). - * @param {string} stopName - 'red' | 'orange' | 'yellow'. + * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). + * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). * @returns {string} */ export function accentForSeverity(stopName) { - return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; + return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name]; } /** diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 8573d7330..62234bb2d 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,11 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -test('FIRMS formatting helpers retain the shipped severity palette', () => { - assert.equal(accentForSeverity('red'), '224, 82, 82'); - assert.equal(accentForSeverity('orange'), '240, 178, 62'); - assert.equal(accentForSeverity('yellow'), '244, 227, 108'); - assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow')); +test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => { + // Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675). + assert.equal(accentForSeverity('blue'), '74, 91, 212'); + assert.equal(accentForSeverity('teal'), '57, 160, 168'); + assert.equal(accentForSeverity('lime'), '201, 207, 74'); + assert.equal(accentForSeverity('amber'), '242, 177, 52'); + assert.equal(accentForSeverity('orange'), '232, 114, 44'); + assert.equal(accentForSeverity('red'), '209, 56, 42'); + assert.equal(accentForSeverity('yellow'), accentForSeverity('blue')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/data/layerState.js b/src/data/layerState.js index 089c8c08b..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,6 +298,20 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ + // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on + // the codec side; the panel's log slider rounds to it. Both default to + // "show everything": many of the strongest fires are rated low confidence. + // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in + // firmsIntensity.js (pinned by layerState.test); spelled out here because + // this file runs in isolation in the token-ledger tests. + 'local-firms': Object.freeze([ + boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }), + enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], { + all: 'a', + nominal: 'n', + high: 'h', + }), + ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { tomtom: 't', @@ -621,7 +635,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'd', disposition: 'enabled-only', }), - Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ + id: 'local-firms', + token: 'w', + disposition: 'enabled+options', + optionOwner: 'local-firms', + }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index d05fb9a95..d343fe20f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -29,6 +29,7 @@ import radioLayer from './radio.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; +import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; function deferred() { let resolve; @@ -2984,9 +2985,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => { gate.resolve(); const results = await restore; for (const id of ['earthquakes', 'flights']) { - assert.equal(results.find((result) => result.layerId === id).succeeded, false); + assert.equal( + results.find((result) => result.layerId === id).succeeded, + false, + ); } assert.deepEqual([...manager.getEnabledLayerIds()], []); assert.equal(enabledLayerParam(sink), ''); coordinator.destroy(); }); + +/** Active Fires options decoded from a v2 link with these `lo` assignments. */ +function firmsOptions(lo) { + const params = new URLSearchParams([ + ['v', '2'], + ['l', 'w'], + ]); + if (lo) params.set('lo', lo); + return decodeLayerStateParams(params).options['local-firms']; +} + +test('Active Fires: the intensity filter rides share token w and round-trips', () => { + const state = createDefaultLayerState(); + assert.deepEqual(state.options['local-firms'], { + minFrp: 0, + confidence: 'all', + }); + state.enabledLayerIds = ['local-firms']; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + const encoded = encode(state); + assert.equal(new URLSearchParams(encoded).get('l'), 'w'); + // Other owners write their own explicit defaults into `lo` too (flights 3D, + // wind overlay); the fire assignments ride beside them. + const assignments = new URLSearchParams(encoded).get('lo').split('_'); + assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); + assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); + const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); + assert.deepEqual(decoded.options['local-firms'], { + minFrp: 20, + confidence: 'high', + }); + assert.deepEqual( + parseStoredLayerState(serializeStoredLayerState(state)).options[ + 'local-firms' + ], + state.options['local-firms'], + ); + // Defaults are omitted from the link: a plain `l=w` is "show everything". + const plain = createDefaultLayerState(); + plain.enabledLayerIds = ['local-firms']; + assert.equal( + new URLSearchParams(encode(plain)) + .get('lo') + .split('_') + .filter((assignment) => assignment.startsWith('w.')).length, + 0, + ); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); +}); + +test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { + // The codec spells its bounds out (it runs in isolation in the ledger + // tests); they must stay the layer's. + assert.equal(MIN_FRP_MW_MAX, 10000); + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(firmsOptions('w.f.300').minFrp, 300); + assert.equal(firmsOptions('w.f.10000').minFrp, 10000); + assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default'); + assert.equal(firmsOptions('w.f.-5').minFrp, 0); + assert.equal( + firmsOptions('w.f.2.5').minFrp, + 0, + 'a third segment is rejected', + ); + assert.equal(firmsOptions('w.c.a').confidence, 'all'); + assert.equal(firmsOptions('w.c.n').confidence, 'nominal'); + assert.equal(firmsOptions('w.c.h').confidence, 'high'); + assert.equal( + firmsOptions('w.c.x').confidence, + 'all', + 'unknown code → default', + ); + assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { + minFrp: 5, + confidence: 'nominal', + }); +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..ffee7fb87 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,17 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "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", + "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", + "High confidence detections only": "Solo detecciones de confianza alta", + "Min FRP": "FRP mín.", + "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "{value} MW": "{value} MW", + "fire intensity filter|All": "Todo", + "Detection confidence": "Confianza de la detección", + "{shown} of {total} shown": "{shown} de {total} mostrados" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..1cd88757d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,17 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "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": "Только детекции высокой достоверности", + "Min FRP": "Мин. FRP", + "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "{value} MW": "{value} МВт", + "fire intensity filter|All": "Все", + "Detection confidence": "Достоверность детекции", + "{shown} of {total} shown": "показано {shown} из {total}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..3044c45f8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,17 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "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 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "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|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", + "Nominal and high confidence detections": "标称和高置信度的检测", + "High confidence detections only": "仅高置信度的检测", + "Min FRP": "最小 FRP", + "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "{value} MW": "{value} MW", + "fire intensity filter|All": "全部", + "Detection confidence": "检测置信度", + "{shown} of {total} shown": "显示 {shown} / {total}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 05f5d3dee..5f1b14f8a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -98,6 +98,7 @@ "WX", "audio {state}", "dB", + "fire confidence filter|Nominal+", "fire confidence|nominal", "ft", "hPa", @@ -120,6 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -168,9 +170,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -195,6 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 494ab5f1e..215c35da3 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -1,5 +1,6 @@ import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; import { fireDetectionKey } from '../../data/firmsLabels.js'; +import { filterFires } from '../../data/firmsIntensity.js'; import { msg } from '../../i18n/index.js'; export function createIngestion({ @@ -40,12 +41,13 @@ export function createIngestion({ layerState._stale = Boolean(payload?.stale); const previousSelection = layerState._selectedFire; layerState._selectedFire = null; - layerState._fires = adaptFirmsRecords(payload?.fires); - layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells - layerState._firesByFrp = [...layerState._fires].sort( + layerState._allFires = adaptFirmsRecords(payload?.fires); + // Sorted once per payload; applyFilter only filters, so a slider drag + // over 200k detections never re-sorts. + layerState._allFiresByFrp = [...layerState._allFires].sort( (a, b) => b.frp - a.frp, ); - layerState._count = layerState._fires.length; + applyFilter(); // Data age, not response age: a stale proxy payload truthfully reads old. layerState._lastUpdate = Number.isFinite(payload?.fetchedAt) ? payload.fetchedAt @@ -65,6 +67,7 @@ export function createIngestion({ } components.rendering.renderCurrentLod(true); if (reselected) components.selection.selectFire(reselected, false); + layerState._rowControlsListener?.(); } catch (error) { if ( request.signal.aborted || @@ -83,6 +86,25 @@ export function createIngestion({ } } + /** + * Run the intensity filter over the loaded payload. This is the ONE place + * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, + * heat cells, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells are + * aggregated per shown set, so the cache goes with it. + */ + + function applyFilter() { + layerState._fires = filterFires(layerState._allFires, layerState._filter); + layerState._cellCacheByGrid.clear(); + layerState._firesByFrp = filterFires( + layerState._allFiresByFrp, + layerState._filter, + ); + layerState._count = layerState._fires.length; + layerState._totalCount = layerState._allFires.length; + } + /** * Find the record in the freshly-loaded set matching a previous selection. * Identity is the detection itself (lat/lon/acquisition time) — indices @@ -109,5 +131,5 @@ export function createIngestion({ }, }; - return { loadHeatmap, findMatchingFire, methods }; + return { loadHeatmap, applyFilter, findMatchingFire, methods }; } diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..1138504dd 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -91,10 +91,14 @@ export function createLifecycle({ components.rendering.removeDetectionCollections(viewer); components.selection.clearContextRegistrations(); clearSelectedEntityContextForLayer(id); + layerState._allFires = []; + layerState._allFiresByFrp = []; layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); layerState._count = 0; + layerState._totalCount = 0; + layerState._rowControlsListener = null; layerState._cellCount = 0; layerState._lastUpdate = null; layerState._keyRequired = false; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 14b875bf8..a90066cc6 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -6,6 +6,7 @@ import { accentForSeverity, formatFrpMw, } from '../../data/firmsLabels.js'; +import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js'; import { VIEW_PADDING, DETECTION_COLOR_STOPS, @@ -119,27 +120,42 @@ export function createModel({ ); } - function heatColor(value, alpha) { - if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); - if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); - return Cesium.Color.YELLOW.withAlpha(alpha); + /** + * The ramp stop for a fire radiative power (stepped, coldest first). + * Intensity alone drives the color: confidence feeds the card text and the + * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. + * @param {number} frp - Megawatts. + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function frpColorStop(frp) { + const name = frpRampStop(frp).name; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); } /** - * Pick a sprite color stop from FRP + confidence, reusing the same - * yellow → orange → red thresholds as the aggregated heat cells. + * Fill color for an aggregated heat cell: the ramp color of its strongest + * detection, so cells, markers and the slider legend read on one scale. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} alpha - Fill alpha (heat-normalized by the caller). + * @returns {Cesium.Color} + */ + + function heatColor(maxFrp, alpha) { + return frpColorStop(maxFrp).color.withAlpha(alpha); + } + + /** + * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. * @returns {{name: string, color: Cesium.Color}} */ function detectionColorStop(fire) { - const heat = Math.min( - 1, - Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, - ); - if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; - if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; - return DETECTION_COLOR_STOPS[2]; + return frpColorStop(fire?.frp); } /** FRP → core marker pixel size, clamped to 8..28px. */ @@ -418,7 +434,7 @@ export function createModel({ position: candidate.position, cullPosition: candidate.cullPosition || candidate.position, gapPx: 10, - accent: candidate.accent || accentForSeverity('yellow'), + accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name), title: `${cell.count} ${noun}`, details: [parts.join(' · ')], selected: false, @@ -460,16 +476,14 @@ export function createModel({ } /** - * Heat-normalized cell score → severity accent, mirroring heatColor's - * red/orange/yellow thresholds. - * @param {number} normalized - 0..1 normalized heat score. + * Cell card accent: the ramp stop of the cell's strongest detection, + * mirroring heatColor. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - if (normalized > 0.72) return accentForSeverity('red'); - if (normalized > 0.42) return accentForSeverity('orange'); - return accentForSeverity('yellow'); + function cellAccent(maxFrp) { + return accentForSeverity(frpColorStop(maxFrp).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -576,6 +590,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + frpColorStop, detectionColorStop, frpPixelSize, sizeBucket, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..18ea88238 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -1,4 +1,5 @@ import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; +import { FRP_RAMP } from '../../data/firmsIntensity.js'; import * as Cesium from 'cesium'; /** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ @@ -88,10 +89,16 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; -/** Color stops shared by cell heat fills and detection glow sprites. */ +/** + * Color stops shared by cell heat fills and detection glow sprites: the + * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * colors. Stop names key the glow-sprite cache and the card accents. + */ -export const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; +export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) => + Object.freeze({ + name: stop.name, + minFrp: stop.minFrp, + color: Cesium.Color.fromCssColorString(stop.hex), + }), +); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..220012133 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -1,7 +1,19 @@ import { fireDetectionKey } from '../../data/firmsLabels.js'; -import { t } from '../../i18n/index.js'; +import { + FIRMS_CONFIDENCE_MODES, + FRP_SLIDER_TICKS, + firePassesFilter, + frpForSliderPosition, + normalizeFireFilter, + rampTrackGradient, + sliderPositionForFrp, +} from '../../data/firmsIntensity.js'; +import { formatNumber, t } from '../../i18n/index.js'; import { REFRESH_INTERVAL_MS } from './policy.js'; +/** Slider resolution: whole-percent positions along the log axis. */ +const SLIDER_STEPS = 1000; + export function createQueries({ layerState, services, @@ -11,6 +23,25 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ + function frpLabel(frp) { + return formatNumber(frp, { maximumFractionDigits: 0 }); + } + + const confidenceLabels = () => ({ + all: t('All', { context: 'fire confidence filter' }), + nominal: t('Nominal+', { context: 'fire confidence filter' }), + high: t('High', { context: 'fire confidence filter' }), + }); + + const confidenceTitles = () => ({ + all: t( + 'Every detection — many of the strongest fires are rated low confidence', + ), + nominal: t('Nominal and high confidence detections'), + high: t('High confidence detections only'), + }); + const methods = { /** Return the selected detection as a plain record for application consumers. */ getSelectedInfo() { @@ -51,6 +82,109 @@ export function createQueries({ // not this interval — is what protects the upstream FIRMS quota). updateInterval: REFRESH_INTERVAL_MS, + /** + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} + */ + getParams() { + return { ...layerState._filter }; + }, + + /** + * Apply a (partial) intensity filter. Invalid fields keep their current + * value. The filter runs in one place (ingestion.applyFilter) and the LOD + * rebuild re-derives markers, cells, cards and context registrations from + * the shown set; a selected fire that no longer passes is deselected. + * @param {{minFrp?: number, confidence?: string}} params + * @returns {boolean} True — the request is always accepted. + */ + setParams(params = {}) { + const next = normalizeFireFilter(params, layerState._filter); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) + return true; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + if (layerState._enabled) components.rendering.renderCurrentLod(true); + layerState._rowControlsListener?.(); + return true; + }, + + /** + * Row controls under the Active Fires row: the log-scale minimum-FRP + * slider whose track is painted with the intensity ramp (so it doubles as + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. + */ + getRowControls() { + const { minFrp, confidence } = layerState._filter; + const labels = confidenceLabels(); + const titles = confidenceTitles(); + const shown = layerState._count; + const total = layerState._totalCount; + return { + slider: { + id: 'min-frp', + label: t('Min FRP'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('{value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + segmented: { + id: 'confidence', + ariaLabel: t('Detection confidence'), + options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ + id: `confidence-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === confidence, + params: { confidence: mode }, + })), + }, + info: total + ? t('{shown} of {total} shown', { + shown: formatNumber(shown), + total: formatNumber(total), + }) + : '', + }; + }, + + /** + * Install the panel's "row controls changed" callback: a load or a filter + * change moves the counter, and nothing else repaints the row before the + * next scheduled refresh. + * @param {(() => void)|null} listener + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + /** * Layer stats for the data panel. Degraded feed states surface through * `error` (established qa-failstate pattern: a dead feed must never look @@ -84,7 +218,9 @@ export function createQueries({ }); } return { + // Shown (filtered) detections; `total` is the loaded payload. count: layerState._count, + total: layerState._totalCount, cells: layerState._cellCount, lastUpdate: layerState._lastUpdate, loading: layerState._loading, @@ -176,8 +312,12 @@ export function createQueries({ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; layerState._enabled = true; + layerState._allFires = fires; + layerState._allFiresByFrp = [...fires]; layerState._fires = fires; layerState._firesByFrp = [...fires]; + layerState._count = fires.length; + layerState._totalCount = fires.length; layerState._pickIndexById.clear(); layerState._labelLodDistance = 1e7; layerState._labelCandidates = fires.map((fire) => { diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..757fd4cf7 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); + const color = components.model.heatColor(cell.maxFrp, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,13 +170,12 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellAccent(cell.maxFrp), }); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..d84ee2026 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) { layerState._error = null; + /** Every adapted detection in the current payload, before the filter. */ + + layerState._allFires = []; + + /** `_allFires` sorted by FRP descending, once per payload. */ + + layerState._allFiresByFrp = []; + + /** + * The detections that pass the intensity filter — the ONLY set rendering, + * selection, cells, the top-fires query and the analyst records ever walk. + */ + layerState._fires = []; layerState._firesByFrp = []; + /** Shown (filtered) detection count. */ + layerState._count = 0; + /** Loaded detection count before the filter ("N of M shown"). */ + + layerState._totalCount = 0; + + /** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */ + + layerState._filter = { ...DEFAULT_FIRE_FILTER }; + + /** Panel callback: repaint the row controls (counter) after a load. */ + + layerState._rowControlsListener = null; + layerState._cellCount = 0; layerState._lastUpdate = null; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 84ba8fbc1..18f64e0aa 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -255,6 +255,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 2b11c639c..e671db426 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -4,6 +4,14 @@ import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { + FIRMS_CONFIDENCE_MODES, + MIN_FRP_MW_MAX, + MIN_FRP_MW_MIN, + firePassesFilter, + normalizeFireFilter, +} from '../../data/firmsIntensity.js'; +import { normalizeConfidence } from '../../data/firmsAdapt.js'; function acquiredMsUtc(acqDate, acqTime) { if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) @@ -87,10 +95,30 @@ export const getActiveFires = defineTool({ title: 'Active fires', description: 'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' + - 'area, most intense (fire radiative power) first.', + 'area, most intense (fire radiative power) first. Optionally limited to a ' + + 'minimum fire radiative power in megawatts and a confidence level — the ' + + "same filter as the Active Fires layer's intensity controls.", inputSchema: { type: 'object', - properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + properties: { + area: AREA_SCHEMA, + min_frp_mw: { + type: 'number', + minimum: MIN_FRP_MW_MIN, + maximum: MIN_FRP_MW_MAX, + description: + 'Hide detections weaker than this fire radiative power (MW). ' + + 'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.', + }, + confidence: { + type: 'string', + enum: [...FIRMS_CONFIDENCE_MODES], + description: + '"all" (default), "nominal" (nominal and high), or "high". Many of the ' + + 'strongest fires are rated low confidence, so prefer "all" unless asked.', + }, + limit: LIMIT_SCHEMA, + }, required: ['area'], additionalProperties: false, }, @@ -103,9 +131,31 @@ export const getActiveFires = defineTool({ 'unavailable', 'Fire detections need a NASA FIRMS key configured for this server', ); + const filter = normalizeFireFilter({ + minFrp: args.min_frp_mw, + confidence: args.confidence, + }); const fires = snapshot.fires - .filter((fire) => areaContains(area, fire)) + .filter( + (fire) => + areaContains(area, fire) && + firePassesFilter( + { + frp: Number(fire.frp), + confidence: normalizeConfidence(fire.confidence), + }, + filter, + ), + ) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); + const filterNotes = [ + ...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []), + ...(filter.confidence !== 'all' + ? [ + `${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`, + ] + : []), + ]; const result = capRows(fires, args.limit); // The proxy serves its last good snapshot when a refresh fails, and marks // satellites whose download failed. @@ -119,10 +169,16 @@ export const getActiveFires = defineTool({ ]; return { summary: - `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + `${countNoun(fires.length, 'fire detection')}` + + (filterNotes.length ? ` (${filterNotes.join(', ')})` : '') + + ` in ${area.label} in the last 24 hours` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { area, layers: ['local-firms'] }), + filter: { + min_frp_mw: filter.minFrp, + confidence: filter.confidence, + }, stale, missing_sources: missing, fetched_at: isoTime(snapshot.fetchedAt), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fa17158dd..c1d3eb43a 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async assert.equal(result.data.rows[0].acquired, expected); } }); + +const WEEK = [ + { lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, +]; +const TAIWAN = { area: { bbox: [120, 23, 122, 25] } }; + +test('get_active_fires shows every detection by default and reports the filter it used', async () => { + const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN); + assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]); + assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' }); + assert.match(result.summary, /^4 fire detections in /); +}); + +test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => { + const catalog = catalogWith(WEEK); + const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 }); + assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]); + assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /); + + const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' }); + assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops'); + assert.match(nominal.summary, /\(nominal or high confidence\)/); + + const both = await catalog.call('get_active_fires', { + ...TAIWAN, + min_frp_mw: 19.6, + confidence: 'high', + }); + assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]); + assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' }); + assert.match(both.summary, /\(≥ 20 MW, high confidence\)/); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..dc6566848 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,4 +1,5 @@ import { syncChipGroup } from './chipGroup.js'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; import { syncRowList } from './rowList.js'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; @@ -388,18 +389,42 @@ export class LayerPanel { const controls = document.createElement('div'); controls.className = 'data-toggle-controls'; this._bind(controls, 'click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); + const button = + event.target?.closest?.('.data-toggle-chip') || + event.target?.closest?.('.data-toggle-segment'); if (!button || button.disabled) return; // Re-read the live descriptor rather than trusting the rendered // chip, so a stale row can never apply an inverted toggle. - const chip = this._rowControlsFor(layer.id)?.chips?.find( - (entry) => entry.id === button.dataset.chipId, - ); + const live = this._rowControlsFor(layer.id); + const chip = + live?.chips?.find( + (entry) => entry.id === button.dataset.chipId, + ) || + live?.segmented?.options?.find( + (entry) => entry.id === button.dataset.chipId, + ); if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); + // A row slider (e.g. the Active Fires minimum-FRP filter) maps its + // position to params through the live descriptor on every tick. + this._bind(controls, 'input', (event) => { + const input = event.target; + if (!input?.matches?.('.data-toggle-slider-input')) return; + const slider = this._rowControlsFor(layer.id)?.slider; + if ( + !slider || + slider.id !== input.dataset.sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(layer.id) + ) + return; + this.setLayerParams(layer.id, slider.params(input.value), { + origin: 'user', + }); + }); row.appendChild(controls); // An ordered list below the chips, for a layer whose row carries a // sequence (turn-by-turn directions). Its own delegated listener, its @@ -521,13 +546,30 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; const infoText = controls?.info; + const slider = controls?.slider || null; + const segmented = controls?.segmented || null; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0 && !infoText; + container.hidden = + chips.length === 0 && + legend.length === 0 && + !infoText && + !slider && + !segmented; const info = container._rowControlsInfo || null; const firstLegend = container.querySelector('.data-toggle-legend-item'); - syncChipGroup(container, chips, { before: firstLegend || info }); + // Order: chips, switch, slider, legend, info. + syncSegmentedGroup(container, segmented, { before: firstLegend || info }); + const switchNode = container.querySelector('.data-toggle-segmented'); + syncSliderBlock(container, slider, { + before: switchNode?.nextSibling || firstLegend || info, + }); + const firstControl = + switchNode || container.querySelector('.data-toggle-slider'); + syncChipGroup(container, chips, { + before: firstControl || firstLegend || info, + }); const legendSignature = JSON.stringify( legend.map(({ label, color, count, blurb }) => [ diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js new file mode 100644 index 000000000..b0299d24b --- /dev/null +++ b/src/ui/rowFilterControls.js @@ -0,0 +1,169 @@ +import { createSlider } from './slider.js'; +import { syncSegmentedControl } from './segmentedControl.js'; + +const set = (node, key, value) => { + if (node[key] !== value) node[key] = value; +}; +const attribute = (node, key, value) => { + if (node.getAttribute(key) !== value) node.setAttribute(key, value); +}; +// Custom properties need setProperty; test fixtures carry a plain style bag. +const styleVar = (node, name, value) => { + if (typeof node.style?.setProperty === 'function') { + if (value) node.style.setProperty(name, value); + else node.style.removeProperty(name); + } else if (node.style) node.style[name] = value || ''; +}; + +/** + * Reconcile a layer row's quantitative slider block in place (keyed by the + * descriptor id), so a refresh triggered by the slider's own input never + * replaces the node under the pointer. The caller owns dispatch: it listens + * for `input` on the row container and reads `.data-toggle-slider-input`. + * + * Descriptor: `{ id, label, ariaLabel, valueText, min, max, step, value, + * track, ticks: [{ at, label }] }` — `track` is a CSS background painting the + * runnable track (a color ramp doubles as the legend), `at` is 0..1. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} slider Descriptor, or null to remove the block. + * @param {{before?: ?Node}} [options] Insertion anchor for a new block. + * @returns {?HTMLElement} The block, when rendered. + */ +export function syncSliderBlock(container, slider, { before = null } = {}) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.sliderId, + ); + if (!slider) { + existing?.remove(); + return null; + } + let block = existing; + if (!block || block.dataset.sliderId !== slider.id) { + existing?.remove(); + block = document.createElement('div'); + block.className = 'data-toggle-slider'; + block.dataset.sliderId = slider.id; + const head = document.createElement('div'); + head.className = 'data-toggle-slider-head'; + const label = document.createElement('span'); + label.className = 'data-toggle-slider-label'; + const value = document.createElement('span'); + value.className = 'data-toggle-slider-value gev-slider-value'; + head.append(label, value); + const input = createSlider({ + documentRef: document, + className: 'data-toggle-slider-input', + min: slider.min, + max: slider.max, + step: slider.step, + value: slider.value, + }); + input.dataset.sliderId = slider.id; + const ticks = document.createElement('div'); + ticks.className = 'data-toggle-slider-ticks'; + block.append(head, input, ticks); + block._parts = { label, value, input, ticks }; + container.insertBefore(block, before); + } + const { label, value, input, ticks } = block._parts; + set(label, 'textContent', slider.label ?? ''); + set(value, 'textContent', slider.valueText ?? ''); + set(input, 'min', String(slider.min)); + set(input, 'max', String(slider.max)); + set(input, 'step', String(slider.step ?? 1)); + // Never move the thumb under an active drag: the layer's rounded value can + // sit a hair off the pointer, and a refresh runs on every input tick. + if (document.activeElement !== input) + set(input, 'value', String(slider.value)); + if (slider.ariaLabel) attribute(input, 'aria-label', slider.ariaLabel); + if (slider.valueText) attribute(input, 'aria-valuetext', slider.valueText); + const track = slider.track || ''; + if ((block.dataset.track || '') !== track) { + styleVar(input, '--slider-track', track); + block.dataset.track = track; + } + const signature = JSON.stringify(slider.ticks || []); + if (block.dataset.ticks !== signature) { + for (const node of [...ticks.children]) node.remove(); + for (const tick of slider.ticks || []) { + const node = document.createElement('span'); + node.className = 'data-toggle-slider-tick'; + styleVar(node, '--at', `${(tick.at * 100).toFixed(2)}%`); + node.textContent = tick.label; + ticks.appendChild(node); + } + block.dataset.ticks = signature; + } + return block; +} + +/** + * Reconcile a one-of-many switch (`.gev-segmented` radiogroup) in place. + * Buttons carry `data-chip-id` like chips so the row's delegated click handler + * can resolve them; the caller matches `.data-toggle-segment`. + * + * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }`. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} segmented Descriptor, or null to remove the group. + * @param {{before?: ?Node}} [options] Insertion anchor for a new group. + * @returns {?HTMLElement} The group, when rendered. + */ +export function syncSegmentedGroup( + container, + segmented, + { before = null } = {}, +) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.segmentedId, + ); + if (!segmented) { + existing?.remove(); + return null; + } + let group = existing; + if (!group || group.dataset.segmentedId !== segmented.id) { + existing?.remove(); + group = document.createElement('div'); + group.className = 'gev-segmented data-toggle-segmented'; + group.dataset.segmentedId = segmented.id; + group.setAttribute('role', 'radiogroup'); + container.insertBefore(group, before); + } + if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); + const stale = new Map(); + for (const node of [...group.children]) + if (node.dataset?.chipId) stale.set(node.dataset.chipId, node); + const ordered = []; + for (const option of segmented.options || []) { + let button = stale.get(option.id); + stale.delete(option.id); + if (!button) { + button = document.createElement('button'); + button.type = 'button'; + button.className = 'gev-segment data-toggle-segment'; + button.dataset.chipId = option.id; + button.setAttribute('role', 'radio'); + } + set(button, 'textContent', option.label); + set(button, 'title', option.title || ''); + set(button, 'disabled', Boolean(option.disabled)); + ordered.push(button); + } + for (const node of stale.values()) node.remove(); + ordered.forEach((node, index) => { + if (group.children[index] !== node) + group.insertBefore(node, group.children[index] || null); + }); + const active = (segmented.options || []).find((option) => option.active); + syncSegmentedControl(ordered, active?.id, { + valueOf: (button) => button.dataset.chipId, + activeClass: 'active', + rovingFocus: true, + }); + return group; +} diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs new file mode 100644 index 000000000..9cc8fb1d4 --- /dev/null +++ b/src/ui/rowFilterControls.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; +import { railFixture } from './railTestFixture.mjs'; + +const sliderDescriptor = (overrides = {}) => ({ + id: 'min-frp', + label: 'MIN FRP', + ariaLabel: 'Minimum fire radiative power', + valueText: 'All', + min: 0, + max: 1000, + step: 1, + value: 0, + track: 'linear-gradient(90deg, #4a5bd4 0% 23%, #d1382a 82% 100%)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.233, label: '5' }, + { at: 0.826, label: '300' }, + ], + params: (value) => ({ minFrp: Number(value) }), + ...overrides, +}); + +const segmentedDescriptor = (active = 'all') => ({ + id: 'confidence', + ariaLabel: 'Detection confidence', + options: ['all', 'nominal', 'high'].map((mode) => ({ + id: `confidence-${mode}`, + label: mode.toUpperCase(), + title: `${mode} title`, + active: mode === active, + params: { confidence: mode }, + })), +}); + +test('the slider block renders label, value, painted track and ticks, and is reconciled in place', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const block = syncSliderBlock(container, sliderDescriptor()); + assert.equal(block.className, 'data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); + const input = block.querySelector('.data-toggle-slider-input'); + assert.equal(input.type, 'range'); + assert.equal(input.max, '1000'); + assert.equal(input.value, '0'); + assert.equal( + input.getAttribute('aria-label'), + 'Minimum fire radiative power', + ); + assert.equal(input.getAttribute('aria-valuetext'), 'All'); + assert.match(input.style['--slider-track'], /^linear-gradient/); + assert.equal( + block.querySelector('.data-toggle-slider-label').textContent, + 'MIN FRP', + ); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + 'All', + ); + const ticks = block.querySelector('.data-toggle-slider-ticks'); + assert.deepEqual( + ticks.children.map((node) => [node.textContent, node.style['--at']]), + [ + ['0', '0.00%'], + ['5', '23.30%'], + ['300', '82.60%'], + ], + ); + + const again = syncSliderBlock( + container, + sliderDescriptor({ value: 434, valueText: '20 MW' }), + ); + assert.equal(again, block, 'same node — a refresh never drops the thumb'); + assert.equal(input.value, '434'); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + '20 MW', + ); + assert.equal(container.children.length, 1); + + // A thumb under the pointer is never moved by a refresh. + input.focus(); + syncSliderBlock(container, sliderDescriptor({ value: 0 })); + assert.equal(input.value, '434'); + + syncSliderBlock(container, null); + assert.equal(container.children.length, 0); +}); + +test('the segmented group is a radiogroup whose active option reads checked, reconciled by id', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const group = syncSegmentedGroup(container, segmentedDescriptor('all')); + assert.equal(group.getAttribute('role'), 'radiogroup'); + assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); + assert.ok(group.className.split(' ').includes('gev-segmented')); + const buttons = group.children; + assert.deepEqual( + buttons.map((b) => [ + b.dataset.chipId, + b.textContent, + b.getAttribute('aria-checked'), + b.getAttribute('tabindex'), + ]), + [ + ['confidence-all', 'ALL', 'true', '0'], + ['confidence-nominal', 'NOMINAL', 'false', '-1'], + ['confidence-high', 'HIGH', 'false', '-1'], + ], + ); + assert.equal(buttons[0].getAttribute('role'), 'radio'); + assert.equal(buttons[1].title, 'nominal title'); + + syncSegmentedGroup(container, segmentedDescriptor('high')); + assert.equal(group.children[0], buttons[0], 'buttons are reused'); + assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); + assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); + assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 1); + + syncSegmentedGroup(container, null); + assert.equal(container.children.length, 0); +}); + +test('a layer row dispatches slider input and switch clicks as user params', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const f = railFixture(); + const previousDocument = globalThis.document; + globalThis.document = f.document; + const calls = []; + let controls = { + slider: sliderDescriptor(), + segmented: segmentedDescriptor('all'), + info: '12 of 40 shown', + }; + const panel = new LayerPanel({ + getLayers: () => [ + { + id: 'local-firms', + name: 'Active Fires', + icon: '▲', + source: 'NASA FIRMS', + enabled: true, + showInTogglePanel: true, + stats: {}, + }, + ], + isEnabled: () => true, + setEnabled: () => {}, + setLayerParams: (...args) => calls.push(args), + hasRowControls: () => true, + subscribeRowControls: () => {}, + getRowControls: () => controls, + }); + try { + panel.mount(f.container); + const row = f.find((n) => n.dataset.layerId === 'local-firms'); + const container = row.querySelector('.data-toggle-controls'); + assert.equal(container.hidden, false); + assert.equal( + row.querySelector('.data-toggle-controls-info').textContent, + '12 of 40 shown', + ); + // Order: switch, slider, info. + assert.deepEqual( + container.children.map((node) => node.className.split(' ')[0]), + ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + ); + + const input = row.querySelector('.data-toggle-slider-input'); + input.value = '434'; + const inputEvent = new Event('input'); + Object.defineProperty(inputEvent, 'target', { value: input }); + container.dispatchEvent(inputEvent); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { minFrp: 434 }, + { origin: 'user' }, + ]); + + const high = row + .querySelector('.data-toggle-segmented') + .children.find((b) => b.dataset.chipId === 'confidence-high'); + high.click(); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { confidence: 'high' }, + { origin: 'user' }, + ]); + + // A stale descriptor never applies: the live one decides. + controls = { ...controls, segmented: segmentedDescriptor('high') }; + // The fixture DOM has no attribute selectors, so sync the row directly. + panel._syncRowControls(container, { id: 'local-firms', enabled: true }); + assert.equal(high.getAttribute('aria-checked'), 'true'); + assert.equal( + row.querySelector('.data-toggle-slider-input'), + input, + 'the slider survives a refresh', + ); + } finally { + panel.destroy(); + globalThis.document = previousDocument; + } +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 483321992..5047ae6f4 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1035,6 +1035,108 @@ html:not([lang='en']) .data-row-list-text { padding-top: 3px; overflow-wrap: anywhere; } + +/* Optional per-layer one-of-many switch (Active Fires confidence). The shared + .gev-segmented surface, sized to the chip row. */ +.data-toggle-controls .data-toggle-segmented { + flex-basis: 100%; + max-width: 220px; +} + +.data-toggle-controls .data-toggle-segment { + min-height: 20px; + padding: 3px 6px; + font-size: 8px; + letter-spacing: 0.8px; +} + +/* Optional per-layer quantitative slider (Active Fires minimum FRP). The + track is painted by the layer (`--slider-track`, a stepped color ramp), so + the slider doubles as the marker legend; ticks sit under their thresholds. */ +.data-toggle-slider { + flex-basis: 100%; + display: grid; + gap: 2px; + padding-top: 2px; +} + +.data-toggle-slider-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + +.data-toggle-slider-value { + min-width: 0; + font-size: 9px; + letter-spacing: 0.5px; + text-transform: none; +} + +.data-toggle-slider-input { + height: 14px; +} + +.data-toggle-slider-input::-webkit-slider-runnable-track { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.data-toggle-slider-input::-moz-range-track, +.data-toggle-slider-input::-moz-range-progress { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); +} + +.data-toggle-slider-input::-webkit-slider-thumb { + width: 11px; + height: 11px; + margin-top: -3px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-input::-moz-range-thumb { + width: 11px; + height: 11px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-ticks { + position: relative; + height: 10px; + margin: 0 5px; + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.4px; + color: var(--text-dim); +} + +.data-toggle-slider-tick { + position: absolute; + top: 0; + left: var(--at); + transform: translateX(-50%); + white-space: nowrap; +} + +.data-toggle-slider-tick:first-child { + transform: none; +} /* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */ .sdr-radio-card { display: grid; From 22a87500c6b3296da1003a4101ca0ed80ba1f992 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:30:58 +0400 Subject: [PATCH 018/262] docs(fires): current-state note and changelog fragment for the intensity filter (#1426) Describe the six-stop FRP ramp, the slider / confidence switch / counter, the single filter point, the share-token options and the tool arguments in docs/CURRENT-STATE.md, and add changelog.d/1426-fire-intensity-filter.md as CONTRIBUTING asks for runtime changes. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 8 ++++++++ docs/CURRENT-STATE.md | 24 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 changelog.d/1426-fire-intensity-filter.md diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md new file mode 100644 index 000000000..fad9051e9 --- /dev/null +++ b/changelog.d/1426-fire-intensity-filter.md @@ -0,0 +1,8 @@ +- Color every Active Fires detection by fire radiative power on a six-stop + cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · + 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the + ramp, an All / Nominal+ / High confidence switch and an "N of M shown" + counter to the layer row. The filter applies once before rendering, so + markers, cards, heat cells, context and the top-fires query agree; share + links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching + `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..61699bda9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2054,6 +2054,30 @@ Generated style sliders use the same name as their visible parameter label. The first-run suppression checkbox keeps its native wrapping label, so its accessible name remains "Don't show this again". Control behavior is unchanged. +## Active Fires intensity filter and color ramp + +Every FIRMS detection is colored by fire radiative power on a six-stop cold → +hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat +cells, glow sprites and card accents read the same ramp; confidence and +brightness do not affect the color. + +The Active Fires row carries a minimum-FRP slider on a log scale whose track is +painted with the ramp and ticked at the same six values, an All / Nominal+ / +High confidence switch, and an "N of M shown" counter. The default is 0 MW and +All, because many of the strongest detections are rated low confidence. The +filter runs once in `ingestion.applyFilter()` before rendering, so markers, +cards, heat cells, the shared context store, the top-fires query and the +analyst records all see one filtered set; a selected fire that no longer passes +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. + +The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` +(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and +names the applied criteria in its notes. Stale-snapshot and missing-source +reporting are unchanged. + ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. From 1dad6b658a77b80465204cd10c5247cb4c2c084c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:50:03 +0400 Subject: [PATCH 019/262] feat(fires): caption the confidence switch and name the slider "Fire intensity (FRP)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch had no visible word saying what ALL / NOMINAL+ / HIGH refer to, and "Min FRP" assumed the reader knows the acronym. The segmented row descriptor gains an optional visible `label` (a caption above the group); the fires row uses "Confidence" and "Fire intensity (FRP)", and the slider value reads "≥ N MW" so the threshold is still clear. Locales updated. Co-Authored-By: Claude Fable 5.1 --- docs/CURRENT-STATE.md | 7 ++++--- src/i18n/locales/es.json | 5 +++-- src/i18n/locales/ru.json | 5 +++-- src/i18n/locales/zh-CN.json | 5 +++-- src/i18n/same-as-english.json | 4 ++-- src/layers/firms/queries.js | 5 +++-- src/ui/rowFilterControls.js | 24 ++++++++++++++++++++++-- src/ui/rowFilterControls.test.mjs | 23 ++++++++++++++++++++--- src/ui/styles/radio.css | 10 ++++++++++ 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61699bda9..2132a910f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2062,9 +2062,10 @@ hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from cells, glow sprites and card accents read the same ramp; confidence and brightness do not affect the color. -The Active Fires row carries a minimum-FRP slider on a log scale whose track is -painted with the ramp and ticked at the same six values, an All / Nominal+ / -High confidence switch, and an "N of M shown" counter. The default is 0 MW and +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, heat cells, the shared context store, the top-fires query and the diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ffee7fb87..babea8bfb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,9 +3008,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "High confidence detections only": "Solo detecciones de confianza alta", - "Min FRP": "FRP mín.", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "{value} MW": "{value} MW", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1cd88757d..6289ad38c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3077,9 +3077,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Nominal and high confidence detections": "Детекции средней и высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности", - "Min FRP": "Мин. FRP", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "{value} MW": "{value} МВт", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3044c45f8..110f7a77c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,9 +2870,10 @@ "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Nominal and high confidence detections": "标称和高置信度的检测", "High confidence detections only": "仅高置信度的检测", - "Min FRP": "最小 FRP", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "{value} MW": "{value} MW", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5f1b14f8a..5d0313965 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -121,7 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -197,7 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 220012133..44ce8573d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -135,11 +135,11 @@ export function createQueries({ return { slider: { id: 'min-frp', - label: t('Min FRP'), + label: t('Fire intensity (FRP)'), ariaLabel: t('Minimum fire radiative power, megawatts'), valueText: minFrp > 0 - ? t('{value} MW', { value: frpLabel(minFrp) }) + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) : t('All', { context: 'fire intensity filter' }), min: 0, max: SLIDER_STEPS, @@ -156,6 +156,7 @@ export function createQueries({ }, segmented: { id: 'confidence', + label: t('Confidence'), ariaLabel: t('Detection confidence'), options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ id: `confidence-${mode}`, diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js index b0299d24b..87a3e0bc6 100644 --- a/src/ui/rowFilterControls.js +++ b/src/ui/rowFilterControls.js @@ -104,8 +104,9 @@ export function syncSliderBlock(container, slider, { before = null } = {}) { * Buttons carry `data-chip-id` like chips so the row's delegated click handler * can resolve them; the caller matches `.data-toggle-segment`. * - * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, - * disabled, params }] }`. + * Descriptor: `{ id, label, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }` — `label` is an optional visible caption above the + * buttons (the group itself is a flex item, so the caption is a sibling). * @param {HTMLElement} container The row's `.data-toggle-controls` node. * @param {?object} segmented Descriptor, or null to remove the group. * @param {{before?: ?Node}} [options] Insertion anchor for a new group. @@ -121,19 +122,38 @@ export function syncSegmentedGroup( const existing = [...container.children].find( (node) => node.dataset?.segmentedId, ); + const existingCaption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor, + ); if (!segmented) { existing?.remove(); + existingCaption?.remove(); return null; } let group = existing; if (!group || group.dataset.segmentedId !== segmented.id) { existing?.remove(); + existingCaption?.remove(); group = document.createElement('div'); group.className = 'gev-segmented data-toggle-segmented'; group.dataset.segmentedId = segmented.id; group.setAttribute('role', 'radiogroup'); container.insertBefore(group, before); } + let caption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor === segmented.id, + ); + if (segmented.label) { + if (!caption) { + caption = document.createElement('span'); + caption.className = 'data-toggle-segmented-label'; + caption.dataset.segmentedCaptionFor = segmented.id; + container.insertBefore(caption, group); + } + set(caption, 'textContent', segmented.label); + } else { + caption?.remove(); + } if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); const stale = new Map(); for (const node of [...group.children]) diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs index 9cc8fb1d4..ac5d2c1d0 100644 --- a/src/ui/rowFilterControls.test.mjs +++ b/src/ui/rowFilterControls.test.mjs @@ -24,6 +24,7 @@ const sliderDescriptor = (overrides = {}) => ({ const segmentedDescriptor = (active = 'all') => ({ id: 'confidence', + label: 'Confidence', ariaLabel: 'Detection confidence', options: ['all', 'nominal', 'high'].map((mode) => ({ id: `confidence-${mode}`, @@ -96,6 +97,9 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(group.getAttribute('role'), 'radiogroup'); assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); assert.ok(group.className.split(' ').includes('gev-segmented')); + const caption = container.children[container.children.indexOf(group) - 1]; + assert.equal(caption.className, 'data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); const buttons = group.children; assert.deepEqual( buttons.map((b) => [ @@ -118,10 +122,18 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 2, 'caption and group'); + + syncSegmentedGroup(container, { ...segmentedDescriptor('high'), label: '' }); + assert.equal( + container.querySelector('.data-toggle-segmented-label'), + null, + 'an empty label removes the caption', + ); assert.equal(container.children.length, 1); syncSegmentedGroup(container, null); - assert.equal(container.children.length, 0); + assert.equal(container.children.length, 0, 'null removes group and caption'); }); test('a layer row dispatches slider input and switch clicks as user params', async () => { @@ -163,10 +175,15 @@ test('a layer row dispatches slider input and switch clicks as user params', asy row.querySelector('.data-toggle-controls-info').textContent, '12 of 40 shown', ); - // Order: switch, slider, info. + // Order: switch caption, switch, slider, info. assert.deepEqual( container.children.map((node) => node.className.split(' ')[0]), - ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + [ + 'data-toggle-segmented-label', + 'gev-segmented', + 'data-toggle-slider', + 'data-toggle-controls-info', + ], ); const input = row.querySelector('.data-toggle-slider-input'); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5047ae6f4..e005c5288 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1043,6 +1043,16 @@ html:not([lang='en']) .data-row-list-text { max-width: 220px; } +.data-toggle-segmented-label { + flex-basis: 100%; + padding-top: 2px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + .data-toggle-controls .data-toggle-segment { min-height: 20px; padding: 3px 6px; From 887b261195fe4a425e9cd8dfdd5027172b7c5342 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:19:40 +0400 Subject: [PATCH 020/262] fix(fires): paint aggregated heat cells warm by density, not by strongest FRP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coloring each far-zoom cell with the ramp stop of its strongest detection turned a burning region into a blue / teal / lime mosaic: neighbouring cells flipped hue on one outlier and the cold stops did not read as fire. Cells say where it burns, so they go back to the heat-normalized score (density, night share, max FRP) on the warm end of the ramp — lime, amber, red — with the cell card accent matching. Single detections keep the full six-stop FRP ramp. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 3 +- docs/CURRENT-STATE.md | 9 ++++-- src/layers/firms/model.js | 37 +++++++++++++++++------ src/layers/firms/rendering.js | 5 +-- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index fad9051e9..09e3c3a7d 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,7 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. The filter applies once before rendering, so + counter to the layer row. Far-zoom heat cells keep a warm density fill + (lime · amber · red). The filter applies once before rendering, so markers, cards, heat cells, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2132a910f..7e29d8675 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2058,9 +2058,12 @@ accessible name remains "Don't show this again". Control behavior is unchanged. Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat -cells, glow sprites and card accents read the same ramp; confidence and -brightness do not affect the color. +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow +sprites and fire-card accents read that ramp; confidence and brightness do +not affect the color. Aggregated heat cells (far zoom) instead take the warm +end of the ramp — lime, amber or red — from their heat-normalized score +(density, night share and strongest FRP), so a region reads as one field +rather than a per-cell mosaic; the cell card's accent matches its fill. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index a90066cc6..83f573c98 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -137,15 +137,33 @@ export function createModel({ } /** - * Fill color for an aggregated heat cell: the ramp color of its strongest - * detection, so cells, markers and the slider legend read on one scale. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * Severity stop for an aggregated heat cell from its heat-normalized score + * (density, night share and strongest FRP together). Cells say *where* it + * burns, so they stay on the warm end of the ramp — lime, amber, red — and + * read as one field instead of a per-cell mosaic; the cold stops belong to + * single detections, which say *how hard* each one burns. + * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function cellColorStop(normalized) { + const name = + normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); + } + + /** + * Fill color for an aggregated heat cell. + * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). * @param {number} alpha - Fill alpha (heat-normalized by the caller). * @returns {Cesium.Color} */ - function heatColor(maxFrp, alpha) { - return frpColorStop(maxFrp).color.withAlpha(alpha); + function heatColor(normalized, alpha) { + return cellColorStop(normalized).color.withAlpha(alpha); } /** @@ -476,14 +494,14 @@ export function createModel({ } /** - * Cell card accent: the ramp stop of the cell's strongest detection, + * Cell card accent: the warm severity stop of the cell's heat score, * mirroring heatColor. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} normalized - 0..1 heat score. * @returns {string} "r, g, b" accent string. */ - function cellAccent(maxFrp) { - return accentForSeverity(frpColorStop(maxFrp).name); + function cellAccent(normalized) { + return accentForSeverity(cellColorStop(normalized).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -590,6 +608,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 757fd4cf7..8ae445f91 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(cell.maxFrp, alpha); + const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,12 +170,13 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. + // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(cell.maxFrp), + accent: components.model.cellAccent(normalized), }); } From 6d6ad20ad3d171ade71911b9542d94d72855194d Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:41:30 -0700 Subject: [PATCH 021/262] fix(infrastructure): ground-sample only stem bases the camera sees scene.sampleHeight reads only surface rendered for the current view. For a data centre or dam base outside the frustum or over the horizon it read through the Earth (about -6,344 km over Ashburn on Esri), failed the 9,000 m check and retried every 2 s indefinitely, one offscreen render per record. Over Ashburn that held rest frames at 58-589 ms. Unseen bases are now skipped without being stamped, so each is sampled on the first walk that sees it, and they no longer arm the retry render. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 4 ++- src/data/localGeojson.test.mjs | 53 +++++++++++++++++++++++++++++++++- src/data/localGeojsonCore.js | 44 +++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 09ae87f1a..e5202e55c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,7 +1959,9 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle and uses valid loaded terrain as a floor while +visible globe tiles to settle, samples only markers whose base is in the +camera's view and above the horizon (an unseen base reads through the Earth +and would retry every 2 s), and uses valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 13cde65a5..89de41529 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({ globalThis.window = { dispatchEvent() {} }; let sampleHeightImpl = sampleHeight; const sampleCalls = { count: 0 }; + // The camera frustum, reduced to the one answer ground sampling asks of it. + let baseVisibility = Cesium.Intersect.INSIDE; const overlayHost = { setVisible: (...args) => hostCalls.push(['visible', ...args]), setEntries: (...args) => hostCalls.push(['entries', ...args]), @@ -107,7 +109,14 @@ async function createRealLocalLayerHarness({ }, camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), - frustum: { fov: Math.PI / 3 }, + directionWC: new Cesium.Cartesian3(), + upWC: new Cesium.Cartesian3(), + frustum: { + fov: Math.PI / 3, + computeCullingVolume: () => ({ + computeVisibility: () => baseVisibility, + }), + }, moveEnd, flyTo() {}, }, @@ -151,6 +160,10 @@ async function createRealLocalLayerHarness({ sampleCalls, /** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */ setSampleHeight(next) { sampleHeightImpl = next; }, + /** Put the feature's base inside (true) or outside the camera frustum. */ + setBaseInView(inView) { + baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE; + }, cleanup() { if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; @@ -736,6 +749,44 @@ test('ground sampling waits for visible globe tiles before caching a height', as assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); +test('an unseen base is not sampled and arms no retry until it is in view', async (t) => { + // Out of view the scene has no surface under the base, so the depth sample + // would pass through the Earth (about -6,344 km) and fail on every retry. + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + _resetRenderGovernorForTest(); + const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; + installRenderGovernor({ scene: governorScene }); + governorScene.renders = 0; // discard the governor's own install settling frame + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + _resetRenderGovernorForTest(); + }); + env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 }; + setCameraAltitude(env, 20_000); + env.setBaseInView(false); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled'); + assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering'); + assert.ok(Math.abs(baseHeightM(env)) < 0.01); + + // In the frustum but over the horizon: 50 m up, about 67 km away. + env.setBaseInView(true); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled'); + assert.equal(governorScene.renders, 0); + + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it'); + assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); +}); + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 1d04fa46c..9d97df61c 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer( // governor the retry's preRender never arrives on a parked camera // and the stem stays at ellipsoid height (buried/floating) until // the user happens to move. Schedule the frame the retry needs. - // Gated on a sampleable scene and in-range records only, so a far - // camera (or a keyless scene) stays fully idle; the distance is - // only computed for still-unsampled stems. + // Gated on a sampleable scene and in-range, in-view records only, + // so a far camera (or a keyless scene) stays fully idle; the + // distance is only computed for still-unsampled stems. if ( canSampleGround && !record.groundSampled && @@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer( Cesium.Cartesian3.distance( viewer.camera.positionWC, record.base, - ) < GROUND_SAMPLE_MAX_DISTANCE_M + ) < GROUND_SAMPLE_MAX_DISTANCE_M && + groundSampleInView(viewer, record) ) { groundRetryPending = true; } @@ -1130,9 +1131,44 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } +const groundSampleSphere = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1, +); + +const groundSampleOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, +); + +/** + * Whether the camera sees the record's base: inside the view frustum and + * above the horizon. scene.sampleHeight reads only surface rendered for the + * current view, so a sample of an unseen base passes through the Earth (about + * -6,344 km near Ashburn on Esri) and fails on every 2 s retry, each one a + * full offscreen render: 110 unseen data centres there held the page near 2 s + * a frame indefinitely. An unseen base keeps its stem at the unsampled height + * and is sampled on the first walk that sees it. + */ +function groundSampleInView(viewer, record) { + const camera = viewer.camera; + const culling = camera.frustum.computeCullingVolume( + camera.positionWC, + camera.directionWC, + camera.upWC, + ); + Cesium.Cartesian3.clone(record.base, groundSampleSphere.center); + if ( + culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE + ) + return false; + groundSampleOccluder.cameraPosition = camera.positionWC; + return groundSampleOccluder.isPointVisible(record.base); +} + function sampleLocalGroundHeight(viewer, record, now) { if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; + if (!groundSampleInView(viewer, record)) return false; record.lastGroundSampleMs = now; const globe = viewer.scene.globe; if (globe?.show && globe.tilesLoaded === false) return false; From 189b30b5459a1a476fed3833325d2f460ce07862 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:43:40 -0700 Subject: [PATCH 022/262] docs(changelog): add the infrastructure ground-sampling entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1464-infrastructure-ground-sample-in-view.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1464-infrastructure-ground-sample-in-view.md diff --git a/changelog.d/1464-infrastructure-ground-sample-in-view.md b/changelog.d/1464-infrastructure-ground-sample-in-view.md new file mode 100644 index 000000000..8ab5d1d88 --- /dev/null +++ b/changelog.d/1464-infrastructure-ground-sample-in-view.md @@ -0,0 +1,4 @@ +- Ground-sample Data Centers and Dams markers only when the camera sees their + base. An off-screen base read through the Earth and retried every 2 s, which + held the page near 2 s a frame over Ashburn, Virginia on the Esri map + (quanticsoul4772, #1464). From ecc0ced47c4f765627910bf089e303251cd5e9b4 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:25:57 -0700 Subject: [PATCH 023/262] fix(infrastructure): test the ground, not the ellipsoid, for in-view sampling An unsampled base sits at ellipsoid height 0. Over high terrain seen at an angle that point projects below the screen while the ground above it is in view, so the gate skipped ground the camera could see and never sampled it. The gate now tests loaded globe terrain when there is some, and otherwise the whole span of heights a sample can accept (0 to 9,000 m). Co-Authored-By: Claude Opus 5.5 --- ...64-infrastructure-ground-sample-in-view.md | 6 +- docs/CURRENT-STATE.md | 8 ++- src/data/localGeojson.test.mjs | 58 +++++++++++++++++++ src/data/localGeojsonCore.js | 49 +++++++++++++--- 4 files changed, 106 insertions(+), 15 deletions(-) diff --git a/changelog.d/1464-infrastructure-ground-sample-in-view.md b/changelog.d/1464-infrastructure-ground-sample-in-view.md index 8ab5d1d88..b563c86b4 100644 --- a/changelog.d/1464-infrastructure-ground-sample-in-view.md +++ b/changelog.d/1464-infrastructure-ground-sample-in-view.md @@ -1,4 +1,4 @@ -- Ground-sample Data Centers and Dams markers only when the camera sees their - base. An off-screen base read through the Earth and retried every 2 s, which - held the page near 2 s a frame over Ashburn, Virginia on the Esri map +- Ground-sample Data Centers and Dams markers only when the camera sees the + ground under them. Unseen ground read through the Earth and retried every + 2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s (quanticsoul4772, #1464). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e5202e55c..065d67112 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,9 +1959,11 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle, samples only markers whose base is in the -camera's view and above the horizon (an unseen base reads through the Earth -and would retry every 2 s), and uses valid loaded terrain as a floor while +visible globe tiles to settle, samples only markers whose ground is in the +camera's view and above the horizon (unseen ground reads through the Earth +and would retry every 2 s; until it is seen, a stem keeps its unsampled +height, which can show for a stem whose base is just off screen), and uses +valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 89de41529..1c47cb8c1 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -787,6 +787,64 @@ test('an unseen base is not sampled and arms no retry until it is in view', asyn assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); +for (const [label, globe] of [ + ['loaded terrain', { show: true, tilesLoaded: true, getHeight: () => 2_500 }], + ['a hidden globe', { show: false, tilesLoaded: true, getHeight: () => undefined }], +]) { + test(`high ground in view is sampled with ${label} although its unsampled base projects off screen`, async (t) => { + // A base on 2,500 m terrain, seen from 3 km away at a shallow angle: its + // ellipsoid-height point is far below the bottom of the screen. + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 2_500, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = globe; + const entity = env.dataSources[0].entities.values[0]; + const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); + const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, 2_500); + const position = Cesium.Cartesian3.fromRadians( + base.longitude, + base.latitude - 3_000 / 6_371_000, + 2_500 + 1_000, + ); + const direction = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const right = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + direction, + Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); + const frustum = new Cesium.PerspectiveFrustum({ + fov: Math.PI / 3, + aspectRatio: 800 / 600, + near: 1, + far: 1e8, + }); + Object.assign(env.viewer.camera, { + positionWC: position, + directionWC: direction, + upWC: up, + frustum, + }); + const offScreen = frustum + .computeCullingVolume(position, direction, up) + .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); + assert.equal(offScreen, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); + + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); + assert.ok(Math.abs(baseHeightM(env) - 2_500) < 0.01); + }); +} + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 9d97df61c..ec8fc824e 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1140,29 +1140,60 @@ const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); +const groundSampleCarto = new Cesium.Cartographic(); +const groundSampleTop = new Cesium.Cartesian3(); + /** - * Whether the camera sees the record's base: inside the view frustum and - * above the horizon. scene.sampleHeight reads only surface rendered for the - * current view, so a sample of an unseen base passes through the Earth (about - * -6,344 km near Ashburn on Esri) and fails on every 2 s retry, each one a - * full offscreen render: 110 unseen data centres there held the page near 2 s - * a frame indefinitely. An unseen base keeps its stem at the unsampled height - * and is sampled on the first walk that sees it. + * Whether the camera can see the ground under the record: inside the view + * frustum and above the horizon. scene.sampleHeight reads only surface + * rendered for the current view, so a sample of unseen ground passes through + * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s + * retry, each one a full offscreen render; 110 unseen data centres there held + * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test + * uses loaded globe terrain when there is some, and otherwise every height a + * sample can accept (0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M): high ground in + * view is never mistaken for an unseen base. Unseen ground is sampled on the + * first walk that sees it. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; + const globe = viewer.scene.globe; + const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; + const known = + Number.isFinite(terrain) && + Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + const span = GROUND_SAMPLE_MAX_ABS_HEIGHT_M / 2; + groundSampleCarto.longitude = record.carto.longitude; + groundSampleCarto.latitude = record.carto.latitude; + groundSampleCarto.height = known ? terrain : span; + Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + groundSampleSphere.center, + ); + groundSampleSphere.radius = known ? 1 : span; const culling = camera.frustum.computeCullingVolume( camera.positionWC, camera.directionWC, camera.upWC, ); - Cesium.Cartesian3.clone(record.base, groundSampleSphere.center); if ( culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE ) return false; + // The highest ground the sample could find is the last to drop below the + // horizon. + if (!known) groundSampleCarto.height = GROUND_SAMPLE_MAX_ABS_HEIGHT_M; groundSampleOccluder.cameraPosition = camera.positionWC; - return groundSampleOccluder.isPointVisible(record.base); + return groundSampleOccluder.isPointVisible( + known + ? groundSampleSphere.center + : Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + groundSampleTop, + ), + ); } function sampleLocalGroundHeight(viewer, record, now) { From f392eecbd969a0f8941b6ee2748bc58362a51865 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:47:05 -0700 Subject: [PATCH 024/262] fix(infrastructure): test ground below 0 m and unknown terrain correctly The ellipsoidal horizon test hid ground below the ellipsoid (low-geoid coasts, the Dead Sea) from a camera near it, so it is now tested at 0 m. With the globe hidden the ground is tested as the vertical from 0 to 9,000 m against the frustum planes, in place of a 4.5 km sphere that let in much unseen ground. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 11 +-- src/data/localGeojson.test.mjs | 128 +++++++++++++++++++++------------ src/data/localGeojsonCore.js | 77 +++++++++++--------- 3 files changed, 130 insertions(+), 86 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 065d67112..4815e0fd1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,11 +1959,12 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle, samples only markers whose ground is in the -camera's view and above the horizon (unseen ground reads through the Earth -and would retry every 2 s; until it is seen, a stem keeps its unsampled -height, which can show for a stem whose base is just off screen), and uses -valid loaded terrain as a floor while +visible globe tiles to settle, and skips markers whose ground the camera +cannot see (unseen ground reads through the Earth and would retry every 2 s). +Ground is tested at loaded globe terrain, or across 0-9,000 m when the globe +is hidden, so photoreal views skip only ground well out of view. Until it is +seen, a stem keeps its unsampled height, which can show for a stem whose base +is just off screen. Sampling uses valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c47cb8c1..3e104592f 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -113,8 +113,13 @@ async function createRealLocalLayerHarness({ upWC: new Cesium.Cartesian3(), frustum: { fov: Math.PI / 3, + // No planes: everything is in view. One plane no point satisfies: + // nothing is. computeCullingVolume: () => ({ - computeVisibility: () => baseVisibility, + planes: + baseVisibility === Cesium.Intersect.OUTSIDE + ? [new Cesium.Cartesian4(0, 0, 0, -1)] + : [], }), }, moveEnd, @@ -787,64 +792,95 @@ test('an unseen base is not sampled and arms no retry until it is in view', asyn assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); -for (const [label, globe] of [ - ['loaded terrain', { show: true, tilesLoaded: true, getHeight: () => 2_500 }], - ['a hidden globe', { show: false, tilesLoaded: true, getHeight: () => undefined }], +/** + * Give the harness a real Cesium camera `southM` metres south of the base at + * `cameraM` metres above the ellipsoid, looking at the base's ground at + * `groundM` (or directly away from it). + */ +function aimRealCamera(env, { groundM, cameraM, southM, away = false }) { + const entity = env.dataSources[0].entities.values[0]; + const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); + const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM); + const position = Cesium.Cartesian3.fromRadians( + base.longitude, + base.latitude - southM / 6_371_000, + cameraM, + ); + const direction = Cesium.Cartesian3.normalize( + away + ? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3()) + : Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const right = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + direction, + Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); + const frustum = new Cesium.PerspectiveFrustum({ + fov: Math.PI / 3, + aspectRatio: 800 / 600, + near: 1, + far: 1e8, + }); + Object.assign(env.viewer.camera, { + positionWC: position, + directionWC: direction, + upWC: up, + frustum, + }); + return frustum + .computeCullingVolume(position, direction, up) + .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); +} + +const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height }); +const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined }; + +for (const { label, globe, groundM, cameraM, southM, baseOffScreen } of [ + // 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height + // base is far below the bottom of the screen. + { label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + { label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + // A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the + // ellipsoid: the ellipsoidal horizon would hide the ground itself. + { label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false }, ]) { - test(`high ground in view is sampled with ${label} although its unsampled base projects off screen`, async (t) => { - // A base on 2,500 m terrain, seen from 3 km away at a shallow angle: its - // ellipsoid-height point is far below the bottom of the screen. + test(`${label} in view is sampled`, async (t) => { const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 2_500, + sampleHeightSupported: true, sampleHeight: () => groundM, }); const clock = installFakeClock(t); t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); env.viewer.scene.globe = globe; - const entity = env.dataSources[0].entities.values[0]; - const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); - const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, 2_500); - const position = Cesium.Cartesian3.fromRadians( - base.longitude, - base.latitude - 3_000 / 6_371_000, - 2_500 + 1_000, - ); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const right = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross( - direction, - Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); - const frustum = new Cesium.PerspectiveFrustum({ - fov: Math.PI / 3, - aspectRatio: 800 / 600, - near: 1, - far: 1e8, - }); - Object.assign(env.viewer.camera, { - positionWC: position, - directionWC: direction, - upWC: up, - frustum, - }); - const offScreen = frustum - .computeCullingVolume(position, direction, up) - .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); - assert.equal(offScreen, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); + const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM }); + if (baseOffScreen) + assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); clock.advance(2_100); env.preRender.raise(); assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); - assert.ok(Math.abs(baseHeightM(env) - 2_500) < 0.01); + assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01); }); } +test('under a hidden globe, ground behind the camera is not sampled', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 2_500, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = hiddenGlobe; + aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true }); + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 0); +}); + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index ec8fc824e..596b78ed9 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1131,17 +1131,25 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } -const groundSampleSphere = new Cesium.BoundingSphere( - new Cesium.Cartesian3(), - 1, -); - const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); const groundSampleCarto = new Cesium.Cartographic(); -const groundSampleTop = new Cesium.Cartesian3(); +const groundSampleLow = new Cesium.Cartesian3(); +const groundSampleHigh = new Cesium.Cartesian3(); + +/** The record's vertical at `height` metres above the ellipsoid. */ +function groundSamplePoint(record, height, result) { + groundSampleCarto.longitude = record.carto.longitude; + groundSampleCarto.latitude = record.carto.latitude; + groundSampleCarto.height = height; + return Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + result, + ); +} /** * Whether the camera can see the ground under the record: inside the view @@ -1150,10 +1158,10 @@ const groundSampleTop = new Cesium.Cartesian3(); * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s * retry, each one a full offscreen render; 110 unseen data centres there held * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test - * uses loaded globe terrain when there is some, and otherwise every height a - * sample can accept (0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M): high ground in - * view is never mistaken for an unseen base. Unseen ground is sampled on the - * first walk that sees it. + * uses loaded globe terrain when there is some. Otherwise (a hidden globe) it + * tests the vertical from 0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a + * sample can accept, so high ground in view is never mistaken for an unseen + * base. Unseen ground is sampled on the first walk that sees it. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; @@ -1162,37 +1170,36 @@ function groundSampleInView(viewer, record) { const known = Number.isFinite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; - const span = GROUND_SAMPLE_MAX_ABS_HEIGHT_M / 2; - groundSampleCarto.longitude = record.carto.longitude; - groundSampleCarto.latitude = record.carto.latitude; - groundSampleCarto.height = known ? terrain : span; - Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleSphere.center, - ); - groundSampleSphere.radius = known ? 1 : span; - const culling = camera.frustum.computeCullingVolume( + const low = groundSamplePoint(record, known ? terrain : 0, groundSampleLow); + const high = known + ? low + : groundSamplePoint( + record, + GROUND_SAMPLE_MAX_ABS_HEIGHT_M, + groundSampleHigh, + ); + // The vertical is unseen only when both ends are outside the same plane. + const { planes } = camera.frustum.computeCullingVolume( camera.positionWC, camera.directionWC, camera.upWC, ); - if ( - culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE - ) - return false; - // The highest ground the sample could find is the last to drop below the - // horizon. - if (!known) groundSampleCarto.height = GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + for (const plane of planes) { + if ( + Cesium.Cartesian3.dot(plane, low) + plane.w < 0 && + Cesium.Cartesian3.dot(plane, high) + plane.w < 0 + ) + return false; + } + // The ellipsoidal occluder hides every point below the ellipsoid from a + // camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is + // tested at 0 m; unknown ground at the top of the vertical. + const horizonHeight = known + ? Math.max(terrain, 0) + : GROUND_SAMPLE_MAX_ABS_HEIGHT_M; groundSampleOccluder.cameraPosition = camera.positionWC; return groundSampleOccluder.isPointVisible( - known - ? groundSampleSphere.center - : Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleTop, - ), + groundSamplePoint(record, horizonHeight, groundSampleHigh), ); } From b778a43f1d7538a10d29f89b0611c29e3dabfe6e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:05:14 +0400 Subject: [PATCH 025/262] feat(fires): Snap Map-style heat raster and its globe imagery drape Detections are binned on a 2048x1024 equirectangular grid weighted 1 + log10(1 + FRP), Gaussian-blurred (longitude wraps), normalized on a log scale against the 99.5th percentile and mapped blue -> red with an alpha ramp that is fully transparent below the floor. heatImagery drapes the bytes as one full-globe SingleTileImageryProvider on the active imagery host. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsHeatRaster.js | 176 ++++++++++++++++++++++++++ src/data/firmsHeatRaster.test.mjs | 73 +++++++++++ src/layers/firms/heatImagery.js | 71 +++++++++++ src/layers/firms/heatImagery.test.mjs | 87 +++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/data/firmsHeatRaster.js create mode 100644 src/data/firmsHeatRaster.test.mjs create mode 100644 src/layers/firms/heatImagery.js create mode 100644 src/layers/firms/heatImagery.test.mjs diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js new file mode 100644 index 000000000..52a020671 --- /dev/null +++ b/src/data/firmsHeatRaster.js @@ -0,0 +1,176 @@ +/** + * Snap Map–style heat raster for the Active Fires far-zoom bands: detections + * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), + * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: + * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + */ + +/** Below this normalized heat nothing is painted: empty land stays empty. */ +export const HEAT_FLOOR = 0.03; + +export const HEAT_PALETTE = Object.freeze([ + { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, + { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, + { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, + { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, + { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, + { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, + { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, +]); + +export function heatColorAt(t) { + if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0]; + const v = Math.min(1, t); + let i = 1; + while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1; + const a = HEAT_PALETTE[i - 1]; + const b = HEAT_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + const mix = (p, q) => Math.round(p + (q - p) * k); + return [ + mix(a.rgb[0], b.rgb[0]), + mix(a.rgb[1], b.rgb[1]), + mix(a.rgb[2], b.rgb[2]), + Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255), + ]; +} + +const defaultWeight = (fire) => + 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + +export function binFires(fires, { width, height, weight = defaultWeight }) { + const grid = new Float32Array(width * height); + for (const fire of fires) { + if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; + const x = Math.min( + width - 1, + Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), + ); + const y = Math.min( + height - 1, + Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), + ); + grid[y * width + x] += weight(fire); + } + return grid; +} + +function kernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const k = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { + k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma)); + sum += k[i + radius]; + } + for (let i = 0; i < k.length; i++) k[i] /= sum; + return { k, radius }; +} + +export function blurGaussian(grid, width, height, sigma) { + const { k, radius } = kernel(sigma); + const tmp = new Float32Array(grid.length); + const out = new Float32Array(grid.length); + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + const v = grid[row + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const xx = (((x + i) % width) + width) % width; // longitude wraps + tmp[row + xx] += v * k[i + radius]; + } + } + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const v = tmp[y * width + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp + out[yy * width + x] += v * k[i + radius]; + } + } + } + return out; +} + +/** Bins of the log-density histogram used to find the reference quantile. */ +const QUANTILE_BINS = 4096; + +/** + * The p-quantile of the non-zero values, from a histogram of log1p(value): + * one linear pass instead of sorting ~350k burning pixels (a sort cost + * 200 ms+ and dominated the build). Bin width on the log axis is + * max/4096, far finer than the 256-step palette it feeds. + */ +function percentileOfNonZero(values, p) { + let count = 0; + let max = 0; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) { + count += 1; + if (v > max) max = v; + } + } + if (!count) return 0; + const logMax = Math.log1p(max); + const bins = new Uint32Array(QUANTILE_BINS); + const toBin = (QUANTILE_BINS - 1) / logMax; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1; + } + const target = Math.floor(p * (count - 1)); + let seen = 0; + for (let b = 0; b < QUANTILE_BINS; b++) { + seen += bins[b]; + if (seen > target) return Math.expm1((b + 1) / toBin); + } + return max; +} + +/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */ +const LUT_STEPS = 256; +let paletteLut = null; +function heatLut() { + if (!paletteLut) { + paletteLut = new Uint8ClampedArray(LUT_STEPS * 4); + for (let i = 0; i < LUT_STEPS; i++) { + paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4); + } + } + return paletteLut; +} + +export function buildHeatRaster( + fires, + { width, height, sigmaPx, percentile = 0.995 }, +) { + const density = blurGaussian( + binFires(fires, { width, height }), + width, + height, + sigmaPx, + ); + const reference = percentileOfNonZero(density, percentile); + const rgba = new Uint8ClampedArray(width * height * 4); + if (reference > 0) { + const lut = heatLut(); + const scale = (LUT_STEPS - 1) / Math.log1p(reference); + for (let i = 0; i < density.length; i++) { + const d = density[i]; + if (d <= 0) continue; + const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale)); + const l = step * 4; + if (lut[l + 3] === 0) continue; + const o = i * 4; + rgba[o] = lut[l]; + rgba[o + 1] = lut[l + 1]; + rgba[o + 2] = lut[l + 2]; + rgba[o + 3] = lut[l + 3]; + } + } + return { width, height, rgba, reference }; +} diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs new file mode 100644 index 000000000..704c83427 --- /dev/null +++ b/src/data/firmsHeatRaster.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + HEAT_PALETTE, + heatColorAt, + binFires, + blurGaussian, + buildHeatRaster, +} from './firmsHeatRaster.js'; + +const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); + +test('palette runs cold to hot and starts fully transparent', () => { + assert.equal(HEAT_PALETTE[0].at, 0); + assert.equal(HEAT_PALETTE.at(-1).at, 1); + assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); + assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); + const low = heatColorAt(0.15); + const high = heatColorAt(1); + assert.ok(low[2] > low[0], 'low heat is blue'); + assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(high[3] > low[3], 'hotter is more opaque'); +}); + +test('binFires drops each detection into its equirectangular pixel', () => { + const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + width: 4, + height: 2, + }); + assert.equal(grid[0], 1, 'north-west corner'); + assert.equal(grid[7], 1, 'south-east corner'); + assert.equal( + grid.reduce((a, b) => a + b, 0), + 2, + ); +}); + +test('binFires weights strong fires slightly more', () => { + const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); + assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +}); + +test('blurGaussian keeps mass and wraps across the antimeridian', () => { + const w = 16; + const h = 8; + const grid = new Float32Array(w * h); + grid[3 * w + 0] = 1; + const out = blurGaussian(grid, w, h, 1.5); + const sum = out.reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`); + assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge'); +}); + +test('buildHeatRaster is transparent where there are no fires', () => { + const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], { + width: 360, + height: 180, + sigmaPx: 2, + }); + assert.equal(r.rgba.length, 360 * 180 * 4); + const alphaAt = (lat, lon) => { + const x = Math.floor(((lon + 180) / 360) * 360); + const y = Math.floor(((90 - lat) / 180) * 180); + return r.rgba[(y * 360 + x) * 4 + 3]; + }; + assert.ok(alphaAt(40, -120) > 0, 'blob over the fires'); + assert.equal(alphaAt(-30, 60), 0, 'empty far away'); +}); + +test('buildHeatRaster with no fires is fully transparent', () => { + const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); + assert.ok(r.rgba.every((v) => v === 0)); +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js new file mode 100644 index 000000000..f2f99fbe6 --- /dev/null +++ b/src/layers/firms/heatImagery.js @@ -0,0 +1,71 @@ +/** + * Drapes the Active Fires heat raster over the globe as one full-extent + * imagery tile on the active imagery host (globe or 3D-tiles), like the wind + * layer's scalar field. Owns the canvas and the imagery layer; nothing else + * in the fires layer touches imagery. + */ + +const defaultCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createHeatImagery({ + cesium, + getHost, + createCanvas = defaultCanvas, +}) { + let raster = null; + let layer = null; + let collection = null; + let show = true; + + function detach() { + if (layer && collection) collection.remove(layer, true); + layer = null; + collection = null; + } + + function attach() { + detach(); + if (!raster) return; + const host = getHost?.() ?? { collection: null, kind: 'none' }; + if (!host.collection) return; + const canvas = createCanvas(raster.width, raster.height); + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(raster.width, raster.height); + image.data.set(raster.rgba); + ctx.putImageData(image, 0, 0); + const provider = new cesium.SingleTileImageryProvider({ + url: canvas.toDataURL('image/png'), + tileWidth: raster.width, + tileHeight: raster.height, + rectangle: cesium.Rectangle.MAX_VALUE, + }); + collection = host.collection; + layer = collection.addImageryProvider(provider); + layer.show = show; + collection.raiseToTop?.(layer); + } + + return { + setRaster(next) { + raster = next; + attach(); + }, + setShow(value) { + show = Boolean(value); + if (layer) layer.show = show; + }, + rehome() { + const host = getHost?.(); + if ((host?.collection ?? null) !== collection) attach(); + }, + destroy() { + detach(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs new file mode 100644 index 000000000..0fca7b97f --- /dev/null +++ b/src/layers/firms/heatImagery.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeatImagery } from './heatImagery.js'; + +function harness() { + const layers = []; + const removed = []; + const collection = { + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + layers.push(layer); + return layer; + }, + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + raiseToTop() {}, + }; + let host = { collection, kind: 'globe' }; + const cesium = { + Rectangle: { MAX_VALUE: { max: true } }, + SingleTileImageryProvider: class { + constructor(options) { + this.options = options; + } + }, + }; + const createCanvas = (w, h) => ({ + width: w, + height: h, + getContext: () => ({ + createImageData: (cw, ch) => ({ + data: new Uint8ClampedArray(cw * ch * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }); + const imagery = createHeatImagery({ + cesium, + getHost: () => host, + createCanvas, + }); + return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; +} + +const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + +test('setRaster drapes one full-globe tile and replaces it on the next build', () => { + const h = harness(); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1); + assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); + assert.equal(h.layers[0].provider.options.tileWidth, 4); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1, 'never two heat layers'); + assert.equal(h.removed[0].destroy, true); +}); + +test('setShow toggles without rebuilding', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setShow(false); + assert.equal(layer.show, false); + h.imagery.setShow(true); + assert.equal(h.layers[0], layer); + assert.equal(layer.show, true); +}); + +test('no imagery host keeps nothing draped; rehome brings it back', () => { + const h = harness(); + h.setHost({ collection: null, kind: 'none' }); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 0); + h.setHost({ collection: h.collection, kind: 'globe' }); + h.imagery.rehome(); + assert.equal(h.layers.length, 1); +}); + +test('destroy removes the layer', () => { + const h = harness(); + h.imagery.setRaster(raster); + h.imagery.destroy(); + assert.equal(h.layers.length, 0); +}); From de87aaf86acfbf3fd358833c6478e93d9718d3f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:11:32 +0400 Subject: [PATCH 026/262] feat(fires): far zoom shows a Snap Map-style heatmap instead of heat squares The global and regional LOD bands drape the heat raster on the active imagery host and draw no rectangle entities; the 2/1 degree aggregation now only feeds the "N FIRES" cards, whose accent is the strongest fire's FRP color. Markers below ~750 km hide the heatmap, as do disable and destroy. A stale raster is rebuilt immediately on first paint and with a 150 ms trailing debounce afterwards, so a slider drag does not re-encode on every step. The shell hands the layer its imagery host, so the heatmap follows globe and 3D-tiles map stacks. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsInteraction.test.mjs | 75 ++++++++++++++++++ src/layers/firms/heatImagery.js | 4 + src/layers/firms/ingestion.js | 7 +- src/layers/firms/lifecycle.js | 24 ++++++ src/layers/firms/model.js | 48 ++---------- src/layers/firms/policy.js | 32 ++++++-- src/layers/firms/queries.js | 15 +++- src/layers/firms/rendering.js | 119 +++++++++++++++++++---------- src/layers/firms/state.js | 19 +++++ src/ui/layerBindings.js | 6 ++ 10 files changed, 257 insertions(+), 92 deletions(-) diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..39f0382c8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().loading, false); } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); + +test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { + // A canvas stub for the heat drape and the detection glow sprites. + const priorDocument = globalThis.document; + const restoreDocument = () => { + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + }; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + const added = []; + const removed = []; + h.viewer.scene.primitives = { + add() {}, + remove() {}, + raiseToTop() {}, + contains: () => true, + }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + try { + const collection = { + addImageryProvider: (provider) => { + const layer = { provider, show: true }; + added.push(layer); + return layer; + }, + remove: (layer, destroy) => removed.push({ layer, destroy }), + raiseToTop() {}, + }; + h.layer.attachShellServices({ + imageryHost: () => ({ kind: 'globe', collection }), + }); + h.layer._renderLodForTest('global'); + assert.equal(added.length, 1, 'one heat imagery layer'); + assert.equal(added[0].show, true); + assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.ok(h.published.length > 0, 'the far band still paints cards'); + + h.layer._renderLodForTest('regional'); + assert.equal(added.length, 1, 'switching far bands reuses the raster'); + + h.layer._renderLodForTest('close'); + assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + + h.layer._renderLodForTest('global'); + assert.equal(added[0].show, true); + h.layer.disable(); + assert.equal(added[0].show, false, 'disable hides the heatmap'); + + h.layer.destroy(h.viewer); + assert.deepEqual( + removed.map((entry) => [entry.layer, entry.destroy]), + [[added[0], true]], + ); + } finally { + h.cleanup(); + restoreDocument(); + } +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index f2f99fbe6..31fbf1f69 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -55,6 +55,10 @@ export function createHeatImagery({ raster = next; attach(); }, + /** True once a raster was handed over (painted or waiting for a host). */ + hasRaster() { + return raster !== null; + }, setShow(value) { show = Boolean(value); if (layer) layer.show = show; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 215c35da3..cb9dbd72f 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -89,14 +89,15 @@ export function createIngestion({ /** * Run the intensity filter over the loaded payload. This is the ONE place * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, - * heat cells, the context store, the top-fires query and the analyst - * records, so none of them can disagree about which fires exist. Cells are - * aggregated per shown set, so the cache goes with it. + * the heatmap, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells and + * the heat raster are built per shown set, so their caches go with it. */ function applyFilter() { layerState._fires = filterFires(layerState._allFires, layerState._filter); layerState._cellCacheByGrid.clear(); + layerState._heatRaster = null; layerState._firesByFrp = filterFires( layerState._allFiresByFrp, layerState._filter, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 1138504dd..72434ad46 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -14,6 +15,20 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { + /** + * Shell services: `imageryHost()` resolves where draped imagery goes on + * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The + * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services + */ + attachShellServices(services) { + layerState._imageryHost = + typeof services?.imageryHost === 'function' + ? services.imageryHost + : null; + layerState._heatImagery?.rehome(); + }, + init(viewer) { if (layerState._destroyed) return; layerState._viewer = viewer; @@ -40,6 +55,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') + layerState._heatImagery?.setShow(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -64,6 +81,7 @@ export function createLifecycle({ components.selection.clearFireSelection(); if (layerState._dataSource) layerState._dataSource.show = false; if (layerState._billboards) layerState._billboards.show = false; + layerState._heatImagery?.setShow(false); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -96,6 +114,12 @@ export function createLifecycle({ layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + layerState._heatImagery?.destroy(); + layerState._heatImagery = null; + layerState._heatRaster = null; + layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; layerState._rowControlsListener = null; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 83f573c98..c3d3be71a 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -136,36 +136,6 @@ export function createModel({ ); } - /** - * Severity stop for an aggregated heat cell from its heat-normalized score - * (density, night share and strongest FRP together). Cells say *where* it - * burns, so they stay on the warm end of the ramp — lime, amber, red — and - * read as one field instead of a per-cell mosaic; the cold stops belong to - * single detections, which say *how hard* each one burns. - * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). - * @returns {{name: string, minFrp: number, color: Cesium.Color}} - */ - - function cellColorStop(normalized) { - const name = - normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; - return ( - DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || - DETECTION_COLOR_STOPS[0] - ); - } - - /** - * Fill color for an aggregated heat cell. - * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). - * @param {number} alpha - Fill alpha (heat-normalized by the caller). - * @returns {Cesium.Color} - */ - - function heatColor(normalized, alpha) { - return cellColorStop(normalized).color.withAlpha(alpha); - } - /** * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. @@ -432,8 +402,8 @@ export function createModel({ /** * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. + * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets + * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {number} nowMs - Current epoch milliseconds. * @returns {Object} firmsLabels entry. @@ -494,14 +464,14 @@ export function createModel({ } /** - * Cell card accent: the warm severity stop of the cell's heat score, - * mirroring heatColor. - * @param {number} normalized - 0..1 heat score. + * Cell card accent: the cell's strongest fire on the FRP ramp, the same + * color its markers take once the camera is close enough to show them. + * @param {{maxFrp?: number}} cell - Aggregated cell. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - return accentForSeverity(cellColorStop(normalized).name); + function cellCardAccent(cell) { + return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -607,8 +577,6 @@ export function createModel({ boundsContainPoint, cellIntersectsBounds, heatScore, - heatColor, - cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, @@ -623,7 +591,7 @@ export function createModel({ buildFireCard, buildCellCard, applyFirmsOverlayPolicy, - cellAccent, + cellCardAccent, formatLatLon, formatFrp, formatAge, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 18ea88238..eb235bc81 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,9 +7,10 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. + * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their + * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` + * bands render individual fire detections as glow-sprite billboards. * Labels in every band go through the shared screen-space greedy declutter * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. */ @@ -18,7 +19,7 @@ export const LOD_LEVELS = [ { id: 'global', minHeight: 9000000, - mode: 'cells', + mode: 'heat', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000, @@ -26,7 +27,7 @@ export const LOD_LEVELS = [ { id: 'regional', minHeight: 3000000, - mode: 'cells', + mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, @@ -47,6 +48,25 @@ export const LOD_LEVELS = [ }, ]; +/** + * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in + * pixels. Built once per data refresh or filter change, never per camera move. + */ + +export const HEAT_RASTER = Object.freeze({ + width: 2048, + height: 1024, + sigmaPx: 3, +}); + +/** + * Trailing delay before a stale heatmap is rebuilt while one is already on + * screen: a slider drag fires many filter changes, and a build plus PNG + * encode costs a few hundred milliseconds. The first paint is immediate. + */ + +export const HEAT_REBUILD_MS = 150; + export const LOD_CHECK_MS = 650; /** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ @@ -90,7 +110,7 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; /** - * Color stops shared by cell heat fills and detection glow sprites: the + * Color stops for detection glow sprites and card accents: the * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 44ce8573d..797734597 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -9,7 +9,7 @@ import { sliderPositionForFrp, } from '../../data/firmsIntensity.js'; import { formatNumber, t } from '../../i18n/index.js'; -import { REFRESH_INTERVAL_MS } from './policy.js'; +import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; /** Slider resolution: whole-percent positions along the log axis. */ const SLIDER_STEPS = 1000; @@ -309,6 +309,19 @@ export function createQueries({ return result; }, + /** Test seam: render one named LOD band against a near-global view. */ + _renderLodForTest(lodId) { + const lod = LOD_LEVELS.find((level) => level.id === lodId); + if (lod.mode === 'detections') + components.rendering.renderDetections(lod, null); + else components.rendering.renderHeat(lod, null); + }, + + /** Test seam: entities drawn by the layer's data source. */ + _entityCountForTest() { + return layerState._dataSource?.entities?.values?.length ?? 0; + }, + /** Test seam that binds the production click path and indexes. */ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..84a6eed36 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,14 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + LOD_LEVELS, + CONTEXT_TOP_N, + HEAT_RASTER, + HEAT_REBUILD_MS, +} from './policy.js'; +import { createHeatImagery } from './heatImagery.js'; export function createRendering({ layerState, @@ -29,8 +36,11 @@ export function createRendering({ !layerState._dataSource || !layerState._fires.length || !layerState._viewer - ) + ) { + // A filter that leaves nothing shown must not leave the old heat behind. + if (!layerState._fires.length) layerState._heatImagery?.setShow(false); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -54,7 +64,7 @@ export function createRendering({ if (lod.mode === 'detections') { renderDetections(lod, bounds); } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); + renderHeat(lod, bounds); } return true; } @@ -118,65 +128,89 @@ export function createRendering({ return clipped; } + /** The heat imagery adapter, created on first use. */ + + function heatImagery() { + if (!layerState._heatImagery) { + layerState._heatImagery = createHeatImagery({ + cesium: Cesium, + getHost: () => + layerState._imageryHost?.() ?? { + collection: layerState._viewer?.imageryLayers ?? null, + kind: 'globe', + }, + }); + } + return layerState._heatImagery; + } + + /** Build the heat raster from the shown set and drape it. */ + + function rebuildHeatRaster() { + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + if (layerState._destroyed) return; + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + heatImagery().setRaster(layerState._heatRaster); + } + /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. + * Make sure the draped heatmap matches the shown set. The first paint is + * immediate; later rebuilds (filter drags, refreshes) trail by + * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + */ + + function ensureHeatRaster() { + if (layerState._heatRaster) return; + if (!heatImagery().hasRaster()) { + rebuildHeatRaster(); + return; + } + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = setTimeout( + rebuildHeatRaster, + HEAT_REBUILD_MS, + ); + } + + /** + * Far bands (global/regional): a Snap Map–style heatmap draped over the + * globe, transparent where nothing burns, plus the decluttered "N FIRES" + * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards go through the same screen-space label pipeline as detections + * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. * @param {?Object} bounds - Padded view bounds in degrees, or null. */ - function renderCells(cells, lod, bounds) { + function renderHeat(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (layerState._billboards) layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; - layerState._cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + const imagery = heatImagery(); + // The map stack may have changed since the last far render. + imagery.rehome(); + ensureHeatRaster(); + imagery.setShow(layerState._enabled); + + const cells = aggregateFires(lod, bounds); + layerState._cellCount = cells.length; layerState._labelCandidates = []; layerState._labelLodDistance = lod.labelDistance; + // Cells arrive heat-sorted, so candidate order doubles as label priority. for (const cell of cells) { - const score = components.model.heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - layerState._dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees, - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ - position, + position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellCardAccent(cell), }); } @@ -202,6 +236,7 @@ export function createRendering({ */ function renderDetections(lod, bounds) { + layerState._heatImagery?.setShow(false); ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (!layerState._billboards) return; @@ -345,7 +380,7 @@ export function createRendering({ return { renderCurrentLod, aggregateFires, - renderCells, + renderHeat, renderDetections, ensureDetectionCollections, removeDetectionCollections, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index d84ee2026..9a0b031b2 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) { layerState._cellCacheByGrid = new Map(); + /** + * Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt + * on the next far render. Cleared with the cell cache on every new shown set. + */ + + layerState._heatRaster = null; + + /** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */ + + layerState._heatRebuildTimer = null; + + /** Imagery adapter (heatImagery.js), created on the first far render. */ + + layerState._heatImagery = null; + + /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ + + layerState._imageryHost = null; + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index fb8f29119..60128f8e0 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -141,6 +141,12 @@ export class LayerBindings { }); this._weatherShellModules.push(layer); } + // Active Fires drapes its far-zoom heatmap on the same imagery host. + const fires = this._dataManager?.layers?.get('local-firms')?.module; + if (typeof fires?.attachShellServices === 'function') { + fires.attachShellServices({ imageryHost: this.services.imageryHost }); + this._weatherShellModules.push(fires); + } } _persistAwarenessSelection(event, cleared = false) { From 52e428955f4c56249bec1573a542169026561ed2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:17:06 +0400 Subject: [PATCH 027/262] docs(fires): describe the far-zoom heatmap Co-Authored-By: Claude Opus 5.5 --- changelog.d/1426-fire-intensity-filter.md | 6 +++--- docs/CURRENT-STATE.md | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 09e3c3a7d..188f964ad 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,8 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far-zoom heat cells keep a warm density fill - (lime · amber · red). The filter applies once before rendering, so - markers, cards, heat cells, context and the top-fires query agree; share + counter to the layer row. Far zoom shows a Snap Map–style heatmap instead + of squares, empty where nothing burns. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e29d8675..336b32132 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2060,10 +2060,19 @@ Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from 100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. Aggregated heat cells (far zoom) instead take the warm -end of the ramp — lime, amber or red — from their heat-normalized score -(density, night share and strongest FRP), so a region reads as one field -rather than a per-cell mosaic; the cell card's accent matches its fill. +not affect the color. + +At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +heatmap instead of individual markers: the shown detections are binned on a +global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +normalized on a log scale against the 99.5th percentile and mapped from blue +through cyan, green, yellow and orange to red. Nothing is painted where +nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery +tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +on each data refresh or filter change (the first paint at once, later ones +150 ms after the last change) and hidden once markers take over below +~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its @@ -2071,7 +2080,7 @@ value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, -cards, heat cells, the shared context store, the top-fires query and the +cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes is deselected. The layer state keeps both options under the `w` (`local-firms`) share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), From 2c422d2f3820707cf86de8c0f0618d57aade8a84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:26:23 +0400 Subject: [PATCH 028/262] build(boundaries): register the fires heat raster and imagery modules Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 17772b58e..894e70a68 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1174,6 +1174,7 @@ "modules": [ "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1185,6 +1186,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -1916,6 +1918,7 @@ "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2076,6 +2079,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2760,6 +2764,7 @@ "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -2890,6 +2895,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", From d32a4f62d33ef6dfe78d8e2b6f9373625c14e8e0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:09 +0400 Subject: [PATCH 029/262] i18n(zh-Hant): fire filter strings follow zh-CN wording (s2tw) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-Hant.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c71924233..9dadb3a50 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,16 +2868,16 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "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": "僅高可信度的探測", + "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": "最小火輻射功率(百萬瓦)", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "探測可信度", + "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" } From bef47ee7c7cca963c3eeb145da5aad0bb08263bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 18:25:33 +0400 Subject: [PATCH 030/262] fix(fires): weight the far-zoom heatmap by fire radiative power Heat was a detection count, so raising the minimum-FRP slider removed most detections and the hot spots faded to blue even where strong fires still burned. Each detection now adds its FRP in megawatts (0.5 MW floor), so weak fires carry little heat and filtering them out keeps strong-fire areas hot. On the live payload, hot spots that stay yellow or hotter at >=100 MW rise from 7% to 17% and their median heat from 0.10 to 0.38. --- docs/CURRENT-STATE.md | 4 +++- src/data/firmsHeatRaster.js | 11 ++++++++++- src/data/firmsHeatRaster.test.mjs | 33 +++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b34636004..1bd9c82cd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2096,7 +2096,9 @@ not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +radiated power, and raising the FRP slider does not dim areas where strong fires +burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped from blue through cyan, green, yellow and orange to red. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 52a020671..516c7676f 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -35,8 +35,17 @@ export function heatColorAt(t) { ]; } +/** Weight of a detection with no or zero FRP, in megawatts. */ +export const HEAT_MIN_WEIGHT_MW = 0.5; + +/** + * Heat is radiated power, not a head count: each detection adds its FRP in + * megawatts. Weak fires carry little power, so raising the minimum-FRP slider + * removes most detections without dimming the areas where strong fires burn + * (a count-based heat collapsed there and the hot spots faded). + */ const defaultWeight = (fire) => - 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 704c83427..2b51785e6 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + HEAT_MIN_WEIGHT_MW, HEAT_PALETTE, heatColorAt, binFires, @@ -23,7 +24,7 @@ test('palette runs cold to hot and starts fully transparent', () => { }); test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { width: 4, height: 2, }); @@ -35,9 +36,33 @@ test('binFires drops each detection into its equirectangular pixel', () => { ); }); -test('binFires weights strong fires slightly more', () => { - const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); - assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +test('binFires weights each detection by its radiative power', () => { + const grid = binFires( + [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + { width: 4, height: 2 }, + ); + assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); +}); + +test('filtering out weak fires keeps a strong fire hot', () => { + const strong = fire(0.1, 0.1, 300); + const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2)); + const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100)); + const options = { width: 360, height: 180, sigmaPx: 2 }; + const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0); + const red = (r) => r.rgba[pixel * 4]; + const alpha = (r) => r.rgba[pixel * 4 + 3]; + const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options); + const filtered = buildHeatRaster([strong, ...reference], options); + assert.ok(alpha(filtered) > 0); + assert.ok( + alpha(filtered) >= alpha(unfiltered) * 0.9, + `alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`, + ); + assert.ok( + red(filtered) >= red(unfiltered) * 0.9, + `red ${red(unfiltered)} -> ${red(filtered)}`, + ); }); test('blurGaussian keeps mass and wraps across the antimeridian', () => { From a4c7b20ca825a731531b61fc2935f1a47621248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:10 -0300 Subject: [PATCH 031/262] feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0) Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the city transport secretariat (SMTR): municipal buses (SPPO) and the Mobi-Rio BRT, the Transit layer's first Latin American region. Both feeds publish Position.speed in km/h instead of m/s. A new optional registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it once, so every speedMps consumer stays correct and other feeds are untouched. Document the source in DATA_SOURCES.md and update the region lists in README.md and docs/CURRENT-STATE.md. Refs #1480 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 3 +- README.md | 2 +- docs/CURRENT-STATE.md | 8 ++++-- src/data/transitFeeds.js | 50 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 43 +++++++++++++++++++++++++++++ src/data/transitProxy.js | 21 +++++++++++++- src/data/transitProxy.test.mjs | 26 ++++++++++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e31d0d3f0..2b989c509 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -95,6 +95,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **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. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **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)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -177,7 +178,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_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 sixteen registered 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 and YRT 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 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 and YRT 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 Browser.** `/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; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful 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. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/README.md b/README.md index 745bec3d8..965c0e283 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **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, 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), 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) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 966bb3878..2adac270e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4295,11 +4295,13 @@ 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. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland, and nine in +gate. Seventeen 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 Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, -Burlington, Barrie and Kingston. Mode silhouettes +Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which +the proxy converts to m/s before any card reads it. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index b8476a6ee..e871a2e89 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -385,7 +385,12 @@ function burlingtonRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'kmh', * }>>} + * + * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h + * instead of the m/s the spec requires; the proxy converts it before anything + * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -729,6 +734,51 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: burlingtonRouteLabel, }), + Object.freeze({ + id: 'smtr-rio-onibus', + name: 'Rio buses (SPPO)', + operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro', + region: 'Rio de Janeiro, RJ', + // Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to + // the Zona Sul, sat within 31 km of this point. + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), + Object.freeze({ + id: 'smtr-rio-brt', + name: 'Mobi-Rio BRT', + operator: + 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet', + region: 'Rio de Janeiro, RJ', + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index fd990af45..51903b7db 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -482,3 +482,46 @@ test('Burlington is live and carries its terms link, which its licence requires' assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); }); + +test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => { + const ids = (lat, lon) => + transitFeedsInRange(lat, lon).map((feed) => feed.id); + for (const [place, lat, lon] of [ + ['Centro', -22.9068, -43.1729], + ['Barra da Tijuca', -23.0004, -43.3659], + ['Santa Cruz', -22.9194, -43.6847], + ]) { + assert.ok( + ids(lat, lon).includes('smtr-rio-onibus'), + `buses reach ${place}`, + ); + assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`); + } + assert.deepEqual( + ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')), + [], + 'São Paulo, 360 km away, never polls Rio', + ); +}); + +test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { + for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { + const feed = getTransitFeed(id); + assert.equal(feed.defaultEnabled, true, `${id} is live`); + assert.equal(feed.license, 'CC BY 4.0'); + assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); + assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); + assert.equal(feed.speedUnit, 'kmh', `${id} publishes km/h`); + assert.equal(transitModeFor(feed, null), 'bus'); + } + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources + .split('\n') + .some((line) => line.startsWith('| **SMTR Rio de Janeiro')), + 'Rio has a DATA_SOURCES row', + ); +}); diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 458240e9d..2923c9c32 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,6 +224,25 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } +/** + * Bring a feed's speeds to the m/s the spec requires. A feed registered with + * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a + * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every + * consumer of `speedMps` honest without each one knowing about the feed. + * + * @param {object} feed Registry entry. + * @param {object[]} vehicles Normalized vehicle records. + * @returns {object[]} + */ +export function normalizeFeedSpeeds(feed, vehicles) { + if (feed?.speedUnit !== 'kmh') return vehicles; + return vehicles.map((vehicle) => + Number.isFinite(vehicle.speedMps) + ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } + : vehicle, + ); +} + /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -257,7 +276,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + normalizeFeedSpeeds(feed, decoded.vehicles), decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index dea10ab25..445e658f2 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -137,6 +137,7 @@ function encodeFeed({ const position = new PbfWriter(); position.writeFloatField(1, entity.lat); position.writeFloatField(2, entity.lon); + if (entity.speed != null) position.writeFloatField(5, entity.speed); vehicle.writeBytesField(2, position.finish()); if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp); const descriptor = new PbfWriter(); @@ -284,6 +285,31 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); +test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { + // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus + // would be reported at 130 km/h. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, + { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, + { id: 'silent', lat: -22.9, lon: -43.2 }, + ], + }); + const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); + const speeds = Object.fromEntries( + rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), + ); + assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); + assert.equal(speeds.still, 0); + assert.equal(speeds.silent, null, 'an absent speed stays absent'); + const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); + assert.equal( + mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, + 36, + 'a spec-conforming feed is left alone', + ); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); From 53d30c9e80d765b14eb2a2c35a957168cad2bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:47 -0300 Subject: [PATCH 032/262] docs(changelog): add fragment for #1481 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1481-rio-transit.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1481-rio-transit.md diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md new file mode 100644 index 000000000..62b4ede04 --- /dev/null +++ b/changelog.d/1481-rio-transit.md @@ -0,0 +1,4 @@ +- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT + from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin + American region. A feed that publishes speed in km/h declares + `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). From 096383b43266fb6b13d3cc471be4b80f74f7876a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 21:00:13 +0400 Subject: [PATCH 033/262] Translate the fire filter strings into de, fr and ja --- src/i18n/locales/de.json | 15 ++++++++++++++- src/i18n/locales/fr.json | 15 ++++++++++++++- src/i18n/locales/ja.json | 15 ++++++++++++++- src/i18n/same-as-english.json | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..f18b5ca67 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2926,5 +2926,18 @@ "Malformed cyclone snapshot": "Fehlerhafter Wirbelsturm-Snapshot", "STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache", "routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig", - "route proxy error": "Fehler des Routen-Proxys" + "route proxy error": "Fehler des Routen-Proxys", + "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", + "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", + "fire intensity filter|All": "Alle", + "Detection confidence": "Konfidenz der Erkennung", + "{shown} of {total} shown": "{shown} von {total} angezeigt" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 931e7c3ff..59641d143 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2996,5 +2996,18 @@ "ESC to dismiss": "ESC pour fermer", "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" + "Cyclone request timed out": "Délai dépassé pour la requête cyclonique", + "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", + "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)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiance", + "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a48dc886..85757c1dd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2856,5 +2856,18 @@ "routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます", "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", - "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました" + "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", + "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)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "信頼度", + "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "fire intensity filter|All": "すべて", + "Detection confidence": "検知の信頼度", + "{shown} of {total} shown": "{total}件中{shown}件を表示" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index dd0df9990..390b56e58 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -163,6 +163,7 @@ "{value} KM/S", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "es": [ @@ -469,6 +470,7 @@ "ΔE", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "it": [ @@ -688,6 +690,7 @@ "{type} SPD: {speed} HDG: {heading}", "ΔE", "ΔN", + "≥ {value} MW", "▲ {frp} MW" ], "pt": [ From 0c4da3cdb27c0eda490954d8581079809cf3ae32 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:47:32 -0800 Subject: [PATCH 034/262] 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 035/262] 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 036/262] 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 037/262] 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 ad171f2854704b2cee1e50b457a96c0adde71e84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 22:43:51 +0400 Subject: [PATCH 049/262] Draw each far-zoom fire as a marker-sized heat spot The heat raster was 2048 px wide with a 3 px blur, so every fire spread over ~200 km and the far-zoom map read as clouds next to the markers. Double the grid to ~10 km per pixel and blur by one pixel: a lone fire is now a spot about as wide as its marker at the 3,000 km hand-off. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 5 +++-- src/layers/firms/heatImagery.test.mjs | 25 +++++++++++++++++++++++ src/layers/firms/policy.js | 13 +++++++----- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index c1d06643e..4c02ccf20 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dabc074ae..5be2a98dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,9 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred, +burn), Gaussian-blurred by one pixel so each fire stays a spot about the size +of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 0fca7b97f..4b6403298 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHeatImagery } from './heatImagery.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { HEAT_RASTER } from './policy.js'; function harness() { const layers = []; @@ -85,3 +87,26 @@ test('destroy removes the layer', () => { h.imagery.destroy(); assert.equal(h.layers.length, 0); }); + +test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { + // A hot cluster elsewhere sets the scale, as Africa does on real data. + const fires = [{ lat: 0, lon: 0, frp: 5 }]; + for (let i = 0; i < 40; i++) { + fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); + } + const r = buildHeatRaster(fires, HEAT_RASTER); + const y = Math.floor((90 / 180) * r.height); + const painted = []; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted.push(x); + } + } + const kmPerPx = 40075 / r.width; + assert.ok(painted.length > 0, 'the weak fire still shows'); + assert.ok( + painted.length * kmPerPx <= 80, + `spot ${Math.round(painted.length * kmPerPx)} km wide`, + ); +}); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index faa55bbc3..9de42cfa4 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,14 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in - * pixels. Built once per data refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker + * at the 3,000 km hand-off and neighbours merge only when they really touch + * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data + * refresh or filter change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, - sigmaPx: 3, + width: 4096, + height: 2048, + sigmaPx: 1, }); /** From 5ab8ddb4ca3cd848017bc3435ff3784c4bf53c31 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 23:12:38 +0400 Subject: [PATCH 050/262] Double the far-zoom heat spots so they read as spots At 10 km per pixel each fire was a marker-sized spot that faded into the imagery from the globe view. A 2048 px grid with the same one-pixel blur makes every spot exactly twice as wide (~60 km for a lone weak fire), still separate, and the build is three times faster. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 6 +++--- src/layers/firms/heatImagery.test.mjs | 4 ++-- src/layers/firms/policy.js | 15 ++++++++------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 4c02ccf20..6f2d11a33 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one distinct spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5be2a98dc..4d1b69849 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,10 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is +global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a spot about the size -of its marker, +burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about +twice the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 4b6403298..5543a0691 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -88,7 +88,7 @@ test('destroy removes the layer', () => { assert.equal(h.layers.length, 0); }); -test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { +test('the shipped raster paints a lone weak fire as a distinct spot', () => { // A hot cluster elsewhere sets the scale, as Africa does on real data. const fires = [{ lat: 0, lon: 0, frp: 5 }]; for (let i = 0; i < 40; i++) { @@ -106,7 +106,7 @@ test('the shipped raster paints a lone weak fire as a marker-sized spot', () => const kmPerPx = 40075 / r.width; assert.ok(painted.length > 0, 'the weak fire still shows'); assert.ok( - painted.length * kmPerPx <= 80, + painted.length * kmPerPx <= 100, `spot ${Math.round(painted.length * kmPerPx)} km wide`, ); }); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 9de42cfa4..2af5d9f5c 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,16 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker - * at the 3,000 km hand-off and neighbours merge only when they really touch - * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data - * refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide + * as its marker at the 3,000 km hand-off and still reads from the + * whole-globe view; neighbours merge only when they nearly touch (σ 3 here + * smeared each fire over ~200 km). Built once per data refresh or filter + * change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 4096, - height: 2048, + width: 2048, + height: 1024, sigmaPx: 1, }); From 09d833ed960bdd82ac831b6d149c0d466782300e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:17:06 -0800 Subject: [PATCH 051/262] 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 052/262] 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 053/262] 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 054/262] 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 083/262] 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 99520b529c4d1a3a4adc7268ca935d66bcdba923 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:02:54 +0530 Subject: [PATCH 084/262] fix(camera): keep the globe in view from orbit From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; a view that already shows less only keeps or gains what it has. A turn that cannot go half a degree further without crossing that limit is refused with a reason instead of reported as done, and a turn it accepts turns at least that half degree. A turn that keeps or gains surface is not refused, even after the window has been resized, except that near the limit, where the planet's edge crosses the sample grid, sampling wobble can occasionally refuse one (most often above about 15,000 km and on phone or ultrawide frames); nothing then turns away from the planet. A turn that starts where the last voice turn left the camera keeps that turn's limit, or a higher one if the planet now fills more, so repeating the request does not take any more; once anything else has moved or turned the camera, a turn is measured from what is on screen. A request made while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs. --- docs/CURRENT-STATE.md | 40 +++ scripts/package-boundaries.json | 5 + src/app/application.test.mjs | 431 ++++++++++++++++++++++++++- src/app/scene.js | 11 + src/app/viewer.js | 172 +++++++++++ src/cameraGlobeRotation.test.mjs | 491 +++++++++++++++++++++++++++++++ src/cameraVerbs.js | 179 ++++++++++- src/globeInView.js | 167 +++++++++++ 8 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/globeInView.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..3fde50e05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -65,6 +65,46 @@ settling. Oblique views below 300 km are left alone. `QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises the real browser controller at desktop and phone widths with keyless fixtures. +Cesium's free look (Shift+drag, which turns the camera in place) works only +below 1,000 km. Above that height a few drags could turn the whole planet out of +frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +zoom are unchanged at every height. How much of the frame shows the planet's +surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while +the scope mask is on (the default) only the rays inside its keyhole count, since +the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D +camera above 1,000 km stop when about a tenth of the frame still shows the +surface (or about as much as it showed when it started), never less than about +1% of the visible frame, and a view that already shows less only keeps or gains +what it has. A turn that cannot go half a degree further without crossing that +limit is refused with a reason instead of reported as done, and a turn it +accepts turns at least that half degree. A turn that keeps or gains surface is +not refused, even after the window has been resized, except that near the limit, +where the planet's edge crosses the sample grid, sampling wobble can +occasionally refuse one (most often above about 15,000 km and on phone or +ultrawide frames); nothing then turns away from the planet. A turn that starts +where the last voice turn left the camera keeps that turn's limit, or a higher +one if the planet now fills more, so repeating the request does not take any +more; once anything else has moved or turned the camera, a turn is measured from +what is on screen. When a camera move ends above 1,000 km with no ray on the +surface, the camera turns straight down at the same position and heading. The +end of a move is taken from Cesium's move-end event and from a check of its own +that samples the pose every 250 ms after a move starts and acts once it has held +for half a second, because at some of these poses the camera drifts by a +rounding error every frame and Cesium never sees it stop. That check keeps +watching until move-end arrives, recovering once after each move that comes to +rest, and gives up five minutes after the camera last moved. Limb glow, and a +surface sliver up to about one grid cell thick (some 50 px on a 900 px tall +frame), can count as out of view. The zoom-out guard above keeps wheel and +pinch zoom-outs in view; this catches the other ways a move can end looking +away, such as a saved or shared view that did, or a drag +that Cesium turns into an in-place look below its 7,500 km trackball height (one +that starts on the stars, or a tilt drag with the screen centre off the planet). +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras and the 2D and +Columbus modes. A voice turn requested while the camera is tracked is judged +by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44c481152..b029af646 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,7 @@ "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", + "src/globeInView.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", @@ -2297,6 +2298,7 @@ "src/camera.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", @@ -3604,6 +3606,9 @@ "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", + "src/scopeMaskPolicy.js", + "src/scopeMask.js", "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..401eefa0b 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import * as Cesium from 'cesium'; +import { + installFreeLookHeightLimit, + installGlobeInViewRecovery, + installTrackpadPinchZoom, +} from './viewer.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeFrameShare, + globeOutOfFrame, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from '../globeInView.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -343,3 +356,419 @@ test('trackpad pinch falls back to the original event when relay construction fa assert.equal(event.propagationStopped, false); assert.equal(f.relayed.length, 0); }); + +function freeLookFixture({ enableLook = true, height = 0 } = {}) { + const listeners = new Set(); + const controller = { enableLook }; + const camera = { positionCartographic: { height } }; + const viewer = { + camera, + scene: { + screenSpaceCameraController: controller, + preUpdate: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }, + }; + const frame = (nextHeight) => { + camera.positionCartographic = { height: nextHeight }; + for (const listener of listeners) listener(); + }; + return { viewer, controller, listeners, frame }; +} + +test('free look follows camera height and is restored on dispose', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1); + assert.equal(f.controller.enableLook, false); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS); + assert.equal(f.controller.enableLook, true); + f.frame(Number.NaN); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + dispose(); + dispose(); + assert.equal(f.controller.enableLook, true); + assert.equal(f.listeners.size, 0); +}); + +test('free look stays off when the viewer had it disabled', () => { + const f = freeLookFixture({ enableLook: false, height: 500 }); + installFreeLookHeightLimit(f.viewer); + f.frame(10); + assert.equal(f.controller.enableLook, false); +}); + +// A real Cesium camera on the smallest scene its constructor reads, so the +// frustum test runs on Cesium's own view math rather than a stub. +function realCamera({ width = 1600, height = 900, mode } = {}) { + const scene = { + canvas: { clientWidth: width, clientHeight: height }, + drawingBufferWidth: width, + drawingBufferHeight: height, + mapProjection: new Cesium.GeographicProjection(), + mode: mode ?? Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + renders: 0, + requestRender() { + this.renders += 1; + }, + }; + const camera = new Cesium.Camera(scene); + camera.frustum.aspectRatio = width / height; + return { camera, scene, viewer: { camera, scene } }; +} + +function aim( + camera, + { lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 }, +) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt), + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: Cesium.Math.toRadians(roll), + }, + }); +} + +const reportedStuckView = { + alt: 17_996_811, + heading: 180, + pitch: -42, + roll: 180, +}; +// Oblique poses that still show part of the planet: Director recipe shots +// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit. +const partialGlobeViews = [ + { lon: -30, lat: 20, alt: 19_000_000, pitch: -65 }, + { lon: 2, lat: 46, alt: 8_000_000, pitch: -52 }, + { lon: 110, lat: 20, alt: 9_500_000, pitch: -66 }, + { lon: 0, lat: 0, alt: 2_000_000, pitch: -30 }, +]; + +// Stars and a few pixels of limb, no surface: where a frame limit measured +// against a sphere of the planet's largest radius used to stop a voice tilt. +const limbOnlyView = { alt: 18_000_000, pitch: -56.75 }; + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +test('the globe is out of frame only when no surface is on screen', () => { + const cases = [ + [reportedStuckView, 1225, 923, true], + [limbOnlyView, 1600, 900, true], + [{ alt: 17_996_811, pitch: -90 }, 1225, 923, false], + ...partialGlobeViews.map((view) => [view, 1600, 900, false]), + ]; + for (const [view, width, height, out] of cases) { + const { camera } = realCamera({ width, height }); + aim(camera, view); + const label = JSON.stringify(view); + assert.equal(globeOutOfFrame(camera), out, label); + assert.equal(surfacePixelShare(camera, width, height) === 0, out, label); + assert.ok( + Math.abs( + globeFrameShare(camera) - surfacePixelShare(camera, width, height), + ) < 0.03, + `${label}: the frame share matches the pick-ray oracle`, + ); + } +}); + +test('a move that ends with the globe out of frame turns back to nadir', () => { + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(viewer); + aim(camera, reportedStuckView); + const position = Cesium.Cartesian3.clone(camera.positionWC); + const heading = camera.heading; + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6)); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3), + ); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(scene.renders, 1); + + // Looking up near the ground, then zoomed out past the limit. + aim(camera, { alt: 3_834_000, pitch: 10 }); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + + // Only the limb on a 16:9 screen still counts as lost. + const wide = realCamera({ width: 1600, height: 900 }); + const disposeWide = installGlobeInViewRecovery(wide.viewer); + aim(wide.camera, limbOnlyView); + wide.camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6)); + disposeWide(); + + dispose(); + dispose(); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert'); +}); + +// True when a moveEnd leaves the camera's view direction as it was. +function moveEndKeepsView(camera) { + const direction = Cesium.Cartesian3.clone(camera.directionWC); + camera.moveEnd.raiseEvent(); + return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9); +} + +test('globe recovery leaves partial, low, locked and 2D views alone', () => { + for (const view of partialGlobeViews) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer); + aim(camera, view); + assert.ok(moveEndKeepsView(camera), JSON.stringify(view)); + } + + const low = realCamera(); + installGlobeInViewRecovery(low.viewer); + aim(low.camera, { alt: 900_000, pitch: 85 }); + assert.equal(globeOutOfFrame(low.camera), true); + assert.ok(moveEndKeepsView(low.camera), 'below the limit'); + + // Locked on a point in space, between it and the planet, facing away. + const locked = realCamera(); + installGlobeInViewRecovery(locked.viewer); + locked.camera.lookAtTransform( + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)), + new Cesium.Cartesian3(-1_000_000, 0, 0), + ); + assert.equal(globeOutOfFrame(locked.camera), true); + assert.ok(moveEndKeepsView(locked.camera), 'locked camera'); + + // Tracking an entity that has no position yet keeps an identity transform. + const tracked = realCamera(); + tracked.viewer.trackedEntity = { id: 'pending-fix' }; + installGlobeInViewRecovery(tracked.viewer); + aim(tracked.camera, reportedStuckView); + assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera'); + + const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW }); + installGlobeInViewRecovery(flat.viewer); + flat.scene.mode = Cesium.SceneMode.SCENE3D; + aim(flat.camera, reportedStuckView); + flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + assert.ok(moveEndKeepsView(flat.camera), 'Columbus view'); +}); + +test('the look limit from orbit tolerates grid noise and never reaches zero', () => { + const radians = Cesium.Math.toRadians; + // About 2% of a 16:9 frame on the planet, below the 10% limit. + const sliver = { alt: 18_000_000, pitch: -60 }; + const { camera } = realCamera(); + aim(camera, sliver); + const start = surfacePixelShare(camera, 1600, 900); + assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`); + + // Sideways across the sliver: the share wobbles by a cell, the planet stays. + assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false); + const startShare = globeFrameShare(camera); + for (let i = 0; i < 20; i += 1) { + const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), { + startShare, + }); + assert.equal(step, radians(0.25), `sideways step ${i + 1}`); + camera.lookLeft(step); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + + // Toward the planet is always allowed. + aim(camera, sliver); + assert.equal( + lookAngleWithinFrame(camera, 'lookDown', radians(10)), + radians(10), + ); + + // Away from it stops short of losing it. + aim(camera, sliver); + const away = lookAngleWithinFrame(camera, 'lookUp', radians(30)); + assert.ok(away < radians(30)); + camera.lookUp(away); + assert.ok(globeFrameShare(camera) > 0); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('the look limit leaves a view with no surface to the move-end recovery', () => { + const { camera } = realCamera({ width: 1225, height: 923 }); + aim(camera, reportedStuckView); + assert.equal( + lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)), + Cesium.Math.toRadians(5), + ); +}); + +// Surface only in the corners of a 16:9 frame, all of it outside the scope's +// keyhole: the full frame shows some planet, the scope shows none. +const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 }; + +test('with a scope keyhole, surface hidden in the masked corners counts as none', () => { + const { camera } = realCamera(); + aim(camera, cornerOnlyView); + const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS }; + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(globeOutOfFrame(camera, keyhole), true); + aim(camera, { alt: 18_000_000, pitch: -90 }); + assert.ok( + Math.abs( + globeFrameShare(camera, keyhole) - + surfacePixelShare(camera, 1600, 900, { keyhole: true }), + ) < 0.03, + 'the keyhole share matches the pick-ray oracle', + ); +}); + +test('the recovery follows the scope: corner-only views are turned back only behind it', () => { + for (const [scopeOn, expectRecovery] of [ + [true, true], + [false, false], + ]) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer, { + keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0), + }); + aim(camera, cornerOnlyView); + camera.moveEnd.raiseEvent(); + assert.equal( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + expectRecovery, + `scope ${scopeOn ? 'on' : 'off'}`, + ); + } +}); + +function fakeTimers() { + let next = 1; + const pending = new Map(); + return { + setTimeout: (callback) => { + const id = next++; + pending.set(id, callback); + return id; + }, + clearTimeout: (id) => pending.delete(id), + pending: () => pending.size, + /** Run the earliest pending callback, as 250 ms passing would. */ + tick() { + const [id, callback] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + callback(); + return true; + }, + }; +} + +test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => { + // At this pose the camera can drift by a rounding error every frame, so + // Cesium never sees it stop and never raises moveEnd for it. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough'); + // A rounding-error drift between samples still counts as at rest. + camera.lookUp(1e-15); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + // Under that drift Cesium raises no new moveStart, so a later move that + // ends looking away is caught by the same check. + aim(camera, reportedStuckView); + timers.tick(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'not yet at rest'); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + camera.moveEnd.raiseEvent(); + assert.equal(timers.pending(), 0, 'moveEnd ends the check'); +}); + +test('the settle check follows a long move and gives up five minutes after the last one', () => { + // A drag of half a minute that stops at a stuck pose is still recovered. + const long = fakeTimers(); + const dragged = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(dragged.viewer, long); + aim(dragged.camera, reportedStuckView); + dragged.camera.moveStart.raiseEvent(); + for (let i = 0; i < 120; i += 1) { + dragged.camera.moveLeft(0.01); + long.tick(); + } + assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving'); + while (long.tick()); + assert.ok( + Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + + // A camera that keeps moving is followed, and left alone, for as long as it + // moves; once it rests the check gives up five minutes later. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 2000; i += 1) { + camera.moveLeft(0.01); + timers.tick(); + } + assert.equal(timers.pending(), 1, 'still following after eight minutes'); + assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone'); + let idleSamples = 0; + while (timers.tick()) idleSamples += 1; + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + assert.ok( + idleSamples >= 1200 && idleSamples <= 1205, + `gave up ${idleSamples} samples after it stopped`, + ); + + const disposed = fakeTimers(); + const other = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(other.viewer, disposed); + aim(other.camera, reportedStuckView); + other.camera.moveStart.raiseEvent(); + dispose(); + assert.equal(disposed.pending(), 0, 'dispose cancels the pending check'); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..9c115638d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -2,9 +2,13 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer, + installFreeLookHeightLimit, + installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -58,6 +62,13 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer(installFreeLookHeightLimit(viewer)); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..ce182a0ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeOutOfFrame, +} from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -103,6 +107,174 @@ export function installTrackpadPinchZoom( }; } +/** + * Allow Cesium's free look only below `maxHeight` and return its disposer. + * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Shift+drag binding only; a drag that starts on the stars below Cesium's + * trackball height still turns the camera in place, which + * `installGlobeInViewRecovery` catches. + */ +export function installFreeLookHeightLimit( + viewer, + maxHeight = FREE_LOOK_MAX_HEIGHT_METERS, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + if (!controller || !camera || !scene?.preUpdate) + throw new TypeError('A complete Cesium viewer is required'); + + const originalEnableLook = controller.enableLook; + const sync = () => { + const height = camera.positionCartographic?.height; + controller.enableLook = + originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + }; + sync(); + const removeListener = scene.preUpdate.addEventListener(sync); + + let active = true; + return () => { + if (!active) return; + active = false; + removeListener(); + controller.enableLook = originalEnableLook; + }; +} + +// How often a moving camera is sampled to see whether it has come to rest, how +// far a pose may drift between samples and still count as at rest, and how +// long after the camera last moved the check gives up if Cesium never raises +// moveEnd. +const SETTLE_SAMPLE_MS = 250; +const SETTLE_POSITION_TOLERANCE_M = 1e-3; +const SETTLE_DIRECTION_TOLERANCE = 1e-9; +const SETTLE_GIVE_UP_MS = 5 * 60_000; + +/** + * Turn the camera back to the planet when a move ends above `minHeight` with + * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about + * one sample cell thick can count as none), and return the disposer. Views with + * more surface, such as an oblique orbital shot, are left as they are. This + * catches a saved or shared view that looked away, and a drag that Cesium turns + * into an in-place look below its trackball height whatever `enableLook` says. + * Heading and position are kept. Tracked cameras, locked ones (non-identity + * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * returns the radius of the visible circle when a scope mask hides the rest of + * the frame (see `globeFrameShare`), or 0; it is read at every check. + * + * Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the + * last frame's to the last rounding error, and at some of these poses the + * camera drifts by a rounding error every frame, so Cesium never sees it stop. + * So each `moveStart` also starts a check of its own that samples the pose + * every 250 ms, with a tolerance for that drift, and once the pose has held + * for half a second after moving runs the same recovery. It keeps watching + * until Cesium raises `moveEnd`, since under that drift Cesium raises no new + * `moveStart` for a later move, and gives up five minutes after the camera + * last moved. + * `setTimeout` and `clearTimeout` can be injected for tests. + */ +export function installGlobeInViewRecovery( + viewer, + { + minHeight = FREE_LOOK_MAX_HEIGHT_METERS, + keyholeRadius = () => 0, + setTimeout: schedule = globalThis.setTimeout, + clearTimeout: unschedule = globalThis.clearTimeout, + } = {}, +) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera?.moveEnd || !camera?.moveStart) + throw new TypeError('A complete Cesium viewer is required'); + + const recover = () => { + if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; + if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) + return; + const height = camera.positionCartographic?.height; + if ( + !(height > minHeight) || + !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) + ) + return; + camera.setView({ + orientation: { + heading: camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }); + scene.requestRender?.(); + }; + + const lastPosition = new Cesium.Cartesian3(); + const lastDirection = new Cesium.Cartesian3(); + let settleTimer = null; + let settleElapsed = 0; + let restingSamples = 0; + let movedSinceCheck = false; + const stopSettling = () => { + if (settleTimer !== null) unschedule(settleTimer); + settleTimer = null; + }; + const sample = () => { + settleTimer = null; + settleElapsed += SETTLE_SAMPLE_MS; + const atRest = + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + lastPosition, + 0, + SETTLE_POSITION_TOLERANCE_M, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + lastDirection, + 0, + SETTLE_DIRECTION_TOLERANCE, + ); + restingSamples = atRest ? restingSamples + 1 : 0; + if (!atRest) { + movedSinceCheck = true; + settleElapsed = 0; + } + // Two quiet samples in a row after a move: at rest for half a second, as + // for moveEnd. + if (restingSamples >= 2 && movedSinceCheck) { + movedSinceCheck = false; + recover(); + } + if (settleElapsed >= SETTLE_GIVE_UP_MS) return; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const startSettling = () => { + stopSettling(); + settleElapsed = 0; + restingSamples = 0; + movedSinceCheck = true; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + stopSettling(); + recover(); + }); + const removeMoveStart = camera.moveStart.addEventListener(startSettling); + + let active = true; + return () => { + if (!active) return; + active = false; + stopSettling(); + removeMoveEnd(); + removeMoveStart(); + }; +} + /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f15ae4978 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,9 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js'; +import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { setScopeMaskEnabled } from './scopeMask.js'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -1347,3 +1350,491 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer samePose(h.camera, before); assert.equal(hasRenderHold(), false); }); + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. With + * `keyhole`, only the pixels inside the scope's visible circle count. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +/** Run a test with the scope mask on or off, restoring the default after. */ +function withScope(t, enabled) { + setScopeMaskEnabled(enabled); + t.after(() => setScopeMaskEnabled(true)); +} + +// The frame limit is measured on a coarser grid than the oracle. +const SHARE_SLACK = 0.02; +// Far-out poses, height in metres and pitch in degrees, where a few tilts up +// are accepted before the limit refuses the next one. +const FAR_OUT_POSES = [ + [35_884_000, -86.26], + [35_884_000, -89], + [30_000_000, -86], + [25_000_000, -80], + [40_000_000, -88], +]; + +test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => { + withScope(t, false); // the whole frame is visible + // Slightly off nadir: from straight down a tilt is clamped before it moves. + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + // From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down + // to about -67°: the first tilt stops there, and later ones are refused. + assert.deepEqual( + results.map((result) => result.ok), + [true, false, false, false], + ); + assert.match(results[1].error, /globe out of view/); + assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); +}); + +test('voice turn in place from orbit stops with the planet still on screen', (t) => { + withScope(t, false); // the whole frame is visible + const { camera, tick } = harness(t, { pitch: -75 }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + const heading = camera.heading; + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + surfacePixelShare(camera, 1280, 800) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.notEqual(camera.heading, heading, 'the turn moved before stopping'); +}); + +test('a long voice turn below the limit keeps the share it started with', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet: the limit is that share, less two + // grid cells of sampling noise, for the whole motion rather than per step. + const { camera, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('repeated voice turns from one spot cannot each take a little more', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet. Every turn from this position is + // measured against the same baseline, so asking again stops being allowed. + const { camera, finishOnce, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = start - 2 / (32 * 18) - 1e-9; + const results = []; + for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) { + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ motion: 'rotate', direction: 'left', mode }); + results.push(result.ok); + if (!result.ok) continue; + if (mode === 'once') finishOnce(); + else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`); + } + } + assert.ok(results.includes(false), 'a turn at the limit is refused'); + assert.equal(results.at(-1), false); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('a voice turn after something else turned the camera starts over', (t) => { + withScope(t, false); // the whole frame is visible + // The same spot, first framed straight down, then turned to about 2% of the + // frame by something other than voice: a turn left is measured from there. + const { camera, finishOnce } = harness(t, { + pitch: -90, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + camera.setView({ + orientation: { heading: 0, pitch: radians(-60), roll: 0 }, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); +}); + +test('nudging the camera between voice turns cannot slide the planet away', (t) => { + withScope(t, false); // the whole frame is visible + // A wheel notch or an orbit drag between requests starts each turn afresh; + // the 1% floor still holds, and below it a turn may only keep what it has. + for (const [nudge, pitch] of [ + [(camera) => camera.moveBackward(2), -60], + [(camera) => camera.rotateRight(radians(0.2)), -60], + [(camera) => camera.moveBackward(2), -58], + ]) { + const { camera, tick } = harness(t, { + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = Math.min(0.01, start) - 1e-9; + for (let i = 0; i < 12; i += 1) { + nudge(camera); + if ( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok + ) + for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok( + globeFrameShare(camera) >= floor, + `start ${start}, turn ${i + 1}`, + ); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + } +}); + +test('after the frame changes size, a voice turn toward the planet is allowed', (t) => { + withScope(t, false); // the whole frame is visible + // A tilt stops at its limit; then the window widens at the same pose, so the + // frame shows less than the carried limit. Toward the planet must still go. + const { camera, canvas, viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200; + camera.frustum.aspectRatio = 3200 / 900; + const narrowed = globeFrameShare(camera); + assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`); + + const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }); + assert.equal(down.ok, true, down.error); + finishOnce(); + assert.ok(globeFrameShare(camera) > narrowed); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok, + true, + ); + finishOnce(); + const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + if (up.ok) finishOnce(); + assert.ok(surfacePixelShare(camera, 3200, 900) > 0); +}); + +test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => { + withScope(t, true); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result.ok); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + assert.equal(results[0], true); + assert.equal(results.at(-1), false, 'a tilt past the limit is refused'); +}); + +test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => { + // An oblique orbit view: turning in place swings the planet toward a corner, + // where the full frame still shows it but the scope mask hides it. + for (const [height, pitch] of [ + [5_000_000, -70], + [3_000_000, -75], + [18_000_000, -80], + ]) { + withScope(t, true); + const { camera, tick } = harness(t, { + height, + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok, + true, + ); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `${height / 1000} km at ${pitch}°`, + ); + } +}); + +test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => { + // A small planet disc: its sampled share wobbles as it crosses the grid. + // Whatever the limit decides, "done" must mean the camera turned, and some + // of these tilts must be accepted for that to be checked at all. + let accepted = 0; + for (const [height, pitch] of FAR_OUT_POSES) { + withScope(t, true); + const { camera, tick } = harness(t, { + latitude: 45.37, + longitudeDeg: -33.33, + height, + heading: 89.9, + pitch, + viewportWidth: 1280, + viewportHeight: 1024, + }); + for (let i = 0; i < 4; i += 1) { + const before = camera.pitch; + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /zoom in first/); + continue; + } + accepted += 1; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + assert.ok( + camera.pitch - before > radians(0.5) - 1e-9, + `${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`, + ); + } + assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0); + } + assert.ok(accepted > 0, 'no far-out tilt was accepted'); +}); + +test('a voice turn reported as done turns at least the half degree its check proved', (t) => { + // A grazing sliver: the first frame's small step lands in a dip of the + // sampled share, which used to stop the turn before it moved at all. + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera({ + motion: 'rotate', + direction: 'left', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /globe out of view/); + continue; + } + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok( + turned >= radians(0.5) - 1e-9, + `scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`, + ); + assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0); + } +}); + +/** Track an entity below `camera`, locking it into that entity's frame. */ +function trackFrom(viewer, camera) { + viewer.trackedEntity = { id: 'high-satellite' }; + const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000); + camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below)); +} + +test('a voice turn that releases a tracked camera is checked as released', (t) => { + // Voice releases the follow camera inside the navigation that runs the + // motion, so the request is judged by the pose the release will leave. + let accepted = 0; + for (const [scope, direction, motion] of [ + [true, 'up', 'tilt'], + [false, 'up', 'tilt'], + [false, 'left', 'rotate'], + ]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + trackFrom(viewer, camera); + const releaseThenRun = (start) => { + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }; + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera( + { motion, direction, mode: 'once' }, + releaseThenRun, + ); + const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`; + if (!result.ok) { + assert.match(result.error, /globe out of view/, label); + continue; + } + accepted += 1; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0, + `${label}: the planet stays on screen`, + ); + } + assert.ok(accepted > 0, 'no released turn was accepted'); +}); + +test('a refused voice turn leaves a tracked camera tracking', (t) => { + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit untracked, then track from there. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + trackFrom(viewer, camera); + const tracked = viewer.trackedEntity; + const transform = Cesium.Matrix4.clone(camera.transform); + let released = false; + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + released = true; + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }, + ); + const label = `scope ${scope ? 'on' : 'off'}`; + assert.equal(result.ok, false, label); + assert.match(result.error, /globe out of view/, label); + assert.equal(released, false, label); + assert.equal(viewer.trackedEntity, tracked, label); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label); + } +}); + +test('the voice frame limit leaves Columbus view alone', (t) => { + // At its limit in 3D a further tilt up is refused; the same tilt in Columbus + // view is not limited by the frame. + withScope(t, false); + const { viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.match(refused.error || '', /globe out of view/); + viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.doesNotMatch(result.error || '', /globe out of view/); +}); + +test('voice tilt below the free-look height is not limited by the frame', (t) => { + const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 }); + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.equal(result.ok, true); + finishOnce(); + near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt'); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..964b5c1a7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -20,11 +20,18 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; +import { + globeFrameShare, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from './globeInView.js'; import { projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, + KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; +import { isScopeMaskEnabled } from './scopeMask.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } +/** + * A free globe camera: 3D, not tracking, not locked to a frame. The frame + * limit for in-place turns applies to it alone, as the move-end recovery does. + */ +function isFreeGlobeCamera(cam) { + return ( + _viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !_viewer.trackedEntity && + Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ); +} + +// The frame share that in-place voice turns measure their limit against, with +// the pose the last voice turn left the camera in. A turn that starts where +// the last one left off keeps that baseline (raised if the planet now fills +// more of the frame), so repeating a request cannot take a little more each +// time. Once anything else has moved or turned the camera, a turn starts over +// from what is on screen. +let _lookBaseline = null; + +// The pre-check's proof that a half-degree turn from this pose keeps the planet +// in view: which way, from which direction. A motion that starts from that +// pose turns at least that far, so a request reported as done really moves +// even when a smaller first step lands in a dip of the sampled share. +const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); +let _provenLook = null; + +/** The part of the frame a viewer can see: the scope keyhole when it is on. */ +function visibleFrame() { + return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 }; +} + +/** The frame share an in-place turn from `cam`'s pose is limited against. */ +function lookBaselineShare(cam) { + const share = globeFrameShare(cam, visibleFrame()); + const continues = + _lookBaseline && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.position, + cam.positionWC, + 0, + 1, + ) && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.direction, + cam.directionWC, + 0, + 1e-9, + ); + _lookBaseline = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + share: continues ? Math.max(_lookBaseline.share, share) : share, + }; + return _lookBaseline.share; +} + +/** + * One in-place look step of motion `m`. From orbit a free globe camera turns + * only as far as keeps the planet in frame, measured against the baseline the + * turn started from, then the motion ends there. + */ +function lookWithinFrame(cam, m, look, angle) { + let allowed = angle; + if (isFreeGlobeCamera(cam)) { + if (m.globeShareAtStart === undefined) { + m.globeShareAtStart = lookBaselineShare(cam); + m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; + m.turnedRad = 0; + } + _provenLook = null; + allowed = lookAngleWithinFrame(cam, look, angle, { + ...visibleFrame(), + startShare: m.globeShareAtStart, + }); + const owed = Math.min(angle, m.provenTurnRad - m.turnedRad); + if (allowed < owed) allowed = owed; + m.turnedRad += allowed; + } + if (allowed > 0) cam[look](allowed); + // The pose this turn leaves, for a turn that continues from it. + if (_lookBaseline) + Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction); + if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); +} + +/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ +function lookProven(cam, look) { + return ( + _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.direction, + cam.directionWC, + 0, + 1e-9, + ) + ); +} + +/** + * The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft' + * or 'lookRight') must pass from orbit: refused when even half a degree would + * cross the frame limit, otherwise proven for that half degree from this pose. + * With `released`, the turn runs through a navigation that first releases a + * tracked or locked camera and keeps its world pose. The frame measure and the + * turn are the same in any reference frame, so such a camera is judged as it + * is, before the release, and a refusal leaves tracking as it was. Otherwise a + * camera that is not a free globe camera is not checked. Returns the refusal, + * or null. + */ +function checkLookFromOrbit(look, { released = false } = {}) { + _provenLook = null; + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; + const cam = _viewer.camera; + if (!released && !isFreeGlobeCamera(cam)) return null; + if ( + lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { + ...visibleFrame(), + startShare: lookBaselineShare(cam), + }) + ) + return { + ok: false, + action: 'move_camera', + error: + 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', + }; + _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + return null; +} + function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -1226,10 +1364,15 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - if (up) cam.lookUp(stepRad); - else cam.lookDown(stepRad); - } else if (m.direction === 'left') cam.lookLeft(stepRad); - else cam.lookRight(stepRad); + lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + } else { + lookWithinFrame( + cam, + m, + m.direction === 'left' ? 'lookLeft' : 'lookRight', + stepRad, + ); + } } else if (m.kind === 'route') { // The dolly's shaping lives in advanceRouteFlight(); the tick only // applies the pose and honours the same completion path as before. @@ -1413,6 +1556,7 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; + _lookBaseline = null; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); if (_trackingRemover) _trackingRemover(); @@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + const look = + motion === 'tilt' || motion === 'rotate' + ? { + up: 'lookUp', + down: 'lookDown', + left: 'lookLeft', + right: 'lookRight', + }[direction] + : null; + // Honest limits from orbit too: a stopped turn rests within 0.05° of its + // limit, so one that cannot go half a degree further is refused rather than + // run and reported as done, and one that can is then turned at least that + // far. Near the limit the sampled share wobbles as the planet's edge crosses + // the grid, so a turn that would keep the planet can occasionally be refused + // too. + // A camera that is tracked or locked now is judged as the navigation that + // runs the turn will release it, so a refusal keeps the tracking. + const lookRefusal = + look && + checkLookFromOrbit(look, { released: typeof runNavigation === 'function' }); + if (lookRefusal) return lookRefusal; if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && @@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = null) { } const start = () => { interruptCameraMotion('replaced'); + // Checked again only if the release left the camera somewhere other than + // where the check above expected it. + if (look && !lookProven(_viewer.camera, look)) { + const refusal = checkLookFromOrbit(look); + if (refusal) return refusal; + } const state = { kind: motion, direction: direction || 'right', diff --git a/src/globeInView.js b/src/globeInView.js new file mode 100644 index 000000000..1ccbcda23 --- /dev/null +++ b/src/globeInView.js @@ -0,0 +1,167 @@ +import * as Cesium from 'cesium'; + +// Cesium's free look (Shift+drag) turns the camera in place. Near the ground +// that surveys a city; from orbit a few drags turn the whole planet out of +// frame and leave only stars. +export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; + +// A voice turn from orbit stops when about this share of the frame still shows +// the planet's surface. +export const MIN_GLOBE_FRAME_SHARE = 0.1; + +const GRID_COLUMNS = 32; +const GRID_ROWS = 18; +// A turn across a sliver of planet moves its edge through sample points, so +// the share wobbles by a cell or two without the planet leaving. +const GRID_NOISE_CELLS = 2; +// Whatever the baseline, a voice turn never takes the planet below this share +// of the frame; a view that already shows less may only keep what it has. +const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01; +const sampleRay = new Cesium.Ray(); +const sampleOffset = new Cesium.Cartesian3(); + +/** + * Cast a 32×18 grid of view rays, one through the centre of each cell, at the + * WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in + * units of half the frame height) only the cells inside that circle count: the + * scope mask hides the rest. Returns the surface hits and the cells counted, or + * null for a frustum other than a perspective one. + */ +function sampleFrame(camera, keyholeRadius) { + const frustum = camera.frustum; + if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null; + const aspect = frustum.aspectRatio; + const tanY = Math.tan(frustum.fovy / 2); + const tanX = tanY * aspect; + const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0; + Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin); + let hits = 0; + let cells = 0; + for (let row = 0; row < GRID_ROWS; row += 1) { + const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1; + for (let column = 0; column < GRID_COLUMNS; column += 1) { + const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1; + if ( + keyholeSquared && + (columnX * aspect) ** 2 + rowY * rowY > keyholeSquared + ) + continue; + cells += 1; + Cesium.Cartesian3.multiplyByScalar( + camera.rightWC, + columnX * tanX, + sampleOffset, + ); + Cesium.Cartesian3.add( + camera.directionWC, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.multiplyByScalar( + camera.upWC, + rowY * tanY, + sampleOffset, + ); + Cesium.Cartesian3.add( + sampleRay.direction, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction); + if ( + Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84) + ) + hits += 1; + } + } + return { hits, cells }; +} + +/** + * Share of the frame, from 0 to 1, that shows the planet's surface: the + * fraction of a 32×18 grid of view rays, one through the centre of each cell, + * that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is + * the share of the visible circle instead. A frustum other than a perspective + * one counts as fully covered. + */ +export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) { + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return 1; + return sample.hits / sample.cells; +} + +/** True when none of the planet's surface is in `camera`'s visible frame. */ +export function globeOutOfFrame(camera, options) { + return globeFrameShare(camera, options) === 0; +} + +const lookProbes = new WeakMap(); +const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05); + +/** + * How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or + * `lookRight` by a non-negative `angle` in radians) keeps the planet in frame + * from orbit. Above the free-look height the step may not leave less than + * `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than + * `startShare` when that is lower, give or take two grid cells of sampling + * noise. However the baseline was taken, it never leaves less than 1% of the + * frame, and a view that already shows less may only keep or gain what it has. + * `startShare` is the baseline the turn is measured against (the current share + * by default): callers pass one value to every step of a motion, and to every + * turn that continues from where the last one left off, so the limit cannot + * slide down a step or a command at a time. `keyholeRadius` limits the measure + * to the scope's visible circle, as in `globeFrameShare`. + * With nothing on screen the limit is nothing either, so the step is left to + * the move-end recovery. Returns `angle` when the whole step fits, otherwise a + * smaller angle that fits, found by bisection to within 0.05°, so a stopped + * motion rests at the limit. Steps are tried on a copy; `camera` is not moved. + */ +export function lookAngleWithinFrame( + camera, + look, + angle, + { startShare, keyholeRadius = 0 } = {}, +) { + if ( + !(angle > 0) || + !(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS) + ) + return angle; + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return angle; + const share = sample.hits / sample.cells; + const baseline = startShare ?? share; + // Capped at what is on screen now, so a step that keeps or gains surface is + // never refused, even when the frame has shrunk since the baseline was taken + // (a resized window or a turned phone). + const floor = Math.min( + share, + Math.max( + Math.min(MIN_GLOBE_FRAME_SHARE, baseline) - + GRID_NOISE_CELLS / sample.cells, + Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline), + ), + ); + const fits = (step) => { + const probe = Cesium.Camera.clone(camera, lookProbes.get(camera)); + lookProbes.set(camera, probe); + probe[look](step); + // Shares are whole grid cells; the epsilon keeps rounding in `floor` from + // costing a cell. + return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9; + }; + if (fits(angle)) return angle; + let inside = 0; + let outside = angle; + while (outside - inside > LOOK_EDGE_TOLERANCE) { + const middle = (inside + outside) / 2; + if (fits(middle)) inside = middle; + else outside = middle; + } + return inside; +} + +/** True when an in-place look step of `angle` would lose part of its turn. */ +export function lookStepLosesGlobe(camera, look, angle, options) { + return lookAngleWithinFrame(camera, look, angle, options) < angle; +} From d36b28f352389c7f9caff1c46801cd698f93b209 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 01:19:22 +0530 Subject: [PATCH 085/262] docs(changelog): add the globe-in-view fragment --- changelog.d/1532-globe-in-view.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/1532-globe-in-view.md diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md new file mode 100644 index 000000000..f697959b2 --- /dev/null +++ b/changelog.d/1532-globe-in-view.md @@ -0,0 +1,8 @@ +- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned + the camera in place, so a few drags from the whole-globe view left only + stars, and the pose stayed in the share link. Free look now works only below + 1,000 km; a camera move that ends above it with no planet surface in view + (inside the scope keyhole when the scope is on) turns straight down; and + voice tilt and turns from orbit stop while the planet is still on screen and + say so instead of reporting a turn they could not make (Manjunath, + @manjunath22466, #1532). From 1f0a8b5f1640921d0742cd94cdd8520310a90e2f Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:58:09 +0100 Subject: [PATCH 086/262] fix(datacenters): report newly tagged features osmium cannot export A still-tagged feature without exportable geometry already keeps its old line. A newly tagged one had nowhere to go and was dropped silently; it is now listed as skipped with a warning, so a refresh never loses a site or hides a broken one. --- scripts/build-datacenters-norway.py | 10 ++++++++-- src/data/datacentersNorwayRefresh.test.mjs | 3 ++- src/data/local_data/datacenters/README.md | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py index 03286b864..d1203f38d 100644 --- a/scripts/build-datacenters-norway.py +++ b/scripts/build-datacenters-norway.py @@ -182,12 +182,13 @@ def feature_line(key, sequence, fresh): def merge(lines, fresh, rings, tagged): - out, seen, sequence = [], set(), 0 - report = {'kept': 0, 'updated': [], 'removed': [], 'added': [], 'unexported': []} + out, seen, known, sequence = [], set(), set(), 0 + report = {'kept': 0, 'updated': [], 'removed': [], 'added': [], 'unexported': [], 'skipped': []} for line in lines: feature = json.loads(line) sequence = max(sequence, feature['properties']['id']) key = osm_key(feature) + known.add(key) if key in fresh: seen.add(key) old_tags = feature['properties']['tags'] @@ -211,6 +212,8 @@ def merge(lines, fresh, rings, tagged): sequence += 1 out.append(feature_line(key, sequence, fresh[key])) report['added'].append(key) + # newly tagged, but osmium built no usable geometry: nothing to add yet + report['skipped'] = sorted(set(tagged) - set(fresh) - known, key=lambda k: (k[0], int(k[1:]))) if len({osm_key(json.loads(line)) for line in out}) != len(out): raise ValueError('Duplicate OSM identity') return out, report @@ -239,6 +242,9 @@ def main(): if report['unexported']: print('warning: kept the old line of tagged features osmium did not export: %s' % ', '.join(report['unexported']), file=sys.stderr) + if report['skipped']: + print('warning: did not add newly tagged features osmium did not export: %s' + % ', '.join(report['skipped']), file=sys.stderr) payload = '\n'.join(merged) + '\n' if args.verify: if payload != DATASET.read_text(encoding='utf-8'): diff --git a/src/data/datacentersNorwayRefresh.test.mjs b/src/data/datacentersNorwayRefresh.test.mjs index 2b7b7d5bf..88527295b 100644 --- a/src/data/datacentersNorwayRefresh.test.mjs +++ b/src/data/datacentersNorwayRefresh.test.mjs @@ -52,9 +52,10 @@ def line(osm_id, sequence): 'type': 'data_center', 'osm_id': osm_id}}) norway = [([(0, 0), (20, 0), (20, 80), (0, 80)], False)] lines = [line(7, 1), line(8, 2)] -out, report = merge(lines, {}, norway, {'w7'}) +out, report = merge(lines, {}, norway, {'w7', 'w12'}) assert out == [lines[0]], out assert report['unexported'] == ['w7'] and report['removed'] == ['w8'], report +assert report['skipped'] == ['w12'] and report['added'] == [], report `, ); }); diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 5a80df267..86f573c61 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -29,7 +29,8 @@ reflects OSM as of the extract below, everything else the original snapshot. - Contact tags are dropped as above; unchanged features keep their original line, so geometry is only rewritten when tags or rings actually changed. A feature is removed only once it is no longer tagged; one still tagged but - without an exportable geometry keeps its line and is reported as unexported. + without an exportable geometry keeps its line and is reported as unexported, + and a newly tagged one osmium cannot export is reported as skipped. - Refresh: `python3 scripts/build-datacenters-norway.py --cache ` uses Geofabrik's current Norway extract and prints the dated name it used; record that name, its OSM timestamp and MD5 here. Requires `osmium-tool`. From 37e751f1e33c179520cecb43ed64681ca55c5369 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:06:56 -0300 Subject: [PATCH 087/262] fix(fires): keep retained heat hidden when reenabling an empty filter Add a lifecycle regression and register it in the format scope. --- docs/CURRENT-STATE.md | 2 ++ scripts/format-scope.json | 1 + src/layers/firms/heatVisibility.test.mjs | 44 ++++++++++++++++++++++++ src/layers/firms/lifecycle.js | 3 +- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/layers/firms/heatVisibility.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 66dd7068c..c67967183 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2231,6 +2231,8 @@ view), so the weak fires the heatmap shows stay on screen as markers when it fades out; it reads each marker's already-built anchor instead of the ground floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. +Re-enabling Active Fires keeps the retained heat imagery hidden when the current +filtered set is empty, including while a new snapshot is pending. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3df4155f2..606314960 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -408,6 +408,7 @@ "src/layers/firms/cards.js", "src/layers/firms/index.js", "src/layers/firms/ingestion.js", + "src/layers/firms/heatVisibility.test.mjs", "src/layers/firms/lifecycle.js", "src/layers/firms/model.js", "src/layers/firms/policy.js", diff --git a/src/layers/firms/heatVisibility.test.mjs b/src/layers/firms/heatVisibility.test.mjs new file mode 100644 index 000000000..4926db156 --- /dev/null +++ b/src/layers/firms/heatVisibility.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLifecycle } from './lifecycle.js'; + +test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => { + let shown = false; + const noop = () => {}; + const layerState = { + _enabled: false, + _viewer: {}, + _dataSource: {}, + _fires: [], + _loading: true, + _pickIndexById: new Map(), + _heatImagery: { + setShow(v) { + shown = v; + }, + }, + }; + const lifecycle = createLifecycle({ + layerState, + services: { + picking: { registerPickOwner: noop, unregisterPickOwner: noop }, + sprites: { restoreSpriteOrderOnEnable: noop }, + context: { clearSelectedEntityContextForLayer: noop }, + }, + config: { id: 'local-firms', overlayHost: { setVisible: noop } }, + components: { + rendering: { applyHandoffFade: noop }, + viewport: { installLodWatcher: noop, installMoveEndWatcher: noop }, + selection: { installClickHandler: noop }, + }, + }); + await lifecycle.methods.enable(layerState._viewer); + assert.equal( + shown, + false, + 'empty current set must keep the old raster hidden while refresh is pending', + ); + layerState._fires = [{ frp: null, confidence: null }]; + await lifecycle.methods.enable(layerState._viewer); + assert.equal(shown, true, 'nonempty sets still resume their raster'); +}); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 541efca16..90d9cc217 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -54,7 +54,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - layerState._heatImagery?.setShow(true); + // A filter can leave an old raster cached while the current set is empty. + layerState._heatImagery?.setShow(layerState._fires.length > 0); // The camera can have crossed the hand-off while the layer was off. components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); From 19e85941e63fad99439e77739137666f686bff45 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:07:29 -0300 Subject: [PATCH 088/262] docs: add changelog entry for empty-filter heat lifecycle fix --- changelog.d/1535-fires-empty-heat-reenable.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1535-fires-empty-heat-reenable.md diff --git a/changelog.d/1535-fires-empty-heat-reenable.md b/changelog.d/1535-fires-empty-heat-reenable.md new file mode 100644 index 000000000..a862a7b56 --- /dev/null +++ b/changelog.d/1535-fires-empty-heat-reenable.md @@ -0,0 +1,2 @@ +- Keep retained Active Fires heat imagery hidden when an empty filtered layer is + re-enabled while a new snapshot is pending (radianx, #1535). From 44860df90e78d854c0523f9563d32f56d0c86e03 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 13:40:43 +0530 Subject: [PATCH 089/262] Follow a replacement voice flight whatever its predecessor reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait. pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded. --- src/cameraTilt.test.mjs | 23 +++++++++++-- src/cameraVerbs.js | 8 ++++- src/voice/gevActions.js | 14 ++++++-- src/voice/gevActions.test.mjs | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 7d2664c9f..93bbf246e 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -78,6 +78,11 @@ function harness( } const pitchDeg = (camera) => degrees(camera.pitch); +/** Tick until done() holds; a regression fails here instead of hanging. */ +const tickUntil = (h, done, frames = 600) => { + for (let i = 0; i < frames && !done(); i += 1) h.tick(); + assert.ok(done(), `not reached in ${frames} frames`); +}; const headingDeg = (camera) => degrees(Cesium.Math.negativePiToPi(camera.heading)); @@ -171,6 +176,20 @@ test('a requested angle the camera already holds is a no-op', (t) => { assert.equal(getActiveCameraMotion(), null); }); +for (const pitch of [-90, -89]) { + test(`a top-down view asked for ${pitch}° stays put`, (t) => { + // The tool description calls -89 top-down; the clamp must not tilt a + // -90° view up 1° and report it as a move up. + const h = harness(t); + const before = pitchDeg(h.camera); + const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(getActiveCameraMotion(), null); + assert.equal(pitchDeg(h.camera), before); + }); +} + test('tilt down from top-down still refuses honestly', (t) => { harness(t); const result = moveCamera({ motion: 'tilt', direction: 'down' }); @@ -335,7 +354,7 @@ test('the pitchDeg and direction check uses the pitch the swing starts from', (t test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { const h = harness(t, { height: 1000 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -30) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -30); adjustOrbitRange(1 / 2.4); adjustOrbitRange(1 / 2.4); h.tick(); @@ -390,7 +409,7 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => { // the camera about 107 m up, then at the 80 m zoom floor. const h = harness(t, { height: 400 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -40) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -40); assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 16888d73e..380a737f0 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1671,7 +1671,13 @@ export function moveCamera(args = {}, runNavigation = null) { // Judge from the pitch the motion will start at: the pivot's, which at // regional range is a degree or two shallower than the camera's. const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); - if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { + // -89 or -90 means top-down, which a -90° view already is: the clamp + // must not tilt it up 1°. + const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN; + if ( + atTopDown || + Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5) + ) { return { ok: true, action: 'move_camera', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index f7573075e..c9bc811a4 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1302,9 +1302,17 @@ export function createGevActionRunner({ runOptions.signal, deadline, ); - // A later fly-to in the same reply cancels this one: follow it to - // where the camera lands, within the same deadline. - while (status === 'cancelled' && voiceFlight !== flight && current()) { + // A later fly-to in the same reply replaces this one: follow it to + // where the camera lands, within the same deadline. Its status does + // not matter. A geocode that loses navigation reports failed, and a + // flight left flying while the next place is looked up can still + // land; running there would take navigation and cancel the newer one. + while ( + voiceFlight !== flight && + status !== 'aborted' && + status !== 'timeout' && + current() + ) { flight = voiceFlight; status = await waitForVoiceFlight( flight, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index a464c94bd..14d008933 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -570,6 +570,71 @@ test('a motion follows a later fly-to in the same reply that cancels its flight' assert.equal(result.motion, 'orbit'); }); +test('a motion follows a later fly-to whose start cancels its geocode', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B takes navigation authority, so A's lookup comes back cancelled. + searches[0].resolve({ cancelled: true }); + assert.equal((await flyA).cancelled, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must follow B, not drop'); + searches[1].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[1].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a motion does not run at a flight that lands while the next place is looked up', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Deferred navigation leaves A flying while B is looked up, and A lands. + const before = order.length; + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for B'); + assert.equal( + order.slice(before).includes('cancel'), + false, + 'the tilt must not take navigation and cancel B', + ); + searches[0].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From b683b8a64b7d71e33f468469e4b7494f60f6c6ff Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:29:37 +0530 Subject: [PATCH 090/262] Look past voice lookups that found nothing A motion queued behind a voice fly-to acts on the last flight that moved the camera. A later lookup that was not found, threw, or was cancelled once a newer voice fly-to had begun moved nothing, so the motion looks past it rather than refusing or running early and cancelling the flight still in the air. It never acts at a flight older than the one it started waiting on. A lookup cancelled while still the latest voice flight (a search, a share link, Cockpit) still refuses. The top-down no-op now reports the camera's real pitch. --- src/cameraTilt.test.mjs | 1 + src/cameraVerbs.js | 4 +- src/voice/gevActions.js | 108 ++++++++++++++++------- src/voice/gevActions.test.mjs | 156 ++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 32 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 93bbf246e..d372d0746 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -185,6 +185,7 @@ for (const pitch of [-90, -89]) { const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); assert.equal(result.ok, true); assert.equal(result.unchanged, true); + assert.equal(result.pitchDeg, Math.round(before)); assert.equal(getActiveCameraMotion(), null); assert.equal(pitchDeg(h.camera), before); }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 380a737f0..c1e5c5d1a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1682,7 +1682,9 @@ export function moveCamera(args = {}, runNavigation = null) { ok: true, action: 'move_camera', motion, - pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + pitchDeg: Math.round( + Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch), + ), unchanged: true, }; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index c9bc811a4..83cdea367 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -288,9 +288,10 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); - // The latest voice flight. A tilt in the same reply as "fly to X" waits for - // it to land: run at once, its navigation cancelled the flight, or the - // flight (still geocoding) then replaced the tilt. + // The latest voice flight. A tilt, orbit, pan or rotate in the same reply + // as "fly to X" waits for it to land: run at once, its navigation + // cancelled the flight, or the flight (still geocoding) then replaced the + // motion. The wait follows later fly-tos (voiceFlightTarget). let voiceFlight = null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the @@ -1143,10 +1144,11 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos in one reply: a grab during the earlier one counts. - const flight = trackVoiceFlight( - voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined, - ); + // Chained fly-tos: a grab during the earlier one counts. The flight + // replaced is the last one that moved, past lookups that found nothing. + const prior = voiceFlightTarget(voiceFlight); + const replaced = prior && !prior.done ? prior : null; + const flight = trackVoiceFlight(replaced?.inputCount, replaced); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -1177,13 +1179,23 @@ export function createGevActionRunner({ if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); }, }); - // No flight started (not found, refused, cancelled): nothing to wait for. - flown.then( - (result) => { - if (result?.ok !== true) flight.settle('failed'); - }, - () => flight.settle('failed'), - ); + // No flight started: nothing to wait for. A lookup that was not found, + // threw, or was cancelled once a newer voice fly-to had begun (which + // takes navigation from it) moved nothing, so a queued motion looks + // past it to the flight it replaced. One cancelled while still the + // latest voice flight lost navigation to something else (a search, a + // share link, Cockpit): the motion then refuses. A flight its own hooks + // already settled (cancelled mid-air) keeps its status. + const notStarted = () => { + if (!flight.done) flight.movedNothing = true; + flight.settle('failed'); + }; + flown.then((result) => { + if (result?.ok === true) return; + if (result?.cancelled && voiceFlight === flight) + flight.settle('failed'); + else notStarted(); + }, notStarted); return flown; } @@ -1287,39 +1299,45 @@ export function createGevActionRunner({ if (name === 'move_camera') { const motion = String(args.motion || '').toLowerCase(); + // A lookup that already found nothing does not hide the last flight + // that moved: running now would cancel that flight mid-air. + const pending = voiceFlightTarget(voiceFlight); if ( VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && - voiceFlight && - !voiceFlight.done + pending && + !pending.done ) { // Bad arguments are refused now, not after waiting on the flight. const argsError = moveCameraArgsError(args); if (argsError) return argsError; const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; - let flight = voiceFlight; + const start = pending; + let flight = start; let status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); - // A later fly-to in the same reply replaces this one: follow it to - // where the camera lands, within the same deadline. Its status does - // not matter. A geocode that loses navigation reports failed, and a - // flight left flying while the next place is looked up can still - // land; running there would take navigation and cancel the newer one. - while ( - voiceFlight !== flight && - status !== 'aborted' && - status !== 'timeout' && - current() - ) { - flight = voiceFlight; + // A later fly-to replaces this one: follow it to where the camera + // lands, within the same deadline, whatever this flight reported. A + // geocode that loses navigation reports failed, and a flight left + // flying while the next place is looked up can still land; running + // there would take navigation and cancel the newer one. A later + // lookup that found nothing moved nothing, so the motion acts on the + // last flight it saw that did move the camera. + let landed = flight.movedNothing ? null : { flight, status }; + while (status !== 'aborted' && status !== 'timeout' && current()) { + const target = voiceFlightTarget(voiceFlight, start); + if (!target || target === flight) break; + flight = target; status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); + if (!flight.movedNothing) landed = { flight, status }; } + if (flight.movedNothing && landed) ({ flight, status } = landed); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -2939,11 +2957,25 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } -/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ -function trackVoiceFlight(inputCount = manualCameraInputCount()) { +let voiceFlightCount = 0; + +/** + * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. + * `replaced` is the last flight that moved, if still pending when this one + * began (past lookups that found nothing). It is kept only + * while this one may turn out to have moved nothing (a lookup that found no + * place), so a queued motion can look past it. + */ +function trackVoiceFlight( + inputCount = manualCameraInputCount(), + replaced = null, +) { let resolve; const flight = { done: false, + seq: (voiceFlightCount += 1), + replaced, + movedNothing: false, // Manual input after this means the user took the camera mid-flight. inputCount, ended: new Promise((settle) => { @@ -2952,12 +2984,26 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) { settle: (status) => { if (flight.done) return; flight.done = true; + if (!flight.movedNothing) flight.replaced = null; resolve(status); }, }; return flight; } +/** + * The flight a motion queued since `start` acts on: the latest one that + * actually started. A lookup that found nothing moved nothing, so look past + * it to the flight it replaced, but never to one older than `start` (a flight + * from an earlier reply). Null when every later lookup found nothing. With + * no `start`, the latest flight that started, however old. + */ +function voiceFlightTarget(latest, start = null) { + let flight = latest; + while (flight?.movedNothing) flight = flight.replaced; + return flight && (!start || flight.seq >= start.seq) ? flight : null; +} + /** * Longest a motion waits for its flight (geocode plus flight, which is about * 2 s) before giving up. The wait also holds the tool output, and on Gemini diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 14d008933..ece2f92e0 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -635,6 +635,162 @@ test('a motion does not run at a flight that lands while the next place is looke assert.equal(result.motion, 'tilt'); }); +// A later fly-to that finds nothing moves nothing: the motion still runs where +// the earlier flight lands, whether the lookup fails before or after that. +for (const order of ['lands first', 'not found first']) { + test(`a motion runs at its flight when a later fly-to finds nothing (${order})`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const harness = tiltAfterFlightHarness(); + const { viewer, styleManager, flights } = harness; + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (order === 'lands first') flights[0].complete(); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + if (order !== 'lands first') flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + assert.equal(result.motion, 'tilt'); + }); +} + +test('a motion beside a fly-to that finds nothing never acts at an earlier reply\'s flight', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + // Reply 1: "fly to Austin", still flying. + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // Reply 2: "fly to Nowhere and tilt". + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +// Two later lookups that both find nothing, in either order, leave the motion +// acting where the first flight landed. +for (const first of [0, 1]) { + test(`a motion looks past two lookups that found nothing (lookup ${first + 1} fails first)`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyB = runner('fly_to_location', { query: 'Nowhere B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[first].resolve(null); + searches[1 - first].resolve(null); + assert.equal((await flyB).ok, false); + assert.equal((await flyC).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + }); +} + +test('a lookup cancelled by a newer one that finds nothing does not hide the flight that landed', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Place B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + // C's start takes navigation from B's lookup; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyB).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion sent after a fly-to that found nothing still waits for the flight before it', async (t) => { + // Serial dispatch: "fly to Austin, fly to Nowhere, tilt", one call at a time. + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +test('two lookups that found nothing in a row still leave the motion waiting for the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + assert.equal((await runner('fly_to_location', { query: 'Nowhere 2' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 4c9b27dc204d52603942be7803e2432d2e8b1b0e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:48:07 +0530 Subject: [PATCH 091/262] Keep a settled flight in reach of a later failed lookup A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing. --- src/voice/gevActions.js | 22 +++++++------ src/voice/gevActions.test.mjs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 83cdea367..089d95be0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1144,11 +1144,14 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos: a grab during the earlier one counts. The flight - // replaced is the last one that moved, past lookups that found nothing. + // Chained fly-tos: a grab during an earlier one still flying counts. + // The flight replaced is the last one that moved, past lookups that + // found nothing. const prior = voiceFlightTarget(voiceFlight); - const replaced = prior && !prior.done ? prior : null; - const flight = trackVoiceFlight(replaced?.inputCount, replaced); + const flight = trackVoiceFlight( + prior && !prior.done ? prior.inputCount : undefined, + prior, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -2961,10 +2964,10 @@ let voiceFlightCount = 0; /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. - * `replaced` is the last flight that moved, if still pending when this one - * began (past lookups that found nothing). It is kept only - * while this one may turn out to have moved nothing (a lookup that found no - * place), so a queued motion can look past it. + * `replaced` is the last flight that moved when this one began (past lookups + * that found nothing), landed or not. It is kept only while this one may turn + * out to have moved nothing (a lookup that found no place), so a queued + * motion can look past it to that flight and its outcome. */ function trackVoiceFlight( inputCount = manualCameraInputCount(), @@ -2996,7 +2999,8 @@ function trackVoiceFlight( * actually started. A lookup that found nothing moved nothing, so look past * it to the flight it replaced, but never to one older than `start` (a flight * from an earlier reply). Null when every later lookup found nothing. With - * no `start`, the latest flight that started, however old. + * no `start`, the latest flight that started, back through lookups that + * found nothing. */ function voiceFlightTarget(latest, start = null) { let flight = latest; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ece2f92e0..678a13659 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -791,6 +791,65 @@ test('two lookups that found nothing in a row still leave the motion waiting for assert.equal((await moved).ok, true); }); +// A flight that already settled is still what a later failed lookup replaced. +test('a motion acts at a landed flight that a later failed lookup replaced', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].complete(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B's start made A's lookup stale; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion refuses when the flight a later failed lookup replaced was cancelled', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The motion sees Austin land, then follows A's lookup. + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[1].cancel(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 99831ea19b2ff5b9fff9d40e26a11422e277bb99 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 16:34:39 +0400 Subject: [PATCH 092/262] Keep STALE on Contacts while a poll is in flight A poll that returned the same stale snapshot dropped STALE from the Contacts cohort until the next answer, because the in-flight loading flag outranked stale and the source revision ignored the provider name. A source that has answered now reads its status without the loading flag; one that has never answered (AIS first connect) still reads as loading, so it shows no STALE before any snapshot exists. --- src/layers/awareness/queries.js | 1 + .../awareness/staleAfterLoading.test.mjs | 159 ++++++++++++++++++ src/layers/awareness/subject.js | 8 +- src/layers/vessels/ownership.test.mjs | 2 - src/ui/cockpitContext.test.mjs | 2 - 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/layers/awareness/staleAfterLoading.test.mjs diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 593c85ebf..0c5bd78f3 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) { stats.count ?? null, stats.status || null, stats.error || null, + stats.source || null, ]; }) .map((parts) => parts.join(':')) diff --git a/src/layers/awareness/staleAfterLoading.test.mjs b/src/layers/awareness/staleAfterLoading.test.mjs new file mode 100644 index 000000000..7128f4958 --- /dev/null +++ b/src/layers/awareness/staleAfterLoading.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createQueries } from './queries.js'; +import { createSubject } from './subject.js'; +import { createModel } from './model.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { layerFeedState } from '../../data/feedState.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, + _beginAisSessionForTest, + _applyAisFeedSnapshotForTest, + _setAisRuntimeForTest, +} from '../../data/aisLiveVessels.js'; + +test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => { + globalThis.document ??= { body: null }; + const services = { + installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: { getNearby: () => [] }, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + let states; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + const realQueries = createQueries({ state: layerState, services, parts }); + parts.queries = { ...realQueries, collectSourceStates: () => states }; + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + const flights = (lastUpdate) => ({ + available: true, enabled: true, stale: false, + stats: { source: 'OpenSky Network', count: 4, lastUpdate }, + }); + const vessels = (loading) => ({ + available: true, enabled: true, stale: true, + stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading }, + }); + const rest = { + military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } }, + 'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + + // 1. Stale AIS cache, nothing loading. + states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(true); + assert.equal(ais().sourceStatus, 'stale'); + + // 2. AIS re-poll in flight while the flights poll lands (revision changes). + states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest }; + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale'); + + // 3. AIS poll returns the same stale snapshot; only loading flips back. + states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(); + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too'); +}); + +function fakeRuntime() { + return { + now: () => 1000, + setTimeout: () => ({}), + clearTimeout() {}, + }; +} + +test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => { + globalThis.document ??= { body: null }; + _setAisRuntimeForTest(fakeRuntime()); + _setVesselStateForTest({ viewer: {}, records: [] }); + try { + _beginAisSessionForTest(); + // What server/providers/vessels/ais-live.js answers while its socket connects. + _applyAisFeedSnapshotForTest({}, { + rows: [], source: 'AISStream', status: 'connecting', refreshing: true, + }); + const vesselStats = aisLiveVesselsLayer.getStats(); + assert.equal(vesselStats.loading, true); + assert.equal(vesselStats.stale, true); + assert.equal(vesselStats.lastUpdate, null); + assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING'); + + const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 }; + const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 }; + const installStats = { coverage: { kind: 'subject', radiusM: 100000 } }; + const statsById = { + flights: flightsStats, + military: militaryStats, + 'ais-live-vessels': vesselStats, + 'military-installations': installStats, + }; + const services = { + installations: { getNearby: () => [], getStats: () => installStats }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: aisLiveVesselsLayer, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + dataManager: { + isEnabled: () => true, + getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }), + layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])), + }, + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + parts.queries = createQueries({ state: layerState, services, parts }); + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + + const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels']; + assert.equal(vesselsSource.available, false, 'never-answered source is unavailable'); + + subject.refreshSelectedSubject(true); + const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING'); + } finally { + _setVesselStateForTest({ enabled: false }); + _setAisRuntimeForTest(); + } +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index a555d332e..32123d248 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -54,8 +54,14 @@ export function createSubject({ state: layerState, services, parts, source }) { const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); // Whether the provider answered as a fallback or from a stale snapshot. + // A poll in flight does not change that, and the source revision ignores it. + // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { - const feedState = layerFeedState(sourceState.stats); + const feedState = layerFeedState( + sourceState.available + ? { ...sourceState.stats, loading: false } + : sourceState.stats, + ); return feedState === 'fallback' || feedState === 'stale' ? feedState : null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a838a8099..2404434a0 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -411,8 +411,6 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy }); test('vessel stats name the provider the last snapshot reported', async () => { - // HAL-721: Contacts named a fixed AIS provider while this layer showed the - // one its server reported. const { layer } = setup({ label: 'Vessels', async getSnapshot() { diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index a8d6616d5..c2f20abcf 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -167,8 +167,6 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => }); test('each count names the provider its snapshot reports, with its feed state', async (t) => { - // HAL-721: the counters named fixed providers while the layer and the - // Contacts list named the one that answered. const reported = snapshot(); reported.cohorts[0].source = 'adsb.lol'; reported.cohorts[0].sourceStatus = 'fallback'; From e66b99c8ff7bb8a023cd7f394741097d88cfdaa7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:53:23 +0400 Subject: [PATCH 093/262] Fix the typed AI chat translations The quick commands "Reset to globe view" and "Show earthquakes" reached t() through a .map(), so the catalog check never saw them and every language showed them in English. They are now marked with msg() and translated. Review of the 71 strings from #1507 in all eleven languages: - zh-Hant is regenerated from zh-CN with OpenCC s2tw, as its guide says; 40 entries had Taiwan vocabulary instead. - ar "Dock to the right" names the left side, where RTL puts that rail. - ja "What am I looking at?" no longer asks what the assistant sees. - Provider Settings, dock, provider, live and map use each glossary's terms. - de drops the du-form; ja drops spaces between Japanese and Latin text. - Calques, register and the example commands read naturally. --- src/i18n/locales/ar.json | 42 +++++++++--------- src/i18n/locales/de.json | 64 ++++++++++++++------------- src/i18n/locales/es.json | 30 +++++++------ src/i18n/locales/fr.json | 54 ++++++++++++----------- src/i18n/locales/it.json | 58 +++++++++++++------------ src/i18n/locales/ja.json | 52 +++++++++++----------- src/i18n/locales/pt.json | 36 +++++++-------- src/i18n/locales/ru.json | 36 +++++++-------- src/i18n/locales/uk.json | 40 +++++++++-------- src/i18n/locales/zh-CN.json | 16 ++++--- src/i18n/locales/zh-Hant.json | 82 ++++++++++++++++++----------------- src/i18n/same-as-english.json | 1 + src/voice/chatPreferences.js | 10 ++--- 13 files changed, 272 insertions(+), 249 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index deb68ec36..629bcacab 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3307,15 +3307,17 @@ "Search cancelled": "أُلغي البحث", "Sea Marks": "علامات بحرية", "Ski Pistes": "مسارات التزلج", - "Fly to Tokyo": "الانتقال إلى طوكيو", - "Show live flights": "عرض الرحلات المباشرة", + "Fly to Tokyo": "انتقل إلى طوكيو", + "Show live flights": "اعرض تتبّع الرحلات المباشر", + "Reset to globe view": "ارجع إلى عرض الكرة الأرضية", + "Show earthquakes": "اعرض الزلازل", "AI chat": "دردشة الذكاء الاصطناعي", "GEV CHAT": "دردشة GEV", - "Ask about the map. Tell it where to go.": "اسأل عن الخريطة. أخبرها إلى أين تذهب.", + "Ask about the map. Tell it where to go.": "اسأل عن الخريطة، ووجّهها إلى حيث تريد.", "Close chat": "إغلاق الدردشة", "Conversation": "المحادثة", "Explore with a question": "استكشف بسؤال", - "Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف بيانات مباشرة، أو غيّر العرض.", + "Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف البيانات المباشرة، أو غيّر العرض الحالي.", "Attachments": "المرفقات", "Remove attachment": "إزالة المرفق", "Attach a map layer": "إرفاق طبقة خريطة", @@ -3332,36 +3334,36 @@ "Stop reply": "إيقاف الرد", "Send message": "إرسال الرسالة", "Panel position": "موضع اللوحة", - "Move above the dock": "النقل فوق الشريط", - "Dock to the right": "تثبيت إلى اليمين", + "Move above the dock": "النقل فوق الشريط السفلي", + "Dock to the right": "تثبيت على اليسار", "Expand panel": "توسيع اللوحة", "Minimize panel": "تصغير اللوحة", "You can attach up to four items.": "يمكنك إرفاق أربعة عناصر كحد أقصى.", - "Attachments are too large together. Use smaller files.": "المرفقات كبيرة جدًا معًا. استخدم ملفات أصغر.", + "Attachments are too large together. Use smaller files.": "الحجم الإجمالي للمرفقات كبير جدًا. استخدم ملفات أصغر.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "اختر ملف TXT أو Markdown أو CSV أو JSON أو GeoJSON.", "Each file must be 10 KB or smaller.": "يجب ألا يتجاوز حجم كل ملف 10 كيلوبايت.", - "Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا بترميز UTF-8 غير فارغ.", + "Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا غير فارغ بترميز UTF-8.", "Connecting…": "جارٍ الاتصال…", "Thinking…": "جارٍ التفكير…", "Working on the map…": "جارٍ العمل على الخريطة…", "Silent chat · microphone off": "دردشة صامتة · الميكروفون متوقف", - "Reply interrupted. You can try again.": "توقف الرد. يمكنك المحاولة مرة أخرى.", - "Could not connect. Try again.": "تعذّر الاتصال. حاول مرة أخرى.", + "Reply interrupted. You can try again.": "انقطع الرد. يمكنك إعادة المحاولة.", + "Could not connect. Try again.": "تعذّر الاتصال. أعد المحاولة.", "Session ended. Send a message to start a new conversation.": "انتهت الجلسة. أرسل رسالة لبدء محادثة جديدة.", - "Connection timed out. Try again.": "انتهت مهلة الاتصال. حاول مرة أخرى.", + "Connection timed out. Try again.": "انتهت مهلة الاتصال. أعد المحاولة.", "Chat closed.": "أُغلقت الدردشة.", "Connection stopped.": "توقف الاتصال.", "Message and attachments are too large. Remove an attachment or shorten your message.": "الرسالة والمرفقات كبيرة جدًا. أزل مرفقًا أو اختصر رسالتك.", - "Message was not sent. Try again.": "لم تُرسل الرسالة. حاول مرة أخرى.", + "Message was not sent. Try again.": "لم تُرسل الرسالة. أعد المحاولة.", "Reply stopped.": "أُوقف الرد.", - "Suggestions": "اقتراحات", + "Suggestions": "الاقتراحات", "Recent": "الأخيرة", - "Quick commands": "أوامر سريعة", + "Quick commands": "الأوامر السريعة", "Open AI chat": "فتح دردشة الذكاء الاصطناعي", "CHAT": "دردشة", "Back to chat": "العودة إلى الدردشة", "Back to voice": "العودة إلى الصوت", - "Latest voice exchange": "آخر تبادل صوتي", + "Latest voice exchange": "آخر حوار صوتي", "Try saying": "جرّب أن تقول", "What am I looking at?": "ما الذي أنظر إليه؟", "AI Provider": "مزوّد الذكاء الاصطناعي", @@ -3370,13 +3372,13 @@ "Chat replies are silent. No microphone access is needed.": "ردود الدردشة صامتة. لا حاجة إلى الوصول إلى الميكروفون.", "Chat": "الدردشة", "Attach layer": "إرفاق الطبقة", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة الكتابية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة الكتابية غير متاحة بينما يفرض GEV_PREFER_CODEX_OAUTH استخدام ChatGPT OAuth.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة النصية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة النصية غير متاحة ما دام GEV_PREFER_CODEX_OAUTH يفرض استخدام ChatGPT OAuth.", "That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.", "That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.", - "The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.", - "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 rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في «إعدادات المزوّدين».", + "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}", "Up to {upper}": "حتى {upper}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6876199d1..e795801fd 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3021,73 +3021,75 @@ "Ski Pistes": "Skipisten", "Fly to Tokyo": "Nach Tokio fliegen", "Show live flights": "Live-Flüge anzeigen", + "Reset to globe view": "Zur Globusansicht zurückkehren", + "Show earthquakes": "Erdbeben anzeigen", "AI chat": "KI-Chat", "GEV CHAT": "GEV-CHAT", - "Ask about the map. Tell it where to go.": "Frag zur Karte. Sag ihr, wohin es geht.", + "Ask about the map. Tell it where to go.": "Fragen zur Karte stellen und Ziele nennen.", "Close chat": "Chat schließen", - "Conversation": "Unterhaltung", + "Conversation": "Gespräch", "Explore with a question": "Mit einer Frage erkunden", - "Find a place, explore live data, or change your view.": "Finde einen Ort, erkunde Live-Daten oder ändere deine Ansicht.", + "Find a place, explore live data, or change your view.": "Einen Ort finden, Live-Daten erkunden oder die Ansicht ändern.", "Attachments": "Anhänge", "Remove attachment": "Anhang entfernen", "Attach a map layer": "Kartenebene anhängen", "Choose a layer…": "Ebene wählen…", "Attach a file": "Datei anhängen", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit deiner Nachricht an deinen KI-Anbieter gesendet.", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit der Nachricht an den gewählten KI-Anbieter gesendet.", "Latest AI response": "Neueste KI-Antwort", - "Expand conversation": "Unterhaltung erweitern", + "Expand conversation": "Gespräch ausklappen", "Command suggestions": "Befehlsvorschläge", - "Expand chat": "Chat erweitern", + "Expand chat": "Chat ausklappen", "Add attachment": "Anhang hinzufügen", "Message GEV": "Nachricht an GEV", - "Ask GEV anything about the map…": "Frag GEV alles zur Karte…", + "Ask GEV anything about the map…": "GEV etwas zur Karte fragen…", "Stop reply": "Antwort stoppen", "Send message": "Nachricht senden", - "Panel position": "Panelposition", + "Panel position": "Bereichsposition", "Move above the dock": "Über das Dock verschieben", "Dock to the right": "Rechts andocken", - "Expand panel": "Panel erweitern", - "Minimize panel": "Panel minimieren", - "You can attach up to four items.": "Du kannst bis zu vier Elemente anhängen.", - "Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Verwende kleinere Dateien.", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Wähle eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei.", + "Expand panel": "Bereich ausklappen", + "Minimize panel": "Bereich minimieren", + "You can attach up to four items.": "Es können höchstens vier Elemente angehängt werden.", + "Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Kleinere Dateien verwenden.", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei wählen.", "Each file must be 10 KB or smaller.": "Jede Datei darf höchstens 10 KB groß sein.", - "Choose a non-empty UTF-8 text file.": "Wähle eine nicht leere UTF-8-Textdatei.", - "Connecting…": "Verbinde…", + "Choose a non-empty UTF-8 text file.": "Eine nicht leere UTF-8-Textdatei wählen.", + "Connecting…": "Verbindet…", "Thinking…": "Denkt nach…", "Working on the map…": "Arbeitet an der Karte…", - "Silent chat · microphone off": "Stiller Chat · Mikrofon aus", - "Reply interrupted. You can try again.": "Antwort unterbrochen. Du kannst es erneut versuchen.", - "Could not connect. Try again.": "Verbindung fehlgeschlagen. Versuch es erneut.", - "Session ended. Send a message to start a new conversation.": "Sitzung beendet. Sende eine Nachricht, um eine neue Unterhaltung zu beginnen.", - "Connection timed out. Try again.": "Zeitüberschreitung der Verbindung. Versuch es erneut.", + "Silent chat · microphone off": "Chat ohne Ton · Mikrofon aus", + "Reply interrupted. You can try again.": "Antwort unterbrochen. Erneut versuchen.", + "Could not connect. Try again.": "Verbindung fehlgeschlagen. Erneut versuchen.", + "Session ended. Send a message to start a new conversation.": "Sitzung beendet. Für ein neues Gespräch eine Nachricht senden.", + "Connection timed out. Try again.": "Zeitüberschreitung bei der Verbindung. Erneut versuchen.", "Chat closed.": "Chat geschlossen.", "Connection stopped.": "Verbindung beendet.", - "Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Entferne einen Anhang oder kürze deine Nachricht.", - "Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Versuch es erneut.", + "Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Einen Anhang entfernen oder die Nachricht kürzen.", + "Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Erneut versuchen.", "Reply stopped.": "Antwort gestoppt.", "Suggestions": "Vorschläge", - "Recent": "Zuletzt", + "Recent": "Zuletzt verwendet", "Quick commands": "Schnellbefehle", "Open AI chat": "KI-Chat öffnen", "CHAT": "CHAT", "Back to chat": "Zurück zum Chat", "Back to voice": "Zurück zur Sprachsteuerung", - "Latest voice exchange": "Letzter Sprachaustausch", - "Try saying": "Sag zum Beispiel", + "Latest voice exchange": "Letzter Sprachdialog", + "Try saying": "Zum Beispiel", "What am I looking at?": "Was sehe ich gerade?", "AI Provider": "KI-Anbieter", - "The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprache und Chat.", + "The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprachsteuerung und Chat.", "Show quick commands": "Schnellbefehle anzeigen", "Chat replies are silent. No microphone access is needed.": "Chat-Antworten sind stumm. Kein Mikrofonzugriff nötig.", "Chat": "Chat", "Attach layer": "Ebene anhängen", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt deinen OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für Sprache.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt den OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für die Sprachsteuerung.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Der Text-Chat ist nicht verfügbar, solange GEV_PREFER_CODEX_OAUTH ChatGPT OAuth vorschreibt.", "That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.", - "That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Versuche eine einfachere Anfrage.", - "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Prüfe ihn in den Anbieter-Einstellungen.", + "That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Eine einfachere Anfrage versuchen.", + "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.", "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Prüfe Projekt, Region oder Berechtigungen des Schlüssels.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Prüfe die Abrechnung oder wähle in den Einstellungen einen anderen KI-Anbieter." + "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index df11469ab..b49d311b4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3094,11 +3094,13 @@ "Search cancelled": "Búsqueda cancelada", "Sea Marks": "Señales marítimas", "Ski Pistes": "Pistas de esquí", - "Fly to Tokyo": "Volar a Tokio", - "Show live flights": "Mostrar vuelos en vivo", + "Fly to Tokyo": "Vuela a Tokio", + "Show live flights": "Muestra los vuelos en vivo", + "Reset to globe view": "Vuelve a la vista del globo", + "Show earthquakes": "Muestra los terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT DE GEV", - "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.", + "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Dile adónde ir.", "Close chat": "Cerrar chat", "Conversation": "Conversación", "Explore with a question": "Explora con una pregunta", @@ -3109,12 +3111,12 @@ "Choose a layer…": "Elige una capa…", "Attach a file": "Adjuntar un archivo", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · hasta 10 KB en total. Los adjuntos se envían a tu proveedor de IA con el mensaje.", - "Latest AI response": "Última respuesta de IA", + "Latest AI response": "Última respuesta de la IA", "Expand conversation": "Expandir conversación", "Command suggestions": "Sugerencias de comandos", "Expand chat": "Expandir chat", "Add attachment": "Añadir adjunto", - "Message GEV": "Enviar mensaje a GEV", + "Message GEV": "Mensaje para GEV", "Ask GEV anything about the map…": "Pregunta a GEV lo que quieras sobre el mapa…", "Stop reply": "Detener respuesta", "Send message": "Enviar mensaje", @@ -3124,7 +3126,7 @@ "Expand panel": "Expandir panel", "Minimize panel": "Minimizar panel", "You can attach up to four items.": "Puedes adjuntar hasta cuatro elementos.", - "Attachments are too large together. Use smaller files.": "Los adjuntos juntos son demasiado grandes. Usa archivos más pequeños.", + "Attachments are too large together. Use smaller files.": "El tamaño total de los adjuntos es demasiado grande. Usa archivos más pequeños.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Elige un archivo TXT, Markdown, CSV, JSON o GeoJSON.", "Each file must be 10 KB or smaller.": "Cada archivo debe tener como máximo 10 KB.", "Choose a non-empty UTF-8 text file.": "Elige un archivo de texto UTF-8 que no esté vacío.", @@ -3135,7 +3137,7 @@ "Reply interrupted. You can try again.": "Respuesta interrumpida. Puedes intentarlo de nuevo.", "Could not connect. Try again.": "No se pudo conectar. Inténtalo de nuevo.", "Session ended. Send a message to start a new conversation.": "Sesión finalizada. Envía un mensaje para iniciar una nueva conversación.", - "Connection timed out. Try again.": "La conexión agotó el tiempo de espera. Inténtalo de nuevo.", + "Connection timed out. Try again.": "Se agotó el tiempo de conexión. Inténtalo de nuevo.", "Chat closed.": "Chat cerrado.", "Connection stopped.": "Conexión detenida.", "Message and attachments are too large. Remove an attachment or shorten your message.": "El mensaje y los adjuntos son demasiado grandes. Quita un adjunto o acorta el mensaje.", @@ -3147,9 +3149,9 @@ "Open AI chat": "Abrir chat de IA", "CHAT": "CHAT", "Back to chat": "Volver al chat", - "Back to voice": "Volver a voz", + "Back to voice": "Volver a la voz", "Latest voice exchange": "Último intercambio de voz", - "Try saying": "Prueba a decir", + "Try saying": "Intenta decir", "What am I looking at?": "¿Qué estoy viendo?", "AI Provider": "Proveedor de IA", "The selected AI provider is shared by voice and chat.": "El proveedor de IA seleccionado se usa tanto para voz como para chat.", @@ -3157,11 +3159,11 @@ "Chat replies are silent. No microphone access is needed.": "Las respuestas del chat son silenciosas. No se necesita acceso al micrófono.", "Chat": "Chat", "Attach layer": "Adjuntar capa", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de la API de OpenAI. El inicio de sesión de ChatGPT solo cubre la voz.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud necesitó demasiados pasos. Los cambios ya hechos en el mapa se mantienen.", - "That request needed too many steps. Try a simpler request.": "Esa solicitud necesitó demasiados pasos. Prueba una solicitud más sencilla.", - "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes del proveedor.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de API de OpenAI. El inicio de sesión de ChatGPT solo sirve para la voz.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija OAuth de ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud requirió demasiados pasos. Los cambios ya hechos en el mapa se mantienen.", + "That request needed too many steps. Try a simpler request.": "Esa solicitud requirió demasiados pasos. Prueba una solicitud más sencilla.", + "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.", "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes." diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 69d19c9f7..27c0453c4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3096,41 +3096,43 @@ "Ski Pistes": "Pistes de ski", "Fly to Tokyo": "Aller à Tokyo", "Show live flights": "Afficher les vols en direct", + "Reset to globe view": "Revenir à la vue du globe", + "Show earthquakes": "Afficher les séismes", "AI chat": "Discussion IA", "GEV CHAT": "DISCUSSION GEV", "Ask about the map. Tell it where to go.": "Posez une question sur la carte. Dites-lui où aller.", "Close chat": "Fermer la discussion", "Conversation": "Conversation", - "Explore with a question": "Explorer avec une question", + "Explore with a question": "Explorez en posant une question", "Find a place, explore live data, or change your view.": "Trouvez un lieu, explorez des données en direct ou changez de vue.", "Attachments": "Pièces jointes", "Remove attachment": "Retirer la pièce jointe", - "Attach a map layer": "Joindre une couche de carte", + "Attach a map layer": "Joindre une couche de la carte", "Choose a layer…": "Choisir une couche…", "Attach a file": "Joindre un fichier", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d’IA avec votre message.", - "Latest AI response": "Dernière réponse de l’IA", - "Expand conversation": "Agrandir la conversation", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d'IA avec votre message.", + "Latest AI response": "Dernière réponse de l'IA", + "Expand conversation": "Développer la conversation", "Command suggestions": "Suggestions de commandes", - "Expand chat": "Agrandir la discussion", + "Expand chat": "Développer la discussion", "Add attachment": "Ajouter une pièce jointe", "Message GEV": "Message à GEV", - "Ask GEV anything about the map…": "Demandez à GEV ce que vous voulez sur la carte…", + "Ask GEV anything about the map…": "Posez à GEV toutes vos questions sur la carte…", "Stop reply": "Arrêter la réponse", "Send message": "Envoyer le message", "Panel position": "Position du panneau", "Move above the dock": "Placer au-dessus du dock", "Dock to the right": "Ancrer à droite", - "Expand panel": "Agrandir le panneau", + "Expand panel": "Développer le panneau", "Minimize panel": "Réduire le panneau", - "You can attach up to four items.": "Vous pouvez joindre jusqu’à quatre éléments.", - "Attachments are too large together. Use smaller files.": "Les pièces jointes sont trop volumineuses ensemble. Utilisez des fichiers plus petits.", + "You can attach up to four items.": "Vous pouvez joindre jusqu'à quatre éléments.", + "Attachments are too large together. Use smaller files.": "La taille totale des pièces jointes est trop importante. Utilisez des fichiers plus petits.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Choisissez un fichier TXT, Markdown, CSV, JSON ou GeoJSON.", "Each file must be 10 KB or smaller.": "Chaque fichier doit faire 10 Ko ou moins.", "Choose a non-empty UTF-8 text file.": "Choisissez un fichier texte UTF-8 non vide.", "Connecting…": "Connexion…", "Thinking…": "Réflexion…", - "Working on the map…": "Travail sur la carte…", + "Working on the map…": "Mise à jour de la carte…", "Silent chat · microphone off": "Discussion silencieuse · micro coupé", "Reply interrupted. You can try again.": "Réponse interrompue. Vous pouvez réessayer.", "Could not connect. Try again.": "Connexion impossible. Réessayez.", @@ -3139,30 +3141,30 @@ "Chat closed.": "Discussion fermée.", "Connection stopped.": "Connexion arrêtée.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Le message et les pièces jointes sont trop volumineux. Retirez une pièce jointe ou raccourcissez votre message.", - "Message was not sent. Try again.": "Le message n’a pas été envoyé. Réessayez.", + "Message was not sent. Try again.": "Le message n'a pas été envoyé. Réessayez.", "Reply stopped.": "Réponse arrêtée.", "Suggestions": "Suggestions", "Recent": "Récents", "Quick commands": "Commandes rapides", "Open AI chat": "Ouvrir la discussion IA", - "CHAT": "DISCUSSION", + "CHAT": "CHAT", "Back to chat": "Retour à la discussion", - "Back to voice": "Retour à la voix", + "Back to voice": "Retour au mode vocal", "Latest voice exchange": "Dernier échange vocal", "Try saying": "Essayez de dire", - "What am I looking at?": "Qu’est-ce que je regarde ?", - "AI Provider": "Fournisseur d’IA", - "The selected AI provider is shared by voice and chat.": "Le fournisseur d’IA choisi est partagé par la voix et la discussion.", + "What am I looking at?": "Qu'est-ce que je regarde ?", + "AI Provider": "Fournisseur d'IA", + "The selected AI provider is shared by voice and chat.": "Le fournisseur d'IA choisi est commun à la voix et à la discussion.", "Show quick commands": "Afficher les commandes rapides", - "Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au micro n’est nécessaire.", + "Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au microphone n'est nécessaire.", "Chat": "Discussion", "Attach layer": "Joindre la couche", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Le chat écrit utilise votre clé d’API OpenAI. La connexion ChatGPT ne couvre que la voix.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Le chat écrit est indisponible tant que GEV_PREFER_CODEX_OAUTH impose ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d’étapes. Les modifications déjà apportées à la carte sont conservées.", - "That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d’étapes. Essayez une demande plus simple.", - "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé d’API. Vérifiez-la dans les paramètres du fournisseur.", - "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de chat. Réessayez dans {seconds} s.", - "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." + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La discussion écrite utilise votre clé API OpenAI. La connexion ChatGPT ne couvre que la voix.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La discussion écrite est indisponible tant que GEV_PREFER_CODEX_OAUTH impose l'OAuth ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d'étapes. Les modifications déjà apportées à la carte sont conservées.", + "That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d'étapes. Essayez une demande plus simple.", + "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.", + "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", + "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." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f33721cbb..a089ba758 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3095,47 +3095,49 @@ "Sea Marks": "Segnalamenti marittimi", "Ski Pistes": "Piste da sci", "Fly to Tokyo": "Vola a Tokyo", - "Show live flights": "Mostra voli in diretta", + "Show live flights": "Mostra i voli live", + "Reset to globe view": "Ripristina la vista del globo", + "Show earthquakes": "Mostra i terremoti", "AI chat": "Chat IA", "GEV CHAT": "CHAT GEV", - "Ask about the map. Tell it where to go.": "Chiedi della mappa. Dille dove andare.", - "Close chat": "Chiudi chat", + "Ask about the map. Tell it where to go.": "Fai domande sulla mappa. Digli dove andare.", + "Close chat": "Chiudi la chat", "Conversation": "Conversazione", "Explore with a question": "Esplora con una domanda", - "Find a place, explore live data, or change your view.": "Trova un luogo, esplora dati in diretta o cambia vista.", + "Find a place, explore live data, or change your view.": "Trova un luogo, esplora i dati live o cambia vista.", "Attachments": "Allegati", - "Remove attachment": "Rimuovi allegato", + "Remove attachment": "Rimuovi l'allegato", "Attach a map layer": "Allega un livello della mappa", "Choose a layer…": "Scegli un livello…", "Attach a file": "Allega un file", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo fornitore di IA insieme al messaggio.", - "Latest AI response": "Ultima risposta dell’IA", - "Expand conversation": "Espandi conversazione", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo provider IA insieme al messaggio.", + "Latest AI response": "Ultima risposta dell'IA", + "Expand conversation": "Espandi la conversazione", "Command suggestions": "Suggerimenti di comandi", - "Expand chat": "Espandi chat", + "Expand chat": "Espandi la chat", "Add attachment": "Aggiungi allegato", "Message GEV": "Messaggio a GEV", "Ask GEV anything about the map…": "Chiedi a GEV qualsiasi cosa sulla mappa…", - "Stop reply": "Interrompi risposta", - "Send message": "Invia messaggio", + "Stop reply": "Interrompi la risposta", + "Send message": "Invia il messaggio", "Panel position": "Posizione del pannello", "Move above the dock": "Sposta sopra il dock", "Dock to the right": "Aggancia a destra", - "Expand panel": "Espandi pannello", - "Minimize panel": "Riduci pannello", + "Expand panel": "Espandi il pannello", + "Minimize panel": "Riduci il pannello", "You can attach up to four items.": "Puoi allegare fino a quattro elementi.", - "Attachments are too large together. Use smaller files.": "Gli allegati insieme sono troppo grandi. Usa file più piccoli.", + "Attachments are too large together. Use smaller files.": "Nel complesso gli allegati sono troppo grandi. Usa file più piccoli.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Scegli un file TXT, Markdown, CSV, JSON o GeoJSON.", - "Each file must be 10 KB or smaller.": "Ogni file deve essere di 10 KB o meno.", + "Each file must be 10 KB or smaller.": "Ogni file non deve superare i 10 KB.", "Choose a non-empty UTF-8 text file.": "Scegli un file di testo UTF-8 non vuoto.", "Connecting…": "Connessione…", "Thinking…": "Sto pensando…", - "Working on the map…": "Lavoro sulla mappa…", + "Working on the map…": "Sto lavorando sulla mappa…", "Silent chat · microphone off": "Chat silenziosa · microfono spento", "Reply interrupted. You can try again.": "Risposta interrotta. Puoi riprovare.", "Could not connect. Try again.": "Impossibile connettersi. Riprova.", "Session ended. Send a message to start a new conversation.": "Sessione terminata. Invia un messaggio per iniziare una nuova conversazione.", - "Connection timed out. Try again.": "Connessione scaduta. Riprova.", + "Connection timed out. Try again.": "La connessione è andata in timeout. Riprova.", "Chat closed.": "Chat chiusa.", "Connection stopped.": "Connessione interrotta.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Messaggio e allegati sono troppo grandi. Rimuovi un allegato o accorcia il messaggio.", @@ -3144,25 +3146,25 @@ "Suggestions": "Suggerimenti", "Recent": "Recenti", "Quick commands": "Comandi rapidi", - "Open AI chat": "Apri chat IA", + "Open AI chat": "Apri la chat IA", "CHAT": "CHAT", "Back to chat": "Torna alla chat", "Back to voice": "Torna alla voce", "Latest voice exchange": "Ultimo scambio vocale", "Try saying": "Prova a dire", "What am I looking at?": "Cosa sto guardando?", - "AI Provider": "Fornitore di IA", - "The selected AI provider is shared by voice and chat.": "Il fornitore di IA scelto è condiviso da voce e chat.", - "Show quick commands": "Mostra comandi rapidi", - "Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l’accesso al microfono.", + "AI Provider": "Provider IA", + "The selected AI provider is shared by voice and chat.": "Il provider IA selezionato è condiviso tra voce e chat.", + "Show quick commands": "Mostra i comandi rapidi", + "Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l'accesso al microfono.", "Chat": "Chat", "Attach layer": "Allega livello", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L’accesso a ChatGPT copre solo la voce.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L'accesso a ChatGPT vale solo per la voce.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La chat scritta non è disponibile finché GEV_PREFER_CODEX_OAUTH richiede ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Questa richiesta ha richiesto troppi passaggi. Le modifiche già fatte alla mappa restano.", - "That request needed too many steps. Try a simpler request.": "Questa richiesta ha richiesto troppi passaggi. Prova una richiesta più semplice.", - "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala nelle impostazioni del provider.", + "That request needed too many steps. Changes already made to the map stay in place.": "Per questa richiesta sono serviti troppi passaggi. Le modifiche già apportate alla mappa restano.", + "That request needed too many steps. Try a simpler request.": "Per questa richiesta sono serviti troppi passaggi. Prova con una richiesta più semplice.", + "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.", "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l’accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L’account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA nelle impostazioni." + "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e47708830..1536306f1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2944,28 +2944,30 @@ "Search cancelled": "検索をキャンセルしました", "Sea Marks": "航路標識", "Ski Pistes": "スキーゲレンデ", - "Fly to Tokyo": "東京へ移動", - "Show live flights": "ライブのフライトを表示", + "Fly to Tokyo": "東京へ移動して", + "Show live flights": "ライブフライトを表示して", + "Reset to globe view": "地球全体ビューに戻して", + "Show earthquakes": "地震を表示して", "AI chat": "AIチャット", - "GEV CHAT": "GEV チャット", + "GEV CHAT": "GEVチャット", "Ask about the map. Tell it where to go.": "地図について質問できます。行き先を伝えてください。", "Close chat": "チャットを閉じる", "Conversation": "会話", "Explore with a question": "質問から探索", "Find a place, explore live data, or change your view.": "場所を探したり、ライブデータを見たり、表示を変えたりできます。", - "Attachments": "添付ファイル", + "Attachments": "添付", "Remove attachment": "添付を削除", "Attach a map layer": "地図レイヤーを添付", "Choose a layer…": "レイヤーを選択…", "Attach a file": "ファイルを添付", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計 10 KB まで。添付はメッセージとともに AI プロバイダーへ送信されます。", - "Latest AI response": "最新の AI の応答", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計10 KBまで。添付はメッセージとともにAIプロバイダーへ送信されます。", + "Latest AI response": "AIの最新の応答", "Expand conversation": "会話を展開", "Command suggestions": "コマンドの候補", "Expand chat": "チャットを展開", "Add attachment": "添付を追加", - "Message GEV": "GEV へのメッセージ", - "Ask GEV anything about the map…": "地図について GEV に何でも質問…", + "Message GEV": "GEVへのメッセージ", + "Ask GEV anything about the map…": "地図についてGEVに何でも質問…", "Stop reply": "応答を停止", "Send message": "メッセージを送信", "Panel position": "パネルの位置", @@ -2973,15 +2975,15 @@ "Dock to the right": "右側にドッキング", "Expand panel": "パネルを展開", "Minimize panel": "パネルを最小化", - "You can attach up to four items.": "添付できるのは 4 件までです。", - "Attachments are too large together. Use smaller files.": "添付ファイルの合計が大きすぎます。小さいファイルを使ってください。", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSON のファイルを選んでください。", - "Each file must be 10 KB or smaller.": "各ファイルは 10 KB 以下にしてください。", - "Choose a non-empty UTF-8 text file.": "空でない UTF-8 のテキストファイルを選んでください。", + "You can attach up to four items.": "添付できるのは4件までです。", + "Attachments are too large together. Use smaller files.": "添付の合計サイズが大きすぎます。小さいファイルを使ってください。", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSONのいずれかのファイルを選んでください。", + "Each file must be 10 KB or smaller.": "各ファイルは10 KB以下にしてください。", + "Choose a non-empty UTF-8 text file.": "空でないUTF-8のテキストファイルを選んでください。", "Connecting…": "接続中…", "Thinking…": "考え中…", "Working on the map…": "地図を操作中…", - "Silent chat · microphone off": "サイレントチャット · マイクオフ", + "Silent chat · microphone off": "テキストチャット · マイク オフ", "Reply interrupted. You can try again.": "応答が中断されました。もう一度お試しください。", "Could not connect. Try again.": "接続できませんでした。もう一度お試しください。", "Session ended. Send a message to start a new conversation.": "セッションが終了しました。メッセージを送ると新しい会話を始められます。", @@ -2992,7 +2994,7 @@ "Message was not sent. Try again.": "メッセージを送信できませんでした。もう一度お試しください。", "Reply stopped.": "応答を停止しました。", "Suggestions": "候補", - "Recent": "最近", + "Recent": "履歴", "Quick commands": "クイックコマンド", "Open AI chat": "AIチャットを開く", "CHAT": "チャット", @@ -3000,19 +3002,19 @@ "Back to voice": "音声に戻る", "Latest voice exchange": "直近の音声のやり取り", "Try saying": "話しかけてみましょう", - "What am I looking at?": "何を見ていますか?", - "AI Provider": "AI プロバイダー", - "The selected AI provider is shared by voice and chat.": "選んだ AI プロバイダーは音声とチャットで共通です。", + "What am I looking at?": "いま見えているのは何?", + "AI Provider": "AIプロバイダー", + "The selected AI provider is shared by voice and chat.": "選んだAIプロバイダーは音声とチャットで共通です。", "Show quick commands": "クイックコマンドを表示", - "Chat replies are silent. No microphone access is needed.": "チャットの応答は音声なしです。マイクへのアクセスは不要です。", + "Chat replies are silent. No microphone access is needed.": "チャットの応答は読み上げられません。マイクへのアクセスは不要です。", "Chat": "チャット", "Attach layer": "レイヤーを添付", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットは OpenAI API キーを使用します。ChatGPT のサインインは音声のみが対象です。", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH が ChatGPT OAuth を必須にしている間、テキストチャットは使えません。", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットはOpenAI APIキーを使用します。ChatGPTのサインインは音声のみが対象です。", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTHがChatGPT OAuthを必須にしている間、テキストチャットは使えません。", "That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。", "That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。", - "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーが API キーを拒否しました。プロバイダー設定で確認してください。", - "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.": "プロバイダーのアカウントのクレジットが不足しています。請求を確認するか、設定で別の AI プロバイダーを選んでください。" + "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。", + "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.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 23c44f786..aa57b24a4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3094,15 +3094,17 @@ "Search cancelled": "Busca cancelada", "Sea Marks": "Sinais marítimos", "Ski Pistes": "Pistas de esqui", - "Fly to Tokyo": "Voar para Tóquio", - "Show live flights": "Mostrar voos ao vivo", + "Fly to Tokyo": "Voe para Tóquio", + "Show live flights": "Mostre os voos ao vivo", + "Reset to globe view": "Volte à vista do globo", + "Show earthquakes": "Mostre os terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT GEV", "Ask about the map. Tell it where to go.": "Pergunte sobre o mapa. Diga para onde ir.", "Close chat": "Fechar chat", "Conversation": "Conversa", "Explore with a question": "Explore com uma pergunta", - "Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude sua visão.", + "Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude a vista do mapa.", "Attachments": "Anexos", "Remove attachment": "Remover anexo", "Attach a map layer": "Anexar uma camada do mapa", @@ -3119,8 +3121,8 @@ "Stop reply": "Parar resposta", "Send message": "Enviar mensagem", "Panel position": "Posição do painel", - "Move above the dock": "Mover para cima do dock", - "Dock to the right": "Encaixar à direita", + "Move above the dock": "Posicionar acima do dock", + "Dock to the right": "Acoplar à direita", "Expand panel": "Expandir painel", "Minimize panel": "Minimizar painel", "You can attach up to four items.": "Você pode anexar até quatro itens.", @@ -3132,14 +3134,14 @@ "Thinking…": "Pensando…", "Working on the map…": "Trabalhando no mapa…", "Silent chat · microphone off": "Chat silencioso · microfone desligado", - "Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar de novo.", - "Could not connect. Try again.": "Não foi possível conectar. Tente de novo.", + "Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar novamente.", + "Could not connect. Try again.": "Não foi possível conectar. Tente novamente.", "Session ended. Send a message to start a new conversation.": "Sessão encerrada. Envie uma mensagem para iniciar uma nova conversa.", - "Connection timed out. Try again.": "A conexão expirou. Tente de novo.", + "Connection timed out. Try again.": "A conexão expirou. Tente novamente.", "Chat closed.": "Chat fechado.", "Connection stopped.": "Conexão interrompida.", "Message and attachments are too large. Remove an attachment or shorten your message.": "A mensagem e os anexos são grandes demais. Remova um anexo ou encurte sua mensagem.", - "Message was not sent. Try again.": "A mensagem não foi enviada. Tente de novo.", + "Message was not sent. Try again.": "A mensagem não foi enviada. Tente novamente.", "Reply stopped.": "Resposta interrompida.", "Suggestions": "Sugestões", "Recent": "Recentes", @@ -3148,21 +3150,21 @@ "CHAT": "CHAT", "Back to chat": "Voltar ao chat", "Back to voice": "Voltar à voz", - "Latest voice exchange": "Última troca de voz", + "Latest voice exchange": "Última interação por voz", "Try saying": "Experimente dizer", "What am I looking at?": "O que estou vendo?", "AI Provider": "Provedor de IA", - "The selected AI provider is shared by voice and chat.": "O provedor de IA escolhido é compartilhado por voz e chat.", + "The selected AI provider is shared by voice and chat.": "O provedor de IA selecionado é usado tanto pela voz quanto pelo chat.", "Show quick commands": "Mostrar comandos rápidos", "Chat replies are silent. No microphone access is needed.": "As respostas do chat são silenciosas. Não é preciso acesso ao microfone.", "Chat": "Chat", "Attach layer": "Anexar camada", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave da API da OpenAI. O login do ChatGPT cobre apenas a voz.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa continuam.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave de API da OpenAI. O login do ChatGPT vale apenas para a voz.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o OAuth do ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa são mantidas.", "That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.", - "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a nas configurações do provedor.", - "Too many chat requests. Try again in {seconds} s.": "Pedidos de chat demais. Tente de novo em {seconds} s.", + "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".", + "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA nas configurações." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b364d3bf1..35f14ec0d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3169,14 +3169,16 @@ "Search cancelled": "Поиск отменён", "Sea Marks": "Морские знаки", "Ski Pistes": "Горнолыжные трассы", - "Fly to Tokyo": "Перелететь в Токио", - "Show live flights": "Показать рейсы в реальном времени", + "Fly to Tokyo": "Перелети в Токио", + "Show live flights": "Покажи рейсы в реальном времени", + "Reset to globe view": "Вернись к виду всего глобуса", + "Show earthquakes": "Покажи землетрясения", "AI chat": "Чат с ИИ", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Укажите, куда перейти.", + "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Скажите, куда лететь.", "Close chat": "Закрыть чат", "Conversation": "Разговор", - "Explore with a question": "Начните исследование с вопроса", + "Explore with a question": "Начните с вопроса", "Find a place, explore live data, or change your view.": "Найдите место, изучите данные в реальном времени или измените вид.", "Attachments": "Вложения", "Remove attachment": "Удалить вложение", @@ -3185,7 +3187,7 @@ "Attach a file": "Прикрепить файл", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON или GeoJSON · до 10 КБ в сумме. Вложения отправляются провайдеру ИИ вместе с сообщением.", "Latest AI response": "Последний ответ ИИ", - "Expand conversation": "Развернуть разговор", + "Expand conversation": "Развернуть чат", "Command suggestions": "Подсказки команд", "Expand chat": "Развернуть чат", "Add attachment": "Добавить вложение", @@ -3194,7 +3196,7 @@ "Stop reply": "Остановить ответ", "Send message": "Отправить сообщение", "Panel position": "Положение панели", - "Move above the dock": "Переместить над панелью", + "Move above the dock": "Переместить над доком", "Dock to the right": "Закрепить справа", "Expand panel": "Развернуть панель", "Minimize panel": "Свернуть панель", @@ -3204,15 +3206,15 @@ "Each file must be 10 KB or smaller.": "Размер каждого файла не должен превышать 10 КБ.", "Choose a non-empty UTF-8 text file.": "Выберите непустой текстовый файл UTF-8.", "Connecting…": "Подключение…", - "Thinking…": "Обдумывание…", - "Working on the map…": "Работа с картой…", + "Thinking…": "Думаю…", + "Working on the map…": "Работаю с картой…", "Silent chat · microphone off": "Текстовый чат · микрофон выключен", "Reply interrupted. You can try again.": "Ответ прерван. Попробуйте ещё раз.", "Could not connect. Try again.": "Не удалось подключиться. Попробуйте ещё раз.", - "Session ended. Send a message to start a new conversation.": "Сеанс завершён. Отправьте сообщение, чтобы начать новый разговор.", + "Session ended. Send a message to start a new conversation.": "Сессия завершена. Отправьте сообщение, чтобы начать новый разговор.", "Connection timed out. Try again.": "Время ожидания подключения истекло. Попробуйте ещё раз.", "Chat closed.": "Чат закрыт.", - "Connection stopped.": "Соединение остановлено.", + "Connection stopped.": "Соединение прервано.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Сообщение и вложения слишком велики. Удалите вложение или сократите сообщение.", "Message was not sent. Try again.": "Сообщение не отправлено. Попробуйте ещё раз.", "Reply stopped.": "Ответ остановлен.", @@ -3221,9 +3223,9 @@ "Quick commands": "Быстрые команды", "Open AI chat": "Открыть чат с ИИ", "CHAT": "ЧАТ", - "Back to chat": "Вернуться в чат", + "Back to chat": "Назад в чат", "Back to voice": "Назад к голосу", - "Latest voice exchange": "Последний голосовой обмен", + "Latest voice exchange": "Последние реплики голосового диалога", "Try saying": "Попробуйте сказать", "What am I looking at?": "Что я вижу?", "AI Provider": "Провайдер ИИ", @@ -3234,10 +3236,10 @@ "Attach layer": "Прикрепить слой", "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовый чат использует ваш ключ API OpenAI. Вход в ChatGPT действует только для голоса.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовый чат недоступен, пока GEV_PREFER_CODEX_OAUTH требует ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Уже внесённые изменения карты сохранятся.", + "That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Изменения, уже внесённые на карту, остаются.", "That request needed too many steps. Try a simpler request.": "Этот запрос потребовал слишком много шагов. Попробуйте запрос попроще.", - "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в настройках провайдера.", - "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.": "На счёте провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." + "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».", + "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.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d01a6af13..9e76044eb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3169,18 +3169,20 @@ "Search cancelled": "Пошук скасовано", "Sea Marks": "Морські знаки", "Ski Pistes": "Гірськолижні траси", - "Fly to Tokyo": "Летіти до Токіо", - "Show live flights": "Показати рейси наживо", + "Fly to Tokyo": "Полети до Токіо", + "Show live flights": "Покажи рейси онлайн", + "Reset to globe view": "Поверни вигляд глобуса", + "Show earthquakes": "Покажи землетруси", "AI chat": "ШІ-чат", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Питайте про мапу. Скажіть, куди перейти.", + "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди летіти.", "Close chat": "Закрити чат", "Conversation": "Розмова", - "Explore with a question": "Досліджуйте за допомогою запитання", - "Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані наживо або змініть вигляд.", + "Explore with a question": "Почніть із запитання", + "Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані в реальному часі або змініть вигляд.", "Attachments": "Вкладення", - "Remove attachment": "Вилучити вкладення", - "Attach a map layer": "Прикріпити шар мапи", + "Remove attachment": "Видалити вкладення", + "Attach a map layer": "Прикріпити шар карти", "Choose a layer…": "Виберіть шар…", "Attach a file": "Прикріпити файл", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON або GeoJSON · до 10 КБ загалом. Вкладення надсилаються вашому постачальнику ШІ разом із повідомленням.", @@ -3190,30 +3192,30 @@ "Expand chat": "Розгорнути чат", "Add attachment": "Додати вкладення", "Message GEV": "Повідомлення для GEV", - "Ask GEV anything about the map…": "Запитайте GEV будь-що про мапу…", + "Ask GEV anything about the map…": "Запитайте GEV будь-що про карту…", "Stop reply": "Зупинити відповідь", "Send message": "Надіслати повідомлення", - "Panel position": "Положення панелі", + "Panel position": "Розташування панелі", "Move above the dock": "Перемістити над доком", "Dock to the right": "Закріпити праворуч", "Expand panel": "Розгорнути панель", "Minimize panel": "Згорнути панель", "You can attach up to four items.": "Можна прикріпити до чотирьох елементів.", - "Attachments are too large together. Use smaller files.": "Вкладення разом завеликі. Використайте менші файли.", + "Attachments are too large together. Use smaller files.": "Загальний розмір вкладень завеликий. Використайте менші файли.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Виберіть файл TXT, Markdown, CSV, JSON або GeoJSON.", "Each file must be 10 KB or smaller.": "Кожен файл має бути не більшим за 10 КБ.", "Choose a non-empty UTF-8 text file.": "Виберіть непорожній текстовий файл UTF-8.", "Connecting…": "Підключення…", "Thinking…": "Думаю…", - "Working on the map…": "Працюю з мапою…", - "Silent chat · microphone off": "Тихий чат · мікрофон вимкнено", + "Working on the map…": "Працюю з картою…", + "Silent chat · microphone off": "Беззвучний чат · мікрофон вимкнено", "Reply interrupted. You can try again.": "Відповідь перервано. Можна спробувати ще раз.", "Could not connect. Try again.": "Не вдалося підключитися. Спробуйте ще раз.", "Session ended. Send a message to start a new conversation.": "Сеанс завершено. Надішліть повідомлення, щоб почати нову розмову.", - "Connection timed out. Try again.": "Час підключення вичерпано. Спробуйте ще раз.", + "Connection timed out. Try again.": "Минув час очікування підключення. Спробуйте ще раз.", "Chat closed.": "Чат закрито.", "Connection stopped.": "Підключення зупинено.", - "Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Вилучіть вкладення або скоротіть повідомлення.", + "Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Видаліть вкладення або скоротіть повідомлення.", "Message was not sent. Try again.": "Повідомлення не надіслано. Спробуйте ще раз.", "Reply stopped.": "Відповідь зупинено.", "Suggestions": "Підказки", @@ -3223,7 +3225,7 @@ "CHAT": "ЧАТ", "Back to chat": "Назад до чату", "Back to voice": "Назад до голосу", - "Latest voice exchange": "Останній голосовий обмін", + "Latest voice exchange": "Остання голосова репліка й відповідь", "Try saying": "Спробуйте сказати", "What am I looking at?": "Що я бачу?", "AI Provider": "Постачальник ШІ", @@ -3234,10 +3236,10 @@ "Attach layer": "Прикріпити шар", "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовий чат використовує ваш ключ API OpenAI. Вхід у ChatGPT діє лише для голосу.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовий чат недоступний, поки GEV_PREFER_CODEX_OAUTH вимагає ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на мапі залишаються.", + "That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на карті залишаються.", "That request needed too many steps. Try a simpler request.": "Цей запит потребував забагато кроків. Спробуйте простіший запит.", - "The provider rejected the API key. Check it in Provider Settings.": "Провайдер відхилив ключ API. Перевірте його в налаштуваннях провайдера.", + "The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».", "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.": "На рахунку провайдера закінчилися кошти. Перевірте оплату або виберіть іншого провайдера ШІ в налаштуваннях." + "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.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 07b1083ab..f583c201a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2946,6 +2946,8 @@ "Ski Pistes": "滑雪道", "Fly to Tokyo": "飞往东京", "Show live flights": "显示实时航班", + "Reset to globe view": "重置为地球视图", + "Show earthquakes": "显示地震", "AI chat": "AI 聊天", "GEV CHAT": "GEV 聊天", "Ask about the map. Tell it where to go.": "询问地图信息,或指定前往的位置。", @@ -2958,7 +2960,7 @@ "Attach a map layer": "附加地图图层", "Choose a layer…": "选择图层…", "Attach a file": "附加文件", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给您的 AI 服务商。", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给你的 AI 服务商。", "Latest AI response": "最新 AI 回复", "Expand conversation": "展开对话", "Command suggestions": "命令建议", @@ -2969,18 +2971,18 @@ "Stop reply": "停止回复", "Send message": "发送消息", "Panel position": "面板位置", - "Move above the dock": "移到底栏上方", + "Move above the dock": "移到停靠栏上方", "Dock to the right": "停靠到右侧", "Expand panel": "展开面板", "Minimize panel": "最小化面板", "You can attach up to four items.": "最多可添加四个附件。", - "Attachments are too large together. Use smaller files.": "附件总大小过大。请使用较小的文件。", + "Attachments are too large together. Use smaller files.": "附件总大小超出上限。请使用较小的文件。", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "请选择 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。", "Each file must be 10 KB or smaller.": "每个文件不得超过 10 KB。", "Choose a non-empty UTF-8 text file.": "请选择非空的 UTF-8 文本文件。", "Connecting…": "正在连接…", "Thinking…": "正在思考…", - "Working on the map…": "正在处理地图…", + "Working on the map…": "正在操作地图…", "Silent chat · microphone off": "静默聊天 · 麦克风已关闭", "Reply interrupted. You can try again.": "回复已中断。请重试。", "Could not connect. Try again.": "无法连接。请重试。", @@ -3011,8 +3013,8 @@ "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 时,文字聊天不可用。", "That request needed too many steps. Changes already made to the map stay in place.": "此请求所需步骤过多。已对地图做出的更改会保留。", "That request needed too many steps. Try a simpler request.": "此请求所需步骤过多。请尝试更简单的请求。", - "The provider rejected the API key. Check it in Provider Settings.": "提供商拒绝了 API 密钥。请在提供商设置中检查。", + "The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。", "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.": "提供商账户余额不足。请检查账单,或在设置中选择其他 AI 提供商。" + "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.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c13b7d39c..77045451d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2945,74 +2945,76 @@ "Sea Marks": "航標", "Ski Pistes": "滑雪道", "Fly to Tokyo": "飛往東京", - "Show live flights": "顯示即時航班", + "Show live flights": "顯示實時航班", + "Reset to globe view": "重置為地球視圖", + "Show earthquakes": "顯示地震", "AI chat": "AI 聊天", "GEV CHAT": "GEV 聊天", - "Ask about the map. Tell it where to go.": "詢問地圖相關問題,告訴它要去哪裡。", + "Ask about the map. Tell it where to go.": "詢問地圖信息,或指定前往的位置。", "Close chat": "關閉聊天", "Conversation": "對話", - "Explore with a question": "用一個問題開始探索", - "Find a place, explore live data, or change your view.": "尋找地點、探索即時資料,或變更檢視。", + "Explore with a question": "從提問開始探索", + "Find a place, explore live data, or change your view.": "查找地點、探索實時數據或更改視圖。", "Attachments": "附件", "Remove attachment": "移除附件", "Attach a map layer": "附加地圖圖層", "Choose a layer…": "選擇圖層…", - "Attach a file": "附加檔案", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨訊息傳送給你的 AI 供應商。", + "Attach a file": "附加文件", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨消息發送給你的 AI 服務商。", "Latest AI response": "最新 AI 回覆", "Expand conversation": "展開對話", - "Command suggestions": "指令建議", + "Command suggestions": "命令建議", "Expand chat": "展開聊天", - "Add attachment": "新增附件", - "Message GEV": "傳訊息給 GEV", - "Ask GEV anything about the map…": "問 GEV 任何關於地圖的問題…", + "Add attachment": "添加附件", + "Message GEV": "向 GEV 發送消息", + "Ask GEV anything about the map…": "向 GEV 詢問地圖相關問題…", "Stop reply": "停止回覆", - "Send message": "傳送訊息", + "Send message": "發送消息", "Panel position": "面板位置", - "Move above the dock": "移到底欄上方", + "Move above the dock": "移到停靠欄上方", "Dock to the right": "停靠到右側", "Expand panel": "展開面板", "Minimize panel": "最小化面板", - "You can attach up to four items.": "最多可附加四個項目。", - "Attachments are too large together. Use smaller files.": "附件合計太大,請使用較小的檔案。", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 檔案。", - "Each file must be 10 KB or smaller.": "每個檔案不得超過 10 KB。", - "Choose a non-empty UTF-8 text file.": "請選擇非空白的 UTF-8 文字檔。", - "Connecting…": "連線中…", - "Thinking…": "思考中…", + "You can attach up to four items.": "最多可添加四個附件。", + "Attachments are too large together. Use smaller files.": "附件總大小超出上限。請使用較小的文件。", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。", + "Each file must be 10 KB or smaller.": "每個文件不得超過 10 KB。", + "Choose a non-empty UTF-8 text file.": "請選擇非空的 UTF-8 文本文件。", + "Connecting…": "正在連接…", + "Thinking…": "正在思考…", "Working on the map…": "正在操作地圖…", - "Silent chat · microphone off": "靜音聊天 · 麥克風關閉", - "Reply interrupted. You can try again.": "回覆已中斷,你可以再試一次。", - "Could not connect. Try again.": "無法連線,請再試一次。", - "Session ended. Send a message to start a new conversation.": "工作階段已結束。傳送訊息即可開始新的對話。", - "Connection timed out. Try again.": "連線逾時,請再試一次。", + "Silent chat · microphone off": "靜默聊天 · 麥克風已關閉", + "Reply interrupted. You can try again.": "回覆已中斷。請重試。", + "Could not connect. Try again.": "無法連接。請重試。", + "Session ended. Send a message to start a new conversation.": "會話已結束。發送消息以開始新對話。", + "Connection timed out. Try again.": "連接超時。請重試。", "Chat closed.": "聊天已關閉。", - "Connection stopped.": "連線已停止。", - "Message and attachments are too large. Remove an attachment or shorten your message.": "訊息與附件太大。請移除一個附件或縮短訊息。", - "Message was not sent. Try again.": "訊息未送出,請再試一次。", - "Reply stopped.": "已停止回覆。", + "Connection stopped.": "連接已停止。", + "Message and attachments are too large. Remove an attachment or shorten your message.": "消息和附件過大。請移除附件或縮短消息。", + "Message was not sent. Try again.": "消息未發送。請重試。", + "Reply stopped.": "回覆已停止。", "Suggestions": "建議", "Recent": "最近", - "Quick commands": "快速指令", - "Open AI chat": "開啟 AI 聊天", + "Quick commands": "快捷命令", + "Open AI chat": "打開 AI 聊天", "CHAT": "聊天", "Back to chat": "返回聊天", "Back to voice": "返回語音", "Latest voice exchange": "最近一次語音對話", "Try saying": "試著說", "What am I looking at?": "我在看什麼?", - "AI Provider": "AI 供應商", - "The selected AI provider is shared by voice and chat.": "所選的 AI 供應商由語音與聊天共用。", - "Show quick commands": "顯示快速指令", - "Chat replies are silent. No microphone access is needed.": "聊天回覆不會發出聲音,不需要麥克風權限。", + "AI Provider": "AI 服務商", + "The selected AI provider is shared by voice and chat.": "語音和聊天共用所選的 AI 服務商。", + "Show quick commands": "顯示快捷命令", + "Chat replies are silent. No microphone access is needed.": "聊天回覆不播放聲音,無需訪問麥克風。", "Chat": "聊天", "Attach layer": "附加圖層", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 金鑰。ChatGPT 登入僅適用於語音。", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天無法使用。", - "That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的變更會保留。", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 密鑰。ChatGPT 登錄僅適用於語音。", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天不可用。", + "That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的更改會保留。", "That request needed too many steps. Try a simpler request.": "此請求所需步驟過多。請嘗試更簡單的請求。", - "The provider rejected the API key. Check it in Provider Settings.": "供應商拒絕了 API 金鑰。請在供應商設定中檢查。", + "The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。", "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.": "供應商帳戶餘額不足。請檢查帳單,或在設定中選擇其他 AI 供應商。" + "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.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0d1022668..51cd4edec 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -361,6 +361,7 @@ "CCTV", "CCTV OFF", "CCTV ON", + "CHAT", "COCKPIT", "COMMERCIAL", "CONTACT", diff --git a/src/voice/chatPreferences.js b/src/voice/chatPreferences.js index e9f1aca05..b094624bb 100644 --- a/src/voice/chatPreferences.js +++ b/src/voice/chatPreferences.js @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; /** * Chat suggestions and the one chat preference (quick commands), written to @@ -17,10 +17,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { }; const quickCommands = () => [ - 'Fly to Tokyo', - 'Show live flights', - 'Reset to globe view', - 'Show earthquakes', + msg('Fly to Tokyo'), + msg('Show live flights'), + msg('Reset to globe view'), + msg('Show earthquakes'), ].map((text) => t(text)); try { const stored = JSON.parse(localStorage.getItem(key)); From 353ca04b61b7f674207d5173d9e00834c734701d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:53:47 +0400 Subject: [PATCH 094/262] Add the changelog entry for #1545 --- changelog.d/1545-typed-chat-translations.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1545-typed-chat-translations.md diff --git a/changelog.d/1545-typed-chat-translations.md b/changelog.d/1545-typed-chat-translations.md new file mode 100644 index 000000000..dd95167b4 --- /dev/null +++ b/changelog.d/1545-typed-chat-translations.md @@ -0,0 +1,5 @@ +- Finish the typed AI chat translations. The "Reset to globe view" and "Show + earthquakes" quick commands are now translated, Traditional Chinese follows + Simplified Chinese again, Arabic docks the panel to the side it actually + lands on, and the new strings use each language's glossary terms + (German, @gekh, #1545). From 20a7016987bddfff57904ad942b16f6c3b087e05 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:56:57 +0400 Subject: [PATCH 095/262] Keep "Tell it where to go" as a direction, not a flight, in ru, uk and es --- src/i18n/locales/es.json | 2 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/uk.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b49d311b4..0dfc4dea3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3100,7 +3100,7 @@ "Show earthquakes": "Muestra los terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT DE GEV", - "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Dile adónde ir.", + "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.", "Close chat": "Cerrar chat", "Conversation": "Conversación", "Explore with a question": "Explora con una pregunta", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 35f14ec0d..df4e575d3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3175,7 +3175,7 @@ "Show earthquakes": "Покажи землетрясения", "AI chat": "Чат с ИИ", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Скажите, куда лететь.", + "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Укажите, куда перейти.", "Close chat": "Закрыть чат", "Conversation": "Разговор", "Explore with a question": "Начните с вопроса", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 9e76044eb..70d4accf9 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3175,7 +3175,7 @@ "Show earthquakes": "Покажи землетруси", "AI chat": "ШІ-чат", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди летіти.", + "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди перейти.", "Close chat": "Закрити чат", "Conversation": "Розмова", "Explore with a question": "Почніть із запитання", From 53ea76251f46fd2b6f0c08914a30bbbb74a1f423 Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:34:30 +0530 Subject: [PATCH 096/262] fix(voice): keep chat's view across a provider switch and clear stale errors Switching AI Provider in Settings opened from chat rebuilt the voice control, so Settings fell back to voice and the dock went idle, widening the wings. The switch now carries chat's Settings, or chat itself, to the new provider as it already carries the input mode; the conversation still starts fresh and focus stays on the provider choice. After a voice error, opening chat or closing the voice panel left the dock red on ERROR, because both stopped only an active session. They now also end a session left in error, which returns the dock to idle. --- docs/CURRENT-STATE.md | 7 ++++-- src/voice/chatControl.js | 34 ++++++++++++++++++++++++++++- src/voice/chatControlView.test.mjs | 31 ++++++++++++++++++++++++++ src/voice/panel/host.test.mjs | 21 ++++++++++++++++++ src/voice/providerCommands.js | 7 ++++-- src/voice/providerCommands.test.mjs | 15 +++++++++++++ src/voice/sessionCommands.js | 11 +++++++++- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c3e15ea4d..fa2287567 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -766,9 +766,12 @@ each side, and the empty Voice Preview suggests things to say until voice starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while -Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice +Preview is off, with Back to chat; voice Settings show Back to voice. Switching +AI Provider keeps that view: chat's Settings, or chat itself, stay as they were +for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the -voice panel's close turns voice off. Asking the assistant from chat to open +voice panel's close turns voice off. Opening chat or closing the voice panel +also clears a voice error left on the dock. Asking the assistant from chat to open Voice Settings or Voice Preview puts chat away, minimized or not, and shows the voice panel; the typed conversation and its running reply carry on, and the chat button brings them back. diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index 6ab5bf494..d366181a7 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -94,7 +94,10 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { : ''; function open({ focus = true } = {}) { leaveChatSettings(); - if (!isTyped() && session.isActive()) session.stop(); + // Chat replaces voice, a stale voice error included, so the dock stops + // showing it. + if (!isTyped() && (session.isActive() || session.state === 'error')) + session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -413,10 +416,39 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); + // What a provider switch carries to the next mount: chat's Settings or + // chat itself, never the conversation. + function presentation() { + if (inChatSettings()) { + const { frameWidth, frameHeight } = ui.state.voicePanel; + return { view: 'settings', frameWidth, frameHeight }; + } + if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; + return null; + } + // Restored without moving focus: the switch keeps it on the provider choice. + function restorePresentation(state) { + if (disposed || !state) return; + if (state.view === 'settings') { + ui.update('voicePanel', { + context: 'chat', + ...(state.frameWidth && { + frameWidth: state.frameWidth, + frameHeight: state.frameHeight, + }), + }); + ui.update('chat', { backVisible: true }); + return sync(); + } + open({ focus: false }); + if (state.view === 'minimized') minimize({ focus: false }); + } return { close, minimize, restore: open, + presentation, + restorePresentation, get minimized() { return chat().open && chat().minimized; }, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index 5dee1ad97..e329a2dc3 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -364,3 +364,34 @@ test('the chat gear is named for the settings it opens', async (t) => { 'Settings', ); }); + +test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { + const mount = () => { + const ui = createTestVoiceUi({ panel: true }); + const chat = bindChatControl({ ui, session: typedSession() }); + t.after(() => chat.destroy()); + return { ui, chat }; + }; + // Chat's Settings: the next mount shows Settings in chat's frame again. + const before = mount(); + before.ui.emit('chat-button'); + before.ui.update('voicePanel', { hidden: false }); + before.ui.emit('settings-button'); + const settings = before.chat.presentation(); + assert.equal(settings.view, 'settings'); + const after = mount(); + after.chat.restorePresentation(settings); + assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.chat.backVisible, true); + assert.equal(after.ui.state.root.assistantMode, 'chat'); + // Open and minimized chat come back as they were, without taking focus. + for (const view of ['open', 'minimized']) { + const next = mount(); + next.chat.restorePresentation({ view }); + assert.equal(next.ui.state.chat.open, true); + assert.equal(next.ui.state.chat.minimized, view === 'minimized'); + assert.equal(next.chat.presentation().view, view); + } + // Idle chat carries nothing. + assert.equal(mount().chat.presentation(), null); +}); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 3c3b3332a..ae5cd1994 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1298,6 +1298,27 @@ test('undocking while a tray is open brings the panel back as its bar', async (t assert.equal($('#gev-chat-panel').dataset.placement, 'compact'); }); +test('opening chat or closing the voice panel clears a stale voice error', async (t) => { + for (const leave of ['chat', 'close']) { + const f = mounted(t, { view: true }); + await f.commands.session.start(); + f.commands.voicePanel.select('preview'); + f.instances[0].hooks.emit({ + type: 'state', + state: 'error', + detail: 'Lost', + }); + await f.settle(); + assert.equal($('#gev-voice-control').dataset.status, 'error'); + assert.equal(f.commands.session.isActive(), false); + if (leave === 'chat') $('#gev-chat-button').click(); + else $('#gev-voice-settings-close').click(); + await f.settle(); + assert.equal(f.commands.session.state, 'idle', leave); + assert.notEqual($('#gev-voice-control').dataset.status, 'error', leave); + } +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 66201141e..756215c7b 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); + // The panel stays where the user is: chat's Settings or chat itself. + const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; current.session.destroy(); @@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { } catch { /* Storage is optional. */ } - mount(); + mount(presentation); if (resume && !disposed && provider === next) void current.session.start({ pushToTalk, @@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { return typeof value === 'function' ? value.bind(current) : value; }, }); - function mount() { + function mount(presentation = null) { voiceEnabled = false; const generation = ++mountGeneration; current = bind({ @@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { handleSessionEvent(event); }, createSession: factories[provider], + presentation, }); window.__gevVoiceCommands = facade; } diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 41d092e73..dfcf77052 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -856,3 +856,18 @@ for (const action of ['stop', 'destroy']) { }); } } + +test('a provider switch hands chat Settings or chat to the new mount', async (t) => { + const f = compositionFixture(t); + const first = f.facade.provider; + const next = first === 'openai' ? 'gemini' : 'openai'; + // Without chat open there is nothing to carry. + assert.equal(f.mounts[0].controls.chatPresentation(), null); + const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + f.mounts[0].controls.chatPresentation = () => shown; + f.select(next); + await settle(); + assert.equal(f.mounts.length, 2); + assert.deepEqual(f.mounts[1].options.presentation, shown); + assert.equal(f.facade.provider, next); +}); diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 78a1816b3..5a8a7e90c 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -40,6 +40,8 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, + // Chat's Settings or chat itself, carried across a provider switch. + presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); const ui = createControl(panelHost ? { panel: true } : {}); @@ -225,6 +227,8 @@ export function createVoiceCommands({ onOpen: () => panelMount?.panel.close(), }) : null; + chat?.restorePresentation(presentation); + controls.chatPresentation = () => chat?.presentation() ?? null; function minimizeVoicePanel() { const panel = ui.element('voicePanel'); const holdsFocus = panel?.contains?.(globalThis.document?.activeElement); @@ -284,7 +288,12 @@ export function createVoiceCommands({ // The panel's close turns voice off, as chat's close ends the chat. A typed // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { - if (!session.isActive() || adapter.getStartOptions?.().textOnly) return; + // A voice error the user closes is over too; stopping returns to idle. + if ( + (!session.isActive() && session.state !== 'error') || + adapter.getStartOptions?.().textOnly + ) + return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); From 46dde909ccc403585d0dc4958430ab2822f790ce Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:37:47 +0530 Subject: [PATCH 097/262] docs(changelog): add #1546 fragment --- changelog.d/1546-chat-provider-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1546-chat-provider-switch.md diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md new file mode 100644 index 000000000..1c3210330 --- /dev/null +++ b/changelog.d/1546-chat-provider-switch.md @@ -0,0 +1,5 @@ +- Keep typed chat's view when switching AI Provider: Settings opened from + chat, or chat itself, stay as they were for the new provider instead of + falling back to voice and widening the dock. Opening chat or closing the + voice panel now also clears a voice error left on the dock (Manjunath, + @manjunath22466, #1546). From 55e6c0017c79215220421c6a727742ec8005839d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:15:16 +0400 Subject: [PATCH 098/262] Tighten the provider-label comments and docs --- changelog.d/1521-provider-labels.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/subject.js | 3 +-- src/layers/vessels/queries.js | 2 +- src/ui/cockpitContext.js | 1 - 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md index d921d9d7b..887d6a775 100644 --- a/changelog.d/1521-provider-labels.md +++ b/changelog.d/1521-provider-labels.md @@ -1,4 +1,4 @@ -- Name the provider that answered in the Cockpit Contact counts and the - Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when - it is. The counts no longer read OpenSky while flights come from the adsb.lol - fallback, and vessels no longer always read AISStream (German, @gekh, #1521). +- Cockpit Contact counts and the Contacts list name the provider that + answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from + the adsb.lol fallback no longer read OpenSky, and vessels no longer always + read AISStream (German, @gekh, #1521). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e845b1472..c6abbf74a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5112,7 +5112,7 @@ silently demoting every later lookup for the session. - 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. -- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. +- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 32123d248..60f622e29 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -53,8 +53,7 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); - // Whether the provider answered as a fallback or from a stale snapshot. - // A poll in flight does not change that, and the source revision ignores it. + // A poll in flight keeps FALLBACK or STALE: the revision key ignores loading. // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { const feedState = layerFeedState( diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 48ded012b..d4d9ef796 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,7 +548,7 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), - // The provider the last snapshot named, as flights and military report it. + // Same field as flights and military, so Contacts names the provider. source: layer.source, }; }, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index ad73869db..6dc209977 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -111,7 +111,6 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; - // The provider that answered, named as the Layers panel names it. shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; From f79e9328d6c628b5bc002f6ba7af7b868ffaf55c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:17:26 +0400 Subject: [PATCH 099/262] docs(cctv): tighten the Bosch MIC record notes --- DATA_SOURCES.md | 12 ++++++------ docs/CURRENT-STATE.md | 8 ++++---- src/layers/cctv/modelSpecs.js | 14 ++++++-------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6f8907812..cac0b5ab7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -256,12 +256,12 @@ The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bos records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under -**CC0-1.0**. Only the records for hardware named by the integrated public catalogs (King -County, Sarasota County and Sioux Falls), plus the City of Amsterdam register proposed -in [#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251), are bundled, compiled into the module. Nothing is -fetched at runtime. The MIC specification fields are unchanged; product titles are shortened -to model codes and colour aliases are added locally. The records describe camera products (optics, resolution, night vision) -only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County, +Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in +[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the +module. Nothing is fetched at runtime. The MIC records keep their specification fields; their +titles are shortened to model codes and colour aliases are added. The records describe camera +products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. ### Bundled OpenStreetMap data diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c386a85db..e868dfc95 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5160,10 +5160,10 @@ silently demoting every later lookup for the session. multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). - The table also includes Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam - register proposed in #251, including its `x` colour aliases (#1319). Their axis-less - datasheet FOV does not supply a horizontal capability range or replace the estimated - FOV. Adding these records does not enable the proposed source pack. + The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam + register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names + no axis, so it neither gives a capability range nor replaces the estimate. The records + alone do not enable that source pack. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW). - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 58734aa14..adc35f10b 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -27,14 +27,12 @@ */ /** - * Records from the dataset's model-enrichment extract (v2.26.0; the Bosch MIC - * records from v2.29.0). MIC specification fields are unchanged; product - * titles are shortened to model codes and colour aliases are added locally. - * Bosch MIC order codes end in a colour letter (B black, G grey, W white) - * on one shared datasheet; those siblings, - * and the `x` wildcard registries write for the colour letter, are listed as - * aliases of the one vendored record. Its `model` leaves the colour out, so - * the HUD never names a variant the source did not. + * Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch + * MIC records from v2.29.0 with titles shortened to model codes. + * A Bosch MIC order code ends in a colour letter (B, G, W; registries write + * `x`) on one shared datasheet. The colours are aliases of one record whose + * `model` leaves the letter out, so the HUD never names a variant the source + * did not. */ const RECORDS = [ { 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 100/262] 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 101/262] 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 82f17e694e9fe2f1b49d0ca322572fcbfa39d51d Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:03:52 +0100 Subject: [PATCH 102/262] refactor(cockpit): place the camera from a per-subject eye profile Split the Cockpit camera update into the aircraft's own front half and a shared placeCamera that reads the eye's forward and up offsets, pitch and ground clearance from a profile. The aircraft profile carries the existing constants, so the aircraft view is unchanged; other subjects can now supply their own eye. --- src/ui/cockpitCamera.js | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index c33ba3c15..0a2d1f372 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -19,6 +19,14 @@ import { COCKPIT_GROUND_WAIT_TIMEOUT_MS, } from './cockpitPresentation.js'; +/** The aircraft eye: just ahead of and above the reported position. */ +const AIRCRAFT_CAMERA_PROFILE = Object.freeze({ + forwardM: COCKPIT_FORWARD_OFFSET_M, + upM: COCKPIT_UP_OFFSET_M, + pitchDeg: COCKPIT_VIEW_PITCH_DEG, + minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M, +}); + export function update() { if (this.destroyed) return false; if (!this.active) { @@ -75,7 +83,20 @@ export function update() { COCKPIT_HEADING_SLEW_DPS * dtSec, ); } + placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE); +} +/** + * 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 {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. + */ +function placeCamera(target, info, nowMs, dtSec, profile) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; @@ -89,7 +110,7 @@ export function update() { // 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(COCKPIT_VIEW_PITCH_DEG); + const pitchRad = Cesium.Math.toRadians(profile.pitchDeg); const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; @@ -248,7 +269,7 @@ export function update() { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, + profile.minClearanceM - profile.upM, ); if (minimumAnchorHeightM !== anchorCartographic.height) { anchorCartographic.height = minimumAnchorHeightM; @@ -297,7 +318,7 @@ export function update() { Cesium.Cartesian3.multiplyByScalar( this.scratchHorizontal, - COCKPIT_FORWARD_OFFSET_M, + profile.forwardM, this.scratchCamera, ); Cesium.Cartesian3.add( @@ -309,7 +330,7 @@ export function update() { Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); Cesium.Cartesian3.multiplyByScalar( this.scratchTarget, - COCKPIT_UP_OFFSET_M, + profile.upM, this.scratchTarget, ); Cesium.Cartesian3.add( @@ -338,7 +359,7 @@ export function update() { const safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, + profile.minClearanceM, ); if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; From 3577d195133fc28f7c8bbc1ada3155188a85f3fa Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:17:08 +0530 Subject: [PATCH 103/262] Cover the real provider switch and share chat Settings and stop checks - Host test switches AI Provider from chat Settings through the real provider commands and checks the new mount keeps chat's context, frame, Back to chat and focus on the provider choice. - Drop the open and minimized branches of the carried presentation: the provider choice lives only in Settings, so chat is never open then. - One showChatSettings helper for the gear and the restore, and one isStoppable helper for the live-or-error check in chat and the panel close. --- changelog.d/1546-chat-provider-switch.md | 4 +- docs/CURRENT-STATE.md | 4 +- src/voice/chatControl.js | 56 ++++++++++-------------- src/voice/chatControlView.test.mjs | 23 +++++----- src/voice/panel/host.test.mjs | 50 +++++++++++++++++++++ src/voice/providerCommands.js | 2 +- src/voice/providerCommands.test.mjs | 4 +- src/voice/session.js | 4 ++ src/voice/sessionCommands.js | 10 ++--- 9 files changed, 99 insertions(+), 58 deletions(-) diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md index 1c3210330..69cdbc939 100644 --- a/changelog.d/1546-chat-provider-switch.md +++ b/changelog.d/1546-chat-provider-switch.md @@ -1,5 +1,5 @@ - Keep typed chat's view when switching AI Provider: Settings opened from - chat, or chat itself, stay as they were for the new provider instead of - falling back to voice and widening the dock. Opening chat or closing the + chat stay chat's Settings for the new provider instead of falling back to + voice and widening the dock. Opening chat or closing the voice panel now also clears a voice error left on the dock (Manjunath, @manjunath22466, #1546). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa2287567..c01693e3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -767,8 +767,8 @@ starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while Preview is off, with Back to chat; voice Settings show Back to voice. Switching -AI Provider keeps that view: chat's Settings, or chat itself, stay as they were -for the new provider. Escape steps back from either Settings view, closes Voice +AI Provider keeps that view: Settings opened from chat stay chat's Settings, +in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the voice panel's close turns voice off. Opening chat or closing the voice panel also clears a voice error left on the dock. Asking the assistant from chat to open diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index d366181a7..0525b9bc5 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -1,6 +1,7 @@ import { bindChatAttachments } from './chatAttachments.js'; import { bindChatPreferences } from './chatPreferences.js'; import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js'; +import { isStoppable } from './session.js'; import { t } from '../i18n/index.js'; /** @@ -96,8 +97,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { leaveChatSettings(); // Chat replaces voice, a stale voice error included, so the dock stops // showing it. - if (!isTyped() && (session.isActive() || session.state === 'error')) - session.stop(); + if (!isTyped() && isStoppable(session)) session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -392,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { } const frame = ui.element('chatPanel')?.getBoundingClientRect?.(); close(); - ui.update('voicePanel', { - context: 'chat', - ...(frame?.width && { - frameWidth: frame.width, - frameHeight: frame.height, - }), - }); - ui.update('chat', { backVisible: true }); - sync(); + showChatSettings(frame); void ui.focus(['chatBack']); }), // The panel clears its context as it closes, so chat tracks its own. @@ -416,32 +408,30 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); - // What a provider switch carries to the next mount: chat's Settings or - // chat itself, never the conversation. + // Settings shown from chat, in chat's frame when it has one. + function showChatSettings(frame) { + ui.update('voicePanel', { + context: 'chat', + ...(frame?.width && { + frameWidth: frame.width, + frameHeight: frame.height, + }), + }); + ui.update('chat', { backVisible: true }); + sync(); + } + // What a provider switch carries to the next mount: chat's Settings, never + // the conversation. The provider choice lives only in Settings, so chat + // itself is never open during a switch. function presentation() { - if (inChatSettings()) { - const { frameWidth, frameHeight } = ui.state.voicePanel; - return { view: 'settings', frameWidth, frameHeight }; - } - if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; - return null; + if (!inChatSettings()) return null; + const { frameWidth: width, frameHeight: height } = ui.state.voicePanel; + return { view: 'settings', frame: width ? { width, height } : null }; } // Restored without moving focus: the switch keeps it on the provider choice. function restorePresentation(state) { - if (disposed || !state) return; - if (state.view === 'settings') { - ui.update('voicePanel', { - context: 'chat', - ...(state.frameWidth && { - frameWidth: state.frameWidth, - frameHeight: state.frameHeight, - }), - }); - ui.update('chat', { backVisible: true }); - return sync(); - } - open({ focus: false }); - if (state.view === 'minimized') minimize({ focus: false }); + if (disposed || state?.view !== 'settings') return; + showChatSettings(state.frame); } return { close, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index e329a2dc3..c0175456c 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -365,7 +365,7 @@ test('the chat gear is named for the settings it opens', async (t) => { ); }); -test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { +test('a provider switch carries chat Settings to the next mount', async (t) => { const mount = () => { const ui = createTestVoiceUi({ panel: true }); const chat = bindChatControl({ ui, session: typedSession() }); @@ -377,21 +377,22 @@ test('a provider switch carries chat Settings or chat itself to the next mount', before.ui.emit('chat-button'); before.ui.update('voicePanel', { hidden: false }); before.ui.emit('settings-button'); + before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 }); const settings = before.chat.presentation(); - assert.equal(settings.view, 'settings'); + assert.deepEqual(settings, { + view: 'settings', + frame: { width: 320, height: 480 }, + }); const after = mount(); after.chat.restorePresentation(settings); assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.voicePanel.frameWidth, 320); + assert.equal(after.ui.state.voicePanel.frameHeight, 480); assert.equal(after.ui.state.chat.backVisible, true); assert.equal(after.ui.state.root.assistantMode, 'chat'); - // Open and minimized chat come back as they were, without taking focus. - for (const view of ['open', 'minimized']) { - const next = mount(); - next.chat.restorePresentation({ view }); - assert.equal(next.ui.state.chat.open, true); - assert.equal(next.ui.state.chat.minimized, view === 'minimized'); - assert.equal(next.chat.presentation().view, view); - } - // Idle chat carries nothing. + // Chat itself, or no chat at all, carries nothing. + const open = mount(); + open.ui.emit('chat-button'); + assert.equal(open.chat.presentation(), null); assert.equal(mount().chat.presentation(), null); }); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index ae5cd1994..57b512ac0 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1319,6 +1319,56 @@ test('opening chat or closing the voice panel clears a stale voice error', async } }); +test('switching AI Provider from chat Settings keeps chat Settings on the new mount', async (t) => { + const view = mountVoiceView(t); + const host = createVoicePanelHost({ storage: null }); + const instances = []; + const commands = createProviderCommands( + { + runner: async () => ({}), + dataManager: { layers: new Map() }, + supportedProviders: ['openai', 'gemini'], + }, + { + bind: (options) => + bind({ + ...options, + panelHost: host, + createControl: (control) => + createTestVoiceUi({ ...control, pinia: view.pinia }), + }), + factories: { openai: factory(instances), gemini: factory(instances) }, + storage: null, + }, + ); + t.after(() => { + commands.destroy(); + host.destroy(); + }); + await view.settle(); + $('#gev-chat-button').click(); + await view.settle(); + $('#gev-chat-panel').getBoundingClientRect = () => ({ + width: 320, + height: 480, + }); + $('#gev-voice-settings-button').click(); + await view.settle(); + const old = commands.ui; + assert.equal(old.state.voicePanel.context, 'chat'); + old.emit('provider-change', 'gemini'); + await view.settle(); + const { ui } = commands; + assert.notEqual(ui, old); + assert.equal(ui.state.voicePanel.context, 'chat'); + assert.equal(ui.state.voicePanel.frameWidth, 320); + assert.equal(ui.state.voicePanel.frameHeight, 480); + assert.equal(ui.state.chat.backVisible, true); + assert.equal(ui.state.root.assistantMode, 'chat'); + assert.equal($('#gev-chat-back').hidden, false); + assert.equal(active(), 'gev-voice-provider'); +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 756215c7b..5d77ebef6 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,7 +64,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); - // The panel stays where the user is: chat's Settings or chat itself. + // Settings opened from chat stay chat's Settings. const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index dfcf77052..a9acd2804 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -857,13 +857,13 @@ for (const action of ['stop', 'destroy']) { } } -test('a provider switch hands chat Settings or chat to the new mount', async (t) => { +test('a provider switch hands chat Settings to the new mount', async (t) => { const f = compositionFixture(t); const first = f.facade.provider; const next = first === 'openai' ? 'gemini' : 'openai'; // Without chat open there is nothing to carry. assert.equal(f.mounts[0].controls.chatPresentation(), null); - const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + const shown = { view: 'settings', frame: { width: 320, height: 480 } }; f.mounts[0].controls.chatPresentation = () => shown; f.select(next); await settle(); diff --git a/src/voice/session.js b/src/voice/session.js index 21400f04c..7c31f17d2 100644 --- a/src/voice/session.js +++ b/src/voice/session.js @@ -185,6 +185,10 @@ export function createVoiceSession({ createAdapter, runner, signal }) { }; } +// Live, or left in error: stopping either returns the session to idle. +export const isStoppable = (session) => + session.isActive() || session.state === 'error'; + function abortError() { return new DOMException('Voice action cancelled', 'AbortError'); } diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 5a8a7e90c..242a2126b 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -3,7 +3,7 @@ import { bindAssistantPanelLayout, bindLayoutDock } from './panelLayout.js'; import { bindChatControl } from './chatControl.js'; import { bindVoiceMiniPreview } from './voiceMiniPreview.js'; import { createVoiceControl, voiceStatusLabel } from './control.js'; -import { createVoiceSession } from './session.js'; +import { createVoiceSession, isStoppable } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js'; @@ -40,7 +40,7 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, - // Chat's Settings or chat itself, carried across a provider switch. + // Chat's Settings, carried across a provider switch. presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); @@ -289,11 +289,7 @@ export function createVoiceCommands({ // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { // A voice error the user closes is over too; stopping returns to idle. - if ( - (!session.isActive() && session.state !== 'error') || - adapter.getStartOptions?.().textOnly - ) - return; + if (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); From 06c84f92d3791278a80c1ab710e995535768612d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:25 +0400 Subject: [PATCH 104/262] Ignore Arabic short vowels and shadda in chat autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arabic catalog strings carry optional marks ("تتبّع"), but people type without them, so "تتبع" found no suggestion. Fold the marks out of both the draft and the candidates before comparing. --- src/voice/chatPreferences.js | 12 ++++++------ src/voice/chatPreferences.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/voice/chatPreferences.js b/src/voice/chatPreferences.js index b094624bb..e478620cd 100644 --- a/src/voice/chatPreferences.js +++ b/src/voice/chatPreferences.js @@ -22,6 +22,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { msg('Reset to globe view'), msg('Show earthquakes'), ].map((text) => t(text)); + // Arabic short vowels and shadda are optional in writing: typing "تتبع" + // still finds "تتبّع". + const fold = (text) => + text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, ''); try { const stored = JSON.parse(localStorage.getItem(key)); if (typeof stored?.quick === 'boolean') quick = stored.quick; @@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { function render() { const draft = getDraft(); if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory(); - const query = draft.trim().toLowerCase(); + const query = fold(draft.trim()); const groups = []; if (query) { const candidates = [...recent, ...(quick ? quickCommands() : [])]; groups.push({ label: t('Suggestions'), items: [...new Set(candidates)] - .filter( - (text) => - text.toLowerCase().includes(query) && - text.toLowerCase() !== query, - ) + .filter((text) => fold(text).includes(query) && fold(text) !== query) .slice(0, 4), }); } else { diff --git a/src/voice/chatPreferences.test.mjs b/src/voice/chatPreferences.test.mjs index 916fc3342..28dcdefcb 100644 --- a/src/voice/chatPreferences.test.mjs +++ b/src/voice/chatPreferences.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; import { bindChatPreferences } from './chatPreferences.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; @@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => { press('ArrowUp'); assert.equal(field.value, 'Show live flights now'); }); + +test('autocomplete ignores Arabic short vowels and shadda', (t) => { + setLocale('ar'); + t.after(() => setLocale('en')); + const ui = createTestVoiceUi(); + let draft = ''; + const prefs = bindChatPreferences({ ui, getDraft: () => draft }); + t.after(() => prefs.destroy()); + const suggested = (text) => { + draft = text; + prefs.render(); + return ui.state.chat.suggestions.flatMap((group) => group.items); + }; + // The quick command is written "تتبّع", with a shadda. + assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']); + prefs.remember('انتقل إلى طوكيو'); + assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']); +}); From e84cae6baa0d41af79c6a25e2347a3f8587df542 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:46 +0400 Subject: [PATCH 105/262] Add the changelog entry for #1561 --- changelog.d/1561-chat-autocomplete-arabic-marks.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1561-chat-autocomplete-arabic-marks.md diff --git a/changelog.d/1561-chat-autocomplete-arabic-marks.md b/changelog.d/1561-chat-autocomplete-arabic-marks.md new file mode 100644 index 000000000..b49cd0e86 --- /dev/null +++ b/changelog.d/1561-chat-autocomplete-arabic-marks.md @@ -0,0 +1,3 @@ +- Chat autocomplete finds Arabic suggestions typed without short vowels or + shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر» + (German, @gekh, #1561). From 219f3e1079cb8efb0a60068677d9cb942348d5a2 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:04:05 +0100 Subject: [PATCH 106/262] feat(cockpit): enter Cockpit with only the ridden aircraft's layer on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit entry no longer needs Contacts or both flight feeds: a civilian aircraft needs Live Flights, a military one Military Flights. Contacts and every other layer can be turned on or off before or during a session, and turning off the ridden layer ends it. A Context mode change in flight still blocks entry. When the other air feed is off, Live Signals and the Contact card say FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of presenting a partial picture as complete. Voice refuses entry with subject-layer-off rather than contacts-inactive, and its tool description says Contacts is not required. Double-clicking the followed aircraft enters Cockpit through the same gate. The Cockpit takes the scene's double-click before Cesium's stock handler, which would otherwise untrack a followed aircraft whose model is a primitive. The entry also shows under the Intel HUD readout, sharing the Contacts button's face. A test keeps the classes the Cockpit adds to the body from matching another element's rule. --- docs/CURRENT-STATE.md | 18 +- scripts/package-boundaries.json | 5 + scripts/qa-voice-routing.mjs | 29 ++- server/providers/openai/toolDescriptions.js | 2 +- src/cockpitMarkup.test.mjs | 25 ++- src/cockpitSignalFocus.test.mjs | 34 +++ src/contextModePolicy.js | 39 ++-- src/contextModePolicy.test.mjs | 41 ++-- src/firstRunExperience.test.mjs | 5 +- 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 | 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/materialSymbolsSubset.test.mjs | 22 +- src/ui/cockpitBodyClasses.test.mjs | 91 ++++++++ src/ui/cockpitContext.js | 51 ++++- src/ui/cockpitContext.test.mjs | 57 +++++ src/ui/cockpitController.js | 37 +++- src/ui/cockpitCoordinator.js | 78 +++++-- src/ui/cockpitEntryGate.test.mjs | 234 ++++++++++++++++++++ src/ui/cockpitInput.js | 94 ++++++++ src/ui/cockpitPresentation.js | 18 ++ src/ui/cockpitSignals.js | 65 +++++- src/ui/cockpitTrackingController.js | 31 ++- src/ui/contextLayerChanges.js | 16 +- src/ui/contextTransactions.js | 9 +- src/ui/styles/cockpit.css | 30 +++ src/ui/views/ContextView.vue | 7 +- src/ui/views/IntelHudView.vue | 12 + src/voice/actionSchemas.test.mjs | 3 +- 37 files changed, 964 insertions(+), 134 deletions(-) create mode 100644 src/ui/cockpitBodyClasses.test.mjs create mode 100644 src/ui/cockpitEntryGate.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c3e15ea4d..ad0dc30e4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4799,7 +4799,7 @@ cannot claim full acceptance. **Lifecycle and proxy.** Contacts temporarily disables Transit and restores its prior state on exit; Space Missions refuses enablement while isolating replay -data. Cockpit enters through Contacts and does not independently enable Transit. +data. Cockpit does not need Contacts and does not independently enable Transit. The proxy fetches registered URLs only, validates redirect origins, bounds responses and decoding, caches successful snapshots, and backs off after failed refreshes. A 304 confirms contact without appending history. Missing vehicles @@ -6017,12 +6017,16 @@ apply to `public/` assets or built-preview serving. - Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14). - The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver. - The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract. -- Cockpit entry is gated to the operational Contacts context bundle. The - Context chooser must be in Contacts mode, Live Flights and Military Flights must both - be enabled, and a civilian or military aircraft must be tracked. The visible - Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone - flight-layer selection cannot enter a context-dependent cockpit. -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). 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 entry needs only the layer of the aircraft it rides and that aircraft + tracked: Live Flights for a civilian, Military Flights for a military aircraft + (the tracked info's `layerId`; `cockpitEntryAllowed` in + `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. 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. +- **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. - **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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a774993b2..842ec9920 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -39,6 +39,9 @@ "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", + "src/ui/cockpitPresentation.js", + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/ui/views/panels/CockpitRollingValue.vue", "src/ui/views/panels/CockpitSignalList.vue", "src/ui/views/panels/CockpitUtilityControls.vue", @@ -866,6 +869,7 @@ "src/ui/cockpitDisplayPortal.js", "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", + "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", @@ -992,6 +996,7 @@ "src/ui/cockpitDisplayPortal.js", "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", + "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 094119030..0b0a5213a 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -2321,9 +2321,11 @@ async function runBehaviorLayer() { `result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`, ); - // (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the - // reason rather than produce the half-entered state the operator saw: a - // plane anchored under the camera with no HUD and no exit control. + // (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts. + // With Contacts OFF, status must explain a shut gate without blaming + // Contacts, and entry must either enter cleanly or refuse with the reason + // rather than produce the half-entered state the operator saw: a plane + // anchored under the camera with no HUD and no exit control. r = await run('control_cockpit', { action: 'status' }); const gateReport = r?.state || {}; const cockpitBeforeGate = await page.evaluate(() => @@ -2331,10 +2333,13 @@ async function runBehaviorLayer() { ); report( gateReport.active === false && - gateReport.entryAllowed === false && - gateReport.entryBlockedReason === 'contacts-inactive' && + (gateReport.entryAllowed === true + ? gateReport.entryBlockedReason === null + : ['subject-layer-off', 'no-tracked-aircraft'].includes( + gateReport.entryBlockedReason, + )) && !cockpitBeforeGate, - 'behavior: cockpit status names why entry is blocked with Contacts off', + 'behavior: cockpit status explains its gate without requiring Contacts', `state=${JSON.stringify(gateReport)?.slice(0, 150)}`, ); @@ -2348,12 +2353,16 @@ async function runBehaviorLayer() { Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active), ); report( - gated?.ok === false && - /contacts/i.test(gated?.error || '') && - !cockpitAfterGate, - 'behavior: cockpit entry with Contacts off is refused, not half-entered', + gated?.ok === true + ? cockpitAfterGate + : !cockpitAfterGate && !/contacts/i.test(gated?.error || ''), + 'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered', `ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`, ); + if (cockpitAfterGate) + await page.evaluate(() => + window.__godsEyeView?.styleManager?.controlCockpit?.('exit'), + ); await runBundledOutlineScenarios({ page, diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 7b25d34db..891145021 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 64d03914b..aa06193ba 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', ( ui.indexOf("if (normalized === 'exit') {"), ); assert.ok(branch, 'cockpit enter branch is missing'); - const gateIndex = branch.indexOf('isEntryAllowed?.()'); + const gateIndex = branch.indexOf('this._cockpitEntryRefusal()'); const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer('); const enterIndex = branch.indexOf('enterCockpitWithTracking({'); assert.ok( gateIndex >= 0, - 'entry consults the same gate the manual chip uses', + 'entry refuses on the gate it can check before tracking', ); assert.ok( retargetIndex > gateIndex, @@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => { ui.indexOf("if (normalized === 'enter') {"), ui.indexOf("if (normalized === 'exit') {"), ); - assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/); - assert.match(branch, /Contacts must be active to enter Cockpit/); - assert.match(branch, /Contacts is still starting up/); + // Contacts is not required: only a Context change in flight or no aircraft + // layer to ride shuts the gate before tracking, and the entry itself then + // applies the manual chip's gate to the aircraft it rides. + assert.match( + branch, + /const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/, + ); + assert.doesNotMatch(branch, /Contacts must be active/); + const refusal = ui.slice( + ui.indexOf(' _cockpitEntryRefusal() {'), + ui.indexOf(' getCockpitState() {'), + ); + assert.match(refusal, /Context is still changing/); + assert.match(refusal, /No aircraft layer is on/); + assert.doesNotMatch(refusal, /[Cc]ontacts/); }); test('cockpit state cannot report entryAllowed while already active', () => { @@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => { ); assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained'); assert.match(state, /'contacts-starting'/); - assert.match(state, /'contacts-inactive'/); + assert.match(state, /'subject-layer-off'/); + assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required'); assert.match(state, /'no-tracked-aircraft'/); }); diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index a4f73c4ce..89ee1cac1 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({ ], }); +test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => { + const f = fixture(t); + const crowded = { + ...snapshot(), + cohorts: [ + { + id: 'flights', + count: 6, + nearest: [1, 2, 3, 4, 5, 6].map((n) => ({ + id: `n${n}`, + label: `N${n}`, + distanceM: n * 1000, + })), + }, + ], + }; + updateCockpitSignals.call(f.owner, crowded, 0, { + title: 'MILITARY FEED OFF · PARTIAL PICTURE', + detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + }); + await nextTick(); + assert.equal(f.owner.signalItems.length, 5); + assert.equal(f.owner.signalItems[0].key, 'feed-status'); + assert.equal(f.owner.signalItems[0].tone, 'warning'); + assert.equal(f.owner.signalItems[0].target, null); + assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/); + updateCockpitSignals.call(f.owner, crowded, 0); + assert.equal( + f.owner.signalItems.some((item) => item.key === 'feed-status'), + false, + 'with the feed back, the row goes', + ); +}); + test('rows render the signals the controller presents', async (t) => { const f = fixture(t); await render(f, [ diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 4c0b7c9cb..fd3324760 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) { return restored; } +/** The layers an aircraft Cockpit subject can come from. */ +export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([ + 'flights', + 'military', +]); + /** - * Cockpit entry belongs to the operational Contacts context bundle. A tracked - * aircraft alone is not sufficient: both observed-flight feeds must still be - * active so the cockpit's surrounding-contact picture is not presented as a - * complete context view when one source is disabled. + * Cockpit entry needs only the aircraft it rides: that aircraft's own layer + * (Live Flights for a civilian, Military Flights for a military aircraft) must + * be on. Contacts and the other feeds are not required. When a source the + * surrounding picture uses is off, the Cockpit's Contact card and Live + * Signals say the picture is partial instead of blocking entry, so a partial + * picture is never presented as complete. A Context mode change in flight + * still owns the camera, so entry waits it out. * - * @param {object} input Current context and dependency visibility. - * @param {string|null} input.contextMode Active Context mode. - * @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled. - * @param {boolean} input.flightsEnabled Whether Live Flights is enabled. - * @param {boolean} input.militaryEnabled Whether Military Flights is enabled. + * @param {object} input Current subject and Context state. + * @param {string|null} input.subjectLayerId Layer of the tracked aircraft + * ('flights' or 'military'), or null with none tracked. + * @param {boolean} input.subjectLayerEnabled Whether that layer is enabled. + * @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled. * @returns {boolean} Whether cockpit entry may be offered or activated. */ export function cockpitEntryAllowed({ - contextMode, + subjectLayerId, + subjectLayerEnabled, contextModeChanging, - flightsEnabled, - militaryEnabled, }) { return ( - contextMode === 'flights' && - !contextModeChanging && - Boolean(flightsEnabled) && - Boolean(militaryEnabled) + COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) && + Boolean(subjectLayerEnabled) && + !contextModeChanging ); } diff --git a/src/contextModePolicy.test.mjs b/src/contextModePolicy.test.mjs index 40a7b291e..0453bd9b8 100644 --- a/src/contextModePolicy.test.mjs +++ b/src/contextModePolicy.test.mjs @@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued } }); -test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => { +test("cockpit entry needs only the tracked aircraft's own layer", () => { assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: 'flights', + subjectLayerEnabled: true, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: true, - }), true); + }), true, 'a civilian needs Live Flights alone: no Contacts, no Military'); assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: 'military', + subjectLayerEnabled: true, + contextModeChanging: false, + }), true, 'a military aircraft needs Military Flights alone'); + assert.equal(cockpitEntryAllowed({ + subjectLayerId: 'flights', + subjectLayerEnabled: true, contextModeChanging: true, - flightsEnabled: true, - militaryEnabled: true, - }), false, 'Cockpit remains unavailable until Contacts activation settles'); + }), false, 'Cockpit remains unavailable until a Context mode change settles'); assert.equal(cockpitEntryAllowed({ - contextMode: null, + subjectLayerId: 'military', + subjectLayerEnabled: false, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: true, - }), false, 'ordinary layer selection is not an operational Contacts context'); + }), false, "the ridden aircraft's own layer is required"); assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: null, + subjectLayerEnabled: false, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: false, - }), false, 'the military contact feed is required'); - assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', - contextModeChanging: false, - flightsEnabled: false, - militaryEnabled: true, - }), false, 'the civilian contact feed is required'); + }), false, 'there is no aircraft to ride'); }); test('context teardown guard restores coordination state after success and failure', async () => { diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 8d6317cb2..ddcdbbd53 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1039,10 +1039,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // The Utilities panel adds one set_panel_open target; Sea Marks and Ski // Pistes extend the generated layer enums and alias hint; voice geometry // adds the analyst area scopes and the osm-places layer. - assert.equal(block.length, 31318, 'serialized tool schema length drifted'); + // control_cockpit states the own-layer entry rule (Contacts not required). + assert.equal(block.length, 31372, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc', + '7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index deb68ec36..93ec661f8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2187,6 +2187,9 @@ "{value} KM/H": "{value} كم/س", "{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}", "ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6876199d1..e4a41e7f2 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2087,6 +2087,9 @@ "{value} KM/H": "{value} KM/H", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER", "{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}", "ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index df11469ab..1066d8aea 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1920,6 +1920,9 @@ "N/A": "N/D", "{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO", "{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE", "ft": "ft", "kts": "kn", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 69d19c9f7..ab3032a3f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "ACTUS RÉGIONALES", "{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT", "CONTEXT STANDBY": "CONTEXTE EN VEILLE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f33721cbb..cd5c23cef 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "NOTIZIE REGIONALI", "{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", "CONTEXT STANDBY": "CONTESTO IN ATTESA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e47708830..84499fd8a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1612,6 +1612,9 @@ "REGIONAL NEWS": "地域ニュース", "{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません", "CONTEXT STANDBY": "コンテキスト待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません", @@ -1684,7 +1687,7 @@ "Restore real aircraft": "実際の航空機に戻す", "Reclassify as TR-3B": "TR-3Bに再分類", "TRACK ACQUIRED": "追跡開始", - "{label} · COURSE {heading}°": "{label} · 針路 {heading}°", + "{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°", "Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください", "No selected data layers": "選択中のデータレイヤーはありません", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 23c44f786..56c56f36f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "NOTÍCIAS DA ÁREA", "{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA", "CONTEXT STANDBY": "CONTEXTO EM ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", @@ -1736,7 +1739,7 @@ "Restore real aircraft": "Restaurar a aeronave real", "Reclassify as TR-3B": "Reclassificar como TR-3B", "TRACK ACQUIRED": "RASTRO ADQUIRIDO", - "{label} · COURSE {heading}°": "{label} · CURSO {heading}°", + "{label} · COURSE {heading}°": "{label} · RUMO {heading}°", "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": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b364d3bf1..764260167 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -458,7 +458,7 @@ "SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН", "Select flight {title}": "Выбрать рейс {title}", "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", - "{label} · COURSE {heading}°": "{label} · КУРС {heading}°", + "{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°", "Restore real aircraft": "Вернуть реальный самолёт", "Reclassify as TR-3B": "Переклассифицировать как TR-3B", "No selected data layers": "Нет выбранных слоёв данных", @@ -1953,6 +1953,9 @@ "N/A": "Н/Д", "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ", "ft": "фт", "kts": "уз", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d01a6af13..abf5a81a1 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1683,6 +1683,9 @@ "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", "{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", @@ -1761,7 +1764,7 @@ "Restore real aircraft": "Повернути справжній літак", "Reclassify as TR-3B": "Перекласифікувати як TR-3B", "TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО", - "{label} · COURSE {heading}°": "{label} · КУРС {heading}°", + "{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°", "Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз", "No selected data layers": "Шари даних не вибрано", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 07b1083ab..4259d04a0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1588,6 +1588,9 @@ "REGIONAL NEWS": "地区新闻", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞", "CONTEXT STANDBY": "态势待机", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", @@ -1660,7 +1663,7 @@ "Restore real aircraft": "恢复真实飞机", "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已获取航迹", - "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c13b7d39c..805255ca4 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1578,6 +1578,9 @@ "REGIONAL NEWS": "地區新聞", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", @@ -1650,7 +1653,7 @@ "Restore real aircraft": "恢復真實飛機", "Reclassify as TR-3B": "重新歸類為 TR-3B", "TRACK ACQUIRED": "已獲取航跡", - "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs index fdba1c49f..ab77d7c0c 100644 --- a/src/materialSymbolsSubset.test.mjs +++ b/src/materialSymbolsSubset.test.mjs @@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html'); /** The glyph written as element text: `radar`. */ const SPAN_TEXT = /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*{{ a ? 'x' : 'y' }}`. */ +const SPAN_MUSTACHE = + /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`, + ).exec(source)?.[1]; + for (const literal of body?.matchAll( + /\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g, + ) ?? []) + add(literal[1]); + } + const expressions = [ + ...source.matchAll(TEXT_ASSIGNMENT), + ...source.matchAll(SPAN_MUSTACHE), + ]; + for (const match of expressions) { const statement = match[1].replaceAll('?.', '.'); const assigned = statement.includes('?') ? statement.slice(statement.indexOf('?') + 1) diff --git a/src/ui/cockpitBodyClasses.test.mjs b/src/ui/cockpitBodyClasses.test.mjs new file mode 100644 index 000000000..adf897ce8 --- /dev/null +++ b/src/ui/cockpitBodyClasses.test.mjs @@ -0,0 +1,91 @@ +// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on +// must not also name an element's rule. A bare `.name { … }` written +// for a panel matches the moment carries `name`, and its +// pointer-events, position and overflow then apply to the whole page. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const UI_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const SRC_ROOT = path.resolve(UI_ROOT, '..'); +const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js'); + +/** Every stylesheet the app ships. */ +function stylesheets(directory = SRC_ROOT) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...stylesheets(absolute)); + else if (entry.isFile() && entry.name.endsWith('.css')) + files.push(absolute); + } + return files; +} + +/** A selector list split on its top-level commas, not those in `:not(a, b)`. */ +function splitSelectorList(list) { + const parts = []; + let depth = 0; + let start = 0; + for (let i = 0; i < list.length; i += 1) { + if (list[i] === '(') depth += 1; + else if (list[i] === ')') depth -= 1; + else if (list[i] === ',' && depth === 0) { + parts.push(list.slice(start, i)); + start = i + 1; + } + } + parts.push(list.slice(start)); + return parts; +} + +/** The classes the tracking controller adds to . */ +function cockpitBodyClasses() { + const source = readFileSync(CONTROLLER, 'utf8'); + const names = [ + ...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g), + ].map((match) => match[1]); + return [...new Set(names)]; +} + +test('the Cockpit adds its subject classes to ', () => { + const classes = cockpitBodyClasses(); + for (const name of ['cockpit-mode']) { + assert.ok(classes.includes(name), `${name} is not added to `); + } +}); + +test('no stylesheet rule matches a Cockpit body class on another element', () => { + const offenders = []; + for (const name of cockpitBodyClasses()) { + // `.name` as a whole class token, not as `.name-suffix`. + const token = new RegExp(`\\.${name}(?![\\w-])`); + for (const file of stylesheets()) { + const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) { + for (const selector of splitSelectorList(selectors)) { + // Prettier wraps long `:not(\n .name\n)` arguments; unwrap them. + const compound = selector + .trim() + .replace(/\(\s+/g, '(') + .replace(/\s+\)/g, ')'); + if (!token.test(compound)) continue; + // Allowed only on itself: `body.name` or `body:not(.name)`. + const onBody = new RegExp( + `(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`, + ); + if (onBody.test(compound)) continue; + offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`); + } + } + } + } + assert.deepEqual( + offenders, + [], + 'Selectors that match a Cockpit body class without `body.`: ' + + offenders.join('; '), + ); +}); diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..a343bbb91 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -24,6 +24,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), }); +/** + * Which air feed the aircraft Cockpit's surrounding picture is missing. Entry + * needs only the ridden aircraft's own layer, so the other one may be off; + * the panels then say the picture is partial rather than present it as + * complete. Null with both feeds on, or when the owner cannot tell. + * @returns {?{title: string, detail: string}} + */ +export function readPartialPicture() { + const isLayerEnabled = this.services.isLayerEnabled; + if (typeof isLayerEnabled !== 'function') return null; + const militaryOff = !isLayerEnabled('military'); + const flightsOff = !isLayerEnabled('flights'); + if (!militaryOff && !flightsOff) return null; + return { + title: militaryOff + ? t('MILITARY FEED OFF · PARTIAL PICTURE') + : t('FLIGHTS FEED OFF · PARTIAL PICTURE'), + detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'), + }; +} + /** Hand a fresh copy of the Contact card to the view. */ export function publishContextView() { const view = this.contextView; @@ -52,6 +73,7 @@ export function updateContext(info, heading) { this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; const trackedId = info.icao24 || info.id; const readout = resolveCockpitContextReadout({ snapshot, info }); + const partial = readPartialPicture.call(this); if (!readout.visible) { this.showContextCard(false); this.contextLayoutStamp = null; @@ -61,6 +83,14 @@ export function updateContext(info, heading) { t('CONTEXT STANDBY'), t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'), ); + if (partial) + this.pushCockpitSignal( + 'feed-status', + 'warning', + partial.title, + partial.detail, + ); + else this.dropCockpitSignal?.('feed-status'); return; } @@ -171,19 +201,22 @@ export function updateContext(info, heading) { angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, }); } - view.uncertainty = unknownCount - ? tn( - unknownCount, - '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', - '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', - ) - : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); + // A feed that is off outranks unknown inputs: the picture is partial. + view.uncertainty = partial + ? partial.title + : unknownCount + ? tn( + unknownCount, + '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + ) + : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); view.updated = Number.isFinite(snapshot.evaluatedAt) ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - view.state = unknownCount ? 'uncertain' : 'current'; + view.state = unknownCount || partial ? 'uncertain' : 'current'; this.publishContextView(); - this.updateCockpitSignals(snapshot, unknownCount); + this.updateCockpitSignals(snapshot, unknownCount, partial); if (this.contextLayoutStamp !== snapshot.evaluatedAt) { this.contextLayoutStamp = snapshot.evaluatedAt; this.scheduleContextLayout(); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..4896d238e 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -204,6 +204,63 @@ test('without a snapshot the card stands by, hidden', async (t) => { assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY'); }); +test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => { + // Every input known, so only the feed that is off can make it partial. + const { $, text, owner } = fixture( + t, + snapshot({ + cohorts: [ + { + id: 'flights', + label: 'Flights', + count: 12, + relationship: 'OBSERVED', + nearest: [{ id: 'N1', distanceM: 4200 }], + }, + ], + }), + ); + const rebuilt = []; + owner.updateCockpitSignals = (...args) => rebuilt.push(args); + owner.services.isLayerEnabled = () => true; + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').dataset.state, 'current'); + assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add'); + + owner.services.isLayerEnabled = (layerId) => layerId !== 'military'; + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').dataset.state, 'uncertain'); + assert.equal( + text('#cockpit-context-uncertainty'), + 'MILITARY FEED OFF · PARTIAL PICTURE', + ); + assert.deepEqual(rebuilt.at(-1)[2], { + title: 'MILITARY FEED OFF · PARTIAL PICTURE', + detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + }); +}); + +test('without Contacts, a feed that is off adds a signal until it is back', async (t) => { + const { owner, signals } = fixture(t, null); + const dropped = []; + owner.dropCockpitSignal = (key) => dropped.push(key); + // Riding a military aircraft with Live Flights off. + owner.services.isLayerEnabled = (layerId) => layerId === 'military'; + updateContext.call(owner, { ...INFO, layerId: 'military' }, 0); + assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY'); + assert.deepEqual(signals.at(-1), [ + 'feed-status', + 'warning', + 'FLIGHTS FEED OFF · PARTIAL PICTURE', + 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + ]); + owner.services.isLayerEnabled = () => true; + updateContext.call(owner, { ...INFO, layerId: 'military' }, 0); + assert.deepEqual(dropped, ['feed-status']); +}); + test('the weather toggle renders its state and the action it offers', async (t) => { const { $, text, owner } = fixture(t); syncWeatherToggle.call(owner, true); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 50a397fc8..1e69146e3 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,6 +1,8 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ import { readAircraftInfo, + readSubjectLayerId, + resolveEntryKind, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, @@ -48,6 +50,7 @@ import { relocalizeBrief, } from './cockpitBriefing.js'; import { + dropCockpitSignal, handleSignalClick, publishCockpitSignals, pushCockpitSignal, @@ -60,7 +63,11 @@ import { syncContextLayout, syncSignalLayout, } from './cockpitLayout.js'; -import { onKeyDown } from './cockpitInput.js'; +import { + onKeyDown, + onSceneDoubleClick, + routeSceneDoubleClick, +} from './cockpitInput.js'; import * as Cesium from 'cesium'; export class CockpitViewController { @@ -205,6 +212,13 @@ export class CockpitViewController { }), ); this._listen(this.entry, 'click', () => this.enter()); + // Double-clicking the followed track is a third way in. + 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._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-entry-readout')) this.enter(); + }); this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); this._listen(this.mapViewButton, 'click', () => this.exit()); this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); @@ -272,6 +286,12 @@ export class CockpitViewController { readAircraftInfo() { return readAircraftInfo.call(this); } + readSubjectLayerId() { + return readSubjectLayerId.call(this); + } + resolveEntryKind() { + return resolveEntryKind.call(this); + } dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); } @@ -371,8 +391,16 @@ export class CockpitViewController { pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); } - updateCockpitSignals(snapshot, unknownCount) { - return updateCockpitSignals.call(this, snapshot, unknownCount); + dropCockpitSignal(key) { + return dropCockpitSignal.call(this, key); + } + updateCockpitSignals(snapshot, unknownCount, partialPicture) { + return updateCockpitSignals.call( + this, + snapshot, + unknownCount, + partialPicture, + ); } scheduleContextLayout() { return scheduleContextLayout.call(this); @@ -392,6 +420,9 @@ export class CockpitViewController { onKeyDown(event) { return onKeyDown.call(this, event); } + onSceneDoubleClick(position) { + return onSceneDoubleClick.call(this, position); + } handleSignalClick(event) { return handleSignalClick.call(this, event); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index b71889b4c..7140f3172 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -1,6 +1,9 @@ import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; -import { cockpitEntryAllowed } from '../contextModePolicy.js'; +import { + COCKPIT_AIRCRAFT_LAYER_IDS, + cockpitEntryAllowed, +} from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -72,19 +75,24 @@ export class CockpitCoordinator { fetchRegionalBrief, regionalDistanceM, weatherCodeLabel, + // The Contact card and Live Signals say which feeds are off. + isLayerEnabled: (layerId) => this._isLayerEnabled(layerId), }, onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle), onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), - isEntryAllowed: () => - cockpitEntryAllowed({ - contextMode: this._contextMode, + // Only the tracked aircraft's own layer gates entry (see the policy). + isEntryAllowed: () => { + const subjectLayerId = + this.cockpitView?.readAircraftInfo?.()?.layerId ?? null; + return cockpitEntryAllowed({ + subjectLayerId, + subjectLayerEnabled: this._isLayerEnabled(subjectLayerId), contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }), + }); + }, publish: this.publishCockpit, isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(), onEntered: () => this.enterPanels(), @@ -115,6 +123,28 @@ export class CockpitCoordinator { get activeStyle() { return this.readActiveStyle(); } + /** Whether a data layer is enabled; false for no layer. */ + _isLayerEnabled(layerId) { + return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId); + } + /** Whether either aircraft layer is on, so there is an aircraft to ride. */ + get _aircraftLayerEnabled() { + return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) => + this._isLayerEnabled(layerId), + ); + } + /** + * Why voice entry must refuse before it touches tracking, or null. The + * tracked aircraft's own layer is checked again by the entry itself. + * @returns {string|null} + */ + _cockpitEntryRefusal() { + if (this._contextModeChanging) + return 'Context is still changing — try Cockpit again in a moment'; + if (!this._aircraftLayerEnabled) + return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; + return null; + } getCockpitState() { const { militaryAwarenessLayer } = this.services; const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); @@ -129,19 +159,24 @@ export class CockpitCoordinator { const entryAllowed = !active && Boolean(gateOpen && info && this.viewer?.trackedEntity?.position); + // Entry needs only the ridden aircraft's own layer: with one tracked, + // that layer; with none, any aircraft layer to track one from. + const subjectLayerOff = info + ? !this._isLayerEnabled(info.layerId) + : !this._aircraftLayerEnabled; return { active, entryAllowed, // Why entry is unavailable, so a refusal can be explained rather than - // guessed at. + // guessed at. Contacts is not required, so it is never the reason. entryBlockedReason: entryAllowed || active ? null - : !gateOpen - ? this._contextModeChanging - ? 'contacts-starting' - : 'contacts-inactive' - : 'no-tracked-aircraft', + : this._contextModeChanging + ? 'contacts-starting' + : subjectLayerOff + ? 'subject-layer-off' + : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, subject: info ? { @@ -260,17 +295,18 @@ export class CockpitCoordinator { }; } if (normalized === 'enter') { - // Entry is gated exactly as the manual entry chip is. Attempting it while - // the gate is shut produced the half-entered look the operator reported - // (a plane anchored under the camera with no cockpit around it), so - // refuse with the reason instead of trying. - if (!this.cockpitView.isEntryAllowed?.()) { + // Attempting entry while the gate is shut produced the half-entered look + // the operator reported (a plane anchored under the camera with no + // cockpit around it), so refuse with the reason instead of trying. The + // aircraft may not be tracked yet (a selection or a requested layer is + // adopted below), so this checks what can be known now; the entry + // itself then applies the manual chip's gate to the aircraft it rides. + const refusal = this._cockpitEntryRefusal(); + if (refusal) { return { ok: false, action: 'control_cockpit', - error: this._contextModeChanging - ? 'Contacts is still starting up — try Cockpit again in a moment' - : 'Contacts must be active to enter Cockpit — say "open contacts" first', + error: refusal, state: this.getCockpitState(), }; } diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs new file mode 100644 index 000000000..5931ca5b2 --- /dev/null +++ b/src/ui/cockpitEntryGate.test.mjs @@ -0,0 +1,234 @@ +// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of +// the vehicle it rides: Contacts and the other feeds may be off before or +// during a session, turning the ridden layer off ends it, and the voice gate +// and state say so without claiming Contacts is required. 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 { CockpitCoordinator } from './cockpitCoordinator.js'; +import { _handleContextLayerChange } from './contextLayerChanges.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import { useHudStore } from './stores/hud.js'; +import ContextView from './views/ContextView.vue'; +import IntelHudView from './views/IntelHudView.vue'; + +const CIVIL = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: 30, + longitude: -97, +}); +const MILITARY = Object.freeze({ + ...CIVIL, + icao24: 'ae0001', + callsign: 'RCH1', +}); + +function fixture( + t, + { + enabled = [], + tracked = null, + contextMode = null, + changing = false, + } = {}, +) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(IntelHudView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const layers = new Set(enabled); + const dataManager = { isEnabled: (layerId) => layers.has(layerId) }; + // A layer that is off has no tracked aircraft, as the flight layers clear + // tracking on disable. + const trackedFrom = (layerId) => () => + tracked?.layerId === layerId && layers.has(layerId) ? tracked.info : null; + const entity = { + position: { getValue: () => null }, + show: true, + gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '', + }; + const listeners = () => ({ addEventListener: () => () => {} }); + const viewer = { + trackedEntity: tracked ? entity : undefined, + clock: { currentTime: null }, + camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } }, + entities: { contains: (candidate) => candidate === entity }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + const context = { mode: contextMode, changing }; + const coordinator = new CockpitCoordinator({ + viewer, + services: { + flightsLayer: { getTrackedInfo: trackedFrom('flights') }, + militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, + militaryAwarenessLayer: { getContextSnapshot: () => null }, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + elements: {}, + operations: { + publishCockpit: { presence: (view) => store.publishPresence(view) }, + _stampNavigation() {}, + getAircraftTrackingTarget: () => + tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null, + }, + readDataManager: () => dataManager, + readContext: () => context, + readActiveStyle: () => 'normal', + readUtilityDisclosureOpen: () => false, + enterPanels() {}, + exitPanels() {}, + }); + const cockpit = coordinator.cockpitView; + // The HUD, briefing and signals have their own tests. + Object.assign(cockpit, { + updateHud() {}, + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal() {}, + scheduleContextLayout() {}, + clearPredictiveRoute() {}, + }); + t.after(() => { + coordinator.stop(); + app.unmount(); + document.body.classList.remove('cockpit-mode'); + }); + /** Turn a layer off and tell the Context owner, as the data manager does. */ + const disable = (layerId) => { + layers.delete(layerId); + _handleContextLayerChange.call( + { + cockpitView: cockpit, + _dataManager: dataManager, + _contextMode: null, + _contextModeChanging: false, + _contextSessionSnapshot: null, + _contextRestoreState: null, + _syncContextModeButtons() {}, + }, + { type: 'visibility', layerId, enabled: false, origin: 'user' }, + ); + }; + const $ = (selector) => document.querySelector(selector); + return { $, coordinator, cockpit, store, layers, disable, pinia }; +} + +test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + const state = coordinator.getCockpitState(); + assert.equal(state.entryAllowed, true); + assert.equal(state.entryBlockedReason, null); + assert.equal(cockpit.enter(), true); +}); + +test('a military aircraft enters with Military Flights alone', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['military'], + tracked: { layerId: 'military', info: MILITARY }, + }); + assert.equal(coordinator.getCockpitState().entryAllowed, true); + assert.equal(cockpit.enter(), true); +}); + +test('a Context change in flight still blocks entry, and says so', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights', 'military'], + tracked: { layerId: 'flights', info: CIVIL }, + contextMode: 'flights', + changing: true, + }); + const state = coordinator.getCockpitState(); + assert.equal(state.entryAllowed, false); + assert.equal(state.entryBlockedReason, 'contacts-starting'); + assert.equal(cockpit.enter(), false); + const voice = coordinator.controlCockpit('enter'); + assert.equal(voice.ok, false); + assert.match(voice.error, /Context is still changing/); + assert.equal(cockpit.active, false); +}); + +test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => { + const { coordinator, cockpit } = fixture(t); + const state = coordinator.getCockpitState(); + assert.equal(state.entryBlockedReason, 'subject-layer-off'); + const voice = coordinator.controlCockpit('enter'); + assert.equal(voice.ok, false); + assert.match(voice.error, /No aircraft layer is on/); + assert.doesNotMatch(voice.error, /contacts/i); + assert.equal(voice.state.entryBlockedReason, 'subject-layer-off'); + assert.equal(cockpit.active, false); +}); + +test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => { + const { coordinator } = fixture(t, { enabled: ['military'] }); + assert.equal( + coordinator.getCockpitState().entryBlockedReason, + 'no-tracked-aircraft', + ); +}); + +test('voice enters without Contacts when the ridden layer is on', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + const result = coordinator.controlCockpit('enter'); + assert.equal(result.ok, true, result.error); + assert.equal(cockpit.active, true); +}); + +test('other layers may be turned off during a session; the ridden layer ends it', (t) => { + const { cockpit, disable } = fixture(t, { + enabled: ['flights', 'military', 'ais-live-vessels'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + assert.equal(cockpit.enter(), true); + disable('military'); + disable('ais-live-vessels'); + assert.equal(cockpit.active, true, 'other feeds are free to go off'); + disable('flights'); + assert.equal(cockpit.active, false, 'the ridden layer going off ends it'); +}); + +test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => { + const { $, cockpit, pinia } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + useHudStore(pinia); + const readoutEntry = $('#cockpit-entry-readout'); + assert.equal(readoutEntry.hidden, true, 'hidden until entry is available'); + cockpit.syncEntry(); + await nextTick(); + assert.equal(readoutEntry.hidden, false); + assert.match(readoutEntry.textContent, /flight/); + assert.match(readoutEntry.textContent, /COCKPIT/); + assert.equal( + $('#cockpit-entry').hidden, + false, + 'the Contacts entry follows the same presence', + ); + readoutEntry.click(); + await nextTick(); + assert.equal(cockpit.active, true); + assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 54b8f56a9..15b4207e1 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,5 +1,99 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ +import * as Cesium from 'cesium'; +import { isPointerFree } from '../data/inputOwnership.js'; +/** How far from the followed track a double-click may land, CSS pixels. */ +export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32; + +/** + * Whether a double-click landed on the followed track's mark. + * @param {{x: number, y: number}|null} click Canvas position of the click. + * @param {{x: number, y: number}|null|undefined} subject Canvas position of + * the track, or nothing when it is off screen. + * @param {number} [radiusPx] + */ +export function doubleClickHitsSubject( + click, + subject, + radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX, +) { + if (!click || !subject) return false; + const distance = Math.hypot(click.x - subject.x, click.y - subject.y); + return Number.isFinite(distance) && distance <= radiusPx; +} + +/** 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 + ); +} + +/** The followed track's canvas position now, or null off screen. */ +function subjectCanvasPosition() { + const kind = this.resolveEntryKind(); + const world = kind ? subjectWorldPosition.call(this, kind) : null; + const scene = this.viewer.scene; + if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function') + return null; + return ( + scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? 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 + * 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. + * @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) { + 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; + this.enter(); + return true; +} + +/** + * Take the scene's double-click through the Cockpit first. Cesium's Viewer + * installs its own (pickAndTrackObject), which untracks whenever the pick is + * not an Entity: on a followed aircraft's model, a primitive, it would drop + * the very track the double-click means to ride. A double-click on the + * followed track is the Cockpit's; any other goes on to Cesium's. + * @returns {() => void} Puts Cesium's handler back, unless another owner + * (the draw tool borrows it) has replaced ours since. + */ +export function routeSceneDoubleClick() { + const handler = this.viewer.screenSpaceEventHandler; + if (typeof handler?.setInputAction !== 'function') return () => {}; + const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; + const stock = handler.getInputAction?.(type) ?? null; + const routed = (event) => { + if (this.onSceneDoubleClick(event?.position)) return; + stock?.(event); + }; + handler.setInputAction(routed, type); + return () => { + if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed) + return; + if (stock) handler.setInputAction(stock, type); + else handler.removeInputAction(type); + }; +} + +/** + * The Cockpit's capture-phase keydown: Escape and C. + * @param {KeyboardEvent} event + */ export function onKeyDown(event) { if (this.destroyed) return false; if (event.repeat || event.isComposing) return; diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index fc62ceaad..b56a81892 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) { const degrees = roundHeadingDegrees(value); return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } + +/** + * The Cockpit entry for each subject it can ride: an aircraft's cockpit. + * 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') }), +}); + +/** + * The entry's glyph and localized label for the subject it would ride. + * @param {'aircraft'|null|undefined} kind + * @returns {{icon: string, label: string}} + */ +export function cockpitEntryFace(kind) { + const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: face.icon, label: t(face.label) }; +} diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 2319a6d21..395db8116 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -62,7 +62,31 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) { this.publishCockpitSignals(); } -export function updateCockpitSignals(snapshot, unknownCount) { +/** + * Take a status row off the list, as when the feed it warned about is back. + * @param {string} key The row's key. + */ +export function dropCockpitSignal(key) { + this.signalSignatures.delete(key); + const kept = this.signalItems.filter((item) => item.key !== key); + if (kept.length === this.signalItems.length) return; + this.signalItems = kept; + this.publishCockpitSignals(); +} + +/** + * Rebuild the rows from a Contacts snapshot: the nearest flights, a row for + * unknown inputs and, first, a row naming an air feed that is off. + * @param {object} snapshot `militaryAwareness.getContextSnapshot()` result. + * @param {number} unknownCount Cohorts whose count is unknown. + * @param {?{title: string, detail: string}} [partialPicture] The feed-off + * row (see `readPartialPicture`), or null with every air feed on. + */ +export function updateCockpitSignals( + snapshot, + unknownCount, + partialPicture = null, +) { const previous = new Map(this.signalItems.map((item) => [item.key, item])); const contacts = []; const subject = snapshot.subject; @@ -110,7 +134,9 @@ export function updateCockpitSignals(snapshot, unknownCount) { } } contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ + // A feed-off row leads, so the list never reads as the whole picture. + const reserved = partialPicture ? 1 : 0; + const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({ ...item, timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), @@ -120,18 +146,35 @@ export function updateCockpitSignals(snapshot, unknownCount) { .filter((cohort) => cohort.count === null) .map((cohort) => cohort.source) .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', + nextItems.splice( + 4 - reserved, + Math.max(0, nextItems.length - 4 + reserved), + { + key: 'input-status', + tone: 'warning', + title: tn( + unknownCount, + '{count} INPUT UNKNOWN', + '{count} INPUTS UNKNOWN', + ), + detail: sources || t('SOURCE STATUS UNAVAILABLE'), + target: null, + timestamp: + previous.get('input-status')?.timestamp || + snapshot.evaluatedAt || + Date.now(), + }, + ); + } + if (partialPicture) { + nextItems.unshift({ + key: 'feed-status', tone: 'warning', - title: tn( - unknownCount, - '{count} INPUT UNKNOWN', - '{count} INPUTS UNKNOWN', - ), - detail: sources || t('SOURCE STATUS UNAVAILABLE'), + title: partialPicture.title, + detail: partialPicture.detail, target: null, timestamp: - previous.get('input-status')?.timestamp || + previous.get('feed-status')?.timestamp || snapshot.evaluatedAt || Date.now(), }); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index f308b0d2f..3ad6267ff 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -17,6 +17,30 @@ export function readAircraftInfo() { }); } +/** + * 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} + */ +export function readSubjectLayerId() { + if (!this.active) return null; + const layerId = this.readAircraftInfo()?.layerId; + if (layerId) return layerId; + const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':'); + return ['flights', 'military'].includes(prefix) ? prefix : null; +} + +/** + * The subject entry would take: a Cesium-tracked aircraft. + * @returns {'aircraft'|null} + */ +export function resolveEntryKind() { + return this.readAircraftInfo() && this.viewer.trackedEntity?.position + ? 'aircraft' + : null; +} + export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String(info?.icao24 || '') @@ -247,9 +271,12 @@ export function exit({ restoreTracking = true } = {}) { this.restoreTrackingFrame(entity); } this.syncEntry(); + // Back to whichever entry was used: the Contacts button or the readout's. + const readoutEntry = document.getElementById('cockpit-entry-readout'); const restoreTarget = - this.entryFocusOrigin === this.entry - ? this.entry + this.entryFocusOrigin === this.entry || + (readoutEntry && this.entryFocusOrigin === readoutEntry) + ? this.entryFocusOrigin : this.entry || this.entryFocusOrigin; this.entryFocusOrigin = null; // The entry shows when Vue flushes the presence; read it after. diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index fc5abe3a7..d4ddd6cd5 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,7 +1,6 @@ import { t, translateKnown } from '../i18n/index.js'; import { SPACE_MISSIONS_LAYER_BLOCK_REASON, - cockpitEntryAllowed, isExplicitUserIntentOrigin, parseContextLayerBlockReason, recordContextRestoreExplicitChange, @@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) { ); } } - if ( - this.cockpitView?.active && - !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }) - ) { + // Turning off the layer the view rides ends it; every other layer may be + // turned on or off freely during a session. + const riddenLayerId = this.cockpitView?.active + ? this.cockpitView.readSubjectLayerId?.() + : null; + if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) { this.cockpitView.exit({ restoreTracking: false }); } this._syncContextModeButtons(); diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index 729edc654..e8a69b5fc 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -18,7 +18,10 @@ export async function _selectContextMode( this._contextModeChanging = true; this._syncContextModeButtons(); try { - if (mode !== 'flights' && this.cockpitView?.active) { + // Cockpit does not need Contacts, so leaving it keeps the view; the + // restore below ends it only by turning off the layer it rides. Another + // mode takes the camera, so switching to one ends it. + if (mode && mode !== 'flights' && this.cockpitView?.active) { this.cockpitView.exit({ restoreTracking: false }); } if (!mode) { @@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({ this._contextMode = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) - this.cockpitView.exit({ restoreTracking: false }); + // A layer turned off ends Contacts but not the Cockpit: the view ends only + // when the layer it rides goes off (see _handleContextLayerChange). this._syncContextModeButtons(); try { await this._restoreContextSession({ notificationToken }); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index befbfbbff..d7ad845b6 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -33,6 +33,36 @@ color: #00dcff; font-size: 18px; } +/* The same entry under the selected vehicle's Intel HUD readout. The HUD + lets clicks through, so the button takes them back. */ +#intel-hud #cockpit-entry-readout { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--hud-border); + border-radius: 6px; + background: rgba(2, 12, 18, 0.72); + font-size: 10px; + letter-spacing: 0.14em; + pointer-events: auto; + cursor: pointer; +} +#intel-hud #cockpit-entry-readout[hidden] { + display: none; +} +#intel-hud #cockpit-entry-readout:hover, +#intel-hud #cockpit-entry-readout:focus-visible { + border-color: var(--hud-color); + outline: none; +} +#intel-hud #cockpit-entry-readout .material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-size: 16px; + text-transform: none; +} #cockpit-entry kbd { padding: 3px 5px; border: 1px solid rgba(220, 238, 255, 0.25); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index f330391e9..c40665fe4 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -14,11 +14,14 @@ import MissionDetail from './panels/MissionDetail.vue'; import MissionRoster from './panels/MissionRoster.vue'; import RadioPanel from './panels/RadioPanel.vue'; import UiButton from '../kit/UiButton.vue'; +import { cockpitEntryFace } from '../cockpitPresentation.js'; // ContextControls presents the mode switch and the nearby sites search and // 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')); // 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. @@ -56,8 +59,8 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
🛸 diff --git a/src/ui/views/IntelHudView.vue b/src/ui/views/IntelHudView.vue index 77cd0c4fd..6f60bce76 100644 --- a/src/ui/views/IntelHudView.vue +++ b/src/ui/views/IntelHudView.vue @@ -2,6 +2,8 @@ import { computed } from 'vue'; import { storeToRefs } from 'pinia'; import { useHudStore } from '../stores/hud.js'; +import { useCockpitStore } from '../stores/cockpit.js'; +import { cockpitEntryFace } from '../cockpitPresentation.js'; // The readouts, visibility, layout variant and colors IntelHUD publishes. const { hud } = storeToRefs(useHudStore()); @@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore()); // right after IntelHUD is built. const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null }); const view = computed(() => hud.value ?? EMPTY); +// The selected vehicle's readout offers the same entry as the Contacts +// 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'));