From da689e03ac983499f9682c46889d454195e7231b Mon Sep 17 00:00:00 2001 From: monwiss <547445+monwiss@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:15:39 -0400 Subject: [PATCH 001/113] refactor(providers): move the tile budget helpers out of the TomTom module utcDayKey, normalizeBudget and isOverBudget move unchanged to src/data/tileBudget.js. Nothing in them was TomTom-specific; the next keyed tile proxy (Xweather) counts a monthly allowance through the same two functions. tomtomTiles.js re-exports them, so the traffic provider and its tests keep their imports. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/format-scope.json | 2 ++ scripts/package-boundaries.json | 12 ++++++-- src/data/tileBudget.js | 53 +++++++++++++++++++++++++++++++++ src/data/tileBudget.test.mjs | 26 ++++++++++++++++ src/data/tomtomTiles.js | 41 ++----------------------- 5 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 src/data/tileBudget.js create mode 100644 src/data/tileBudget.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..9b82af4b7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -79,6 +79,8 @@ "src/data/terrainHeightsProxy.test.mjs", "src/data/tomtomTiles.js", "src/data/tomtomTiles.test.mjs", + "src/data/tileBudget.js", + "src/data/tileBudget.test.mjs", "src/data/firmsCsv.js", "src/data/firmsCsv.test.mjs", "src/tooling/environmentProviders.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..748f11d44 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,12 +113,16 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js", + "src/data/tileBudget.js" + ], "external": [] }, "traffic-source": { "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "modules": ["src/data/tomtomTiles.js", "src/data/tileBudget.js"], "external": [] }, "firms-csv": { @@ -1009,6 +1013,7 @@ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/trafficBounds.js", "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", @@ -1424,6 +1429,7 @@ "src/data/telegeographySubmarineCables.js", "src/data/terrainHeights.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", "src/data/trackedModelRegime.js", @@ -2071,6 +2077,7 @@ "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", "src/data/trackedModelRegime.js", @@ -2707,6 +2714,7 @@ "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/layers/traffic/flowDecode.js", "src/layers/traffic/flowSource.js", "src/layers/traffic/source.js", diff --git a/src/data/tileBudget.js b/src/data/tileBudget.js new file mode 100644 index 000000000..dcc44a28c --- /dev/null +++ b/src/data/tileBudget.js @@ -0,0 +1,53 @@ +/** + * @file Upstream-request accounting shared by the keyed tile proxies. + * + * A soft cap per UTC period, persisted as `{date, count}` so it survives a + * restart. Nothing here is provider-specific: the caller picks the period by + * the key it passes, and decides what happens over the cap. + * + * Zero dependencies and Cesium-free, so providers and browser helpers can + * import it and node:test can exercise it directly. + * + * @module data/tileBudget + */ + +/** + * UTC calendar-day key for budget bucketing. + * + * @param {number} [epochMs=Date.now()] - Timestamp in ms. + * @returns {string} 'YYYY-MM-DD' in UTC. + */ +export function utcDayKey(epochMs = Date.now()) { + return new Date(epochMs).toISOString().slice(0, 10); +} + +/** + * Normalize a persisted budget state against today's UTC day key. + * Rolls the counter to zero on day change; replaces missing/corrupt state. + * Returns the SAME object when it is already valid for `dayKey` (cheap to + * call on every request). + * + * @param {{date:string, count:number}|null|undefined} state - Persisted state. + * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). + * @returns {{date:string, count:number}} Valid state for `dayKey`. + */ +export function normalizeBudget(state, dayKey) { + const valid = + Boolean(state) && + state.date === dayKey && + Number.isFinite(state.count) && + state.count >= 0; + return valid ? state : { date: dayKey, count: 0 }; +} + +/** + * Whether the daily soft cap has been reached. + * + * @param {{count:number}} state - Normalized budget state. + * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. + * @returns {boolean} True when `count >= limit`. + */ +export function isOverBudget(state, limit) { + if (!Number.isFinite(limit) || limit <= 0) return false; + return state.count >= limit; +} diff --git a/src/data/tileBudget.test.mjs b/src/data/tileBudget.test.mjs new file mode 100644 index 000000000..fa48440bb --- /dev/null +++ b/src/data/tileBudget.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js'; +import * as tomtom from './tomtomTiles.js'; + +test('tile budget helpers live in one module and TomTom re-exports them', () => { + assert.equal(tomtom.utcDayKey, utcDayKey); + assert.equal(tomtom.normalizeBudget, normalizeBudget); + assert.equal(tomtom.isOverBudget, isOverBudget); +}); + +test('normalizeBudget keeps a valid state and resets another period', () => { + const state = { date: '2026-09-24', count: 3 }; + assert.equal(normalizeBudget(state, '2026-09-24'), state); + assert.deepEqual(normalizeBudget(state, '2026-09-25'), { + date: '2026-09-25', + count: 0, + }); + assert.deepEqual( + normalizeBudget({ date: '2026-09-24', count: -1 }, '2026-09-24'), + { + date: '2026-09-24', + count: 0, + }, + ); +}); diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index af4fc95b3..a1dbc18cb 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -131,42 +131,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) { // ─── Daily budget accounting ─────────────────────────────── /** - * UTC calendar-day key for budget bucketing. - * - * @param {number} [epochMs=Date.now()] - Timestamp in ms. - * @returns {string} 'YYYY-MM-DD' in UTC. + * Re-exported from `./tileBudget.js`, which keyed tile proxies share. The + * traffic provider and its tests keep one import for tile math and budget. */ -export function utcDayKey(epochMs = Date.now()) { - return new Date(epochMs).toISOString().slice(0, 10); -} - -/** - * Normalize a persisted budget state against today's UTC day key. - * Rolls the counter to zero on day change; replaces missing/corrupt state. - * Returns the SAME object when it is already valid for `dayKey` (cheap to - * call on every request). - * - * @param {{date:string, count:number}|null|undefined} state - Persisted state. - * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). - * @returns {{date:string, count:number}} Valid state for `dayKey`. - */ -export function normalizeBudget(state, dayKey) { - const valid = - Boolean(state) && - state.date === dayKey && - Number.isFinite(state.count) && - state.count >= 0; - return valid ? state : { date: dayKey, count: 0 }; -} - -/** - * Whether the daily soft cap has been reached. - * - * @param {{count:number}} state - Normalized budget state. - * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. - * @returns {boolean} True when `count >= limit`. - */ -export function isOverBudget(state, limit) { - if (!Number.isFinite(limit) || limit <= 0) return false; - return state.count >= limit; -} +export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js'; From b09db551495e2c9e51c8ff30c903f9e1a4c851c3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:53:13 +0700 Subject: [PATCH 002/113] fix(server): declare the method contract on the three read-only query proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/opensky, /api/adsblol/mil and /api/route answered any verb with the same body while ignoring the request entirely. Measured against a running server before the change: POST /api/opensky returned 200 with 833,759 bytes of live state vectors, POST /api/adsblol/mil returned 47,372 bytes. Their siblings do not: /api/geocode, /api/weather and /api/transit answer 405, and /api/overpass is deliberately POST-only. These three are read-only query proxies, so they now answer 405 to anything but GET, using the wording and shape /api/geocode already uses. The refusal comes first, before rate limiting and before any upstream call, which the tests assert by stubbing fetch to throw. Scope, stated plainly: this is a consistency and intent fix. No rate limiter was being bypassed — /api/route already limits every request regardless of verb, and the two aircraft routes have no limiter at all, only 9s and 12s server-side caches that bound upstream cost for any method. One existing fixture in src/search/geospatial.test.mjs built a request without a method, which a real Node request always has; it now sets GET. Verified on a running server after the change: GET still 200 on all three, POST 405 on all three, /api/overpass and the other proxies unchanged. Suite goes from 5090 to 5092 tests, 0 failures. --- docs/CURRENT-STATE.md | 1 + server/providers/aircraft/adsb-lol.js | 7 +++++++ server/providers/aircraft/opensky.js | 8 ++++++++ server/providers/places/routes.js | 7 +++++++ src/search/geospatial.test.mjs | 6 +++++- src/tooling/liveProviders.test.mjs | 23 +++++++++++++++++++++++ src/tooling/placeProviders.test.mjs | 19 +++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..15e159ac8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..80140bb56 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -68,6 +68,13 @@ export function adsbLolProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/adsblol/mil', async (req, res) => { + // Read-only snapshot endpoint; match the method contract its sibling + // query proxies already declare instead of serving every verb alike. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const now = Date.now(); if (_cache && now - _cacheAt < CACHE_MS) { diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..aa0dad268 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -350,6 +350,14 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { export function openSkyProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/opensky', async (req, res) => { + // Read-only snapshot endpoint: state the method contract the way + // /api/geocode and the other query proxies state it, rather than + // answering any verb with the same body and ignoring the request body. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const requestedMode = normalizeOpenSkyAuthMode( process.env.OPENSKY_AUTH_MODE, diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 42e24ad32..8e837fcd4 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -227,6 +227,13 @@ export function installRouteMiddleware( // only shape that asks the upstream for them. A response WITHOUT steps is // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { + // The documented shape above is a GET with query parameters; say so, + // the way the other query proxies do, instead of answering any verb. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } const fail = (msg) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: msg })); diff --git a/src/search/geospatial.test.mjs b/src/search/geospatial.test.mjs index be846bb36..dbedbe53f 100644 --- a/src/search/geospatial.test.mjs +++ b/src/search/geospatial.test.mjs @@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo const request = async (query) => { let payload; await handler( - { url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } }, + { + url: `/?${query}`, + method: 'GET', + socket: { remoteAddress: '127.0.0.1' }, + }, { writeHead() {}, end(body) { diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..c25c445d4 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -381,3 +381,26 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => { + // Both routes answered every verb with the full snapshot, unlike the query + // proxies beside them, so nothing said whether a body was read or ignored. + // A stubbed fetch that throws proves the refusal happens before the handler + // reaches upstream, rather than after paying for the work and discarding it. + t.mock.method(globalThis, 'fetch', async (url) => { + throw new Error(`upstream must not be reached: ${url}`); + }); + const cases = [ + [install(providers.openSkyProxy()), '/api/opensky'], + [install(providers.adsbLolProxy()), '/api/adsblol/mil'], + ]; + for (const [request, route] of cases) { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request(route, '/', method); + assert.equal(response.statusCode, 405, `${method} ${route}`); + assert.deepEqual(JSON.parse(response.body), { + error: 'Method Not Allowed', + }); + } + } +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 78d311369..968c5a9fa 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -482,3 +482,22 @@ test('the upstream host is pinned: a redirect is refused, not followed', async ( await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01'); assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); }); + +test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => { + // The documented shape is a GET with query parameters, but every verb was + // answered alike. Refuse first: a stubbed fetch that throws shows the + // rejection costs neither an upstream call nor a rate-limit slot. + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('upstream must not be reached'); + }); + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + method, + ); + assert.equal(response.statusCode, 405, method); + assert.deepEqual(response.body, { error: 'Method Not Allowed' }); + } +}); From fb6b4e34f0aab85c715146739152d753d5a3efa8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 13:16:17 +0700 Subject: [PATCH 003/113] docs: changelog entry for the read-only proxy method contract (#794) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..7885f0131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- The read-only query proxies state their method contract. `/api/opensky`, + `/api/adsblol/mil` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a From 2e5dfce1af9b07664005bd1bd77e5bcb4ccf0ba4 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Tue, 29 Sep 2026 10:56:20 +0700 Subject: [PATCH 004/113] fix(server): declare Allow: GET on the new 405 responses RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts, and /api/radio/stations already does this in-tree. Thanks to kvnloo for catching it on review. The same gap on the pre-existing handlers is tracked in #809 and is deliberately left alone here. --- docs/CURRENT-STATE.md | 2 +- server/providers/aircraft/adsb-lol.js | 5 ++++- server/providers/aircraft/opensky.js | 5 ++++- server/providers/places/routes.js | 2 +- src/tooling/liveProviders.test.mjs | 3 +++ src/tooling/placeProviders.test.mjs | 2 ++ 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 15e159ac8..6e3039920 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,7 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did, declaring `Allow: GET` with it as `/api/radio/stations` does. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 80140bb56..1e5565832 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -71,7 +71,10 @@ export function adsbLolProxy() { // Read-only snapshot endpoint; match the method contract its sibling // query proxies already declare instead of serving every verb alike. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index aa0dad268..679548300 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -354,7 +354,10 @@ export function openSkyProxy() { // /api/geocode and the other query proxies state it, rather than // answering any verb with the same body and ignoring the request body. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 8e837fcd4..2c67f1f5c 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -230,7 +230,7 @@ export function installRouteMiddleware( // The documented shape above is a GET with query parameters; say so, // the way the other query proxies do, instead of answering any verb. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index c25c445d4..856a528a2 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -398,6 +398,9 @@ test('the aircraft snapshot routes decline a non-GET before any upstream work', for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { const response = await request(route, '/', method); assert.equal(response.statusCode, 405, `${method} ${route}`); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it does + // accept, which is what `/api/radio/stations` already does in-tree. + assert.equal(response.headers.allow, 'GET', `${method} ${route}`); assert.deepEqual(JSON.parse(response.body), { error: 'Method Not Allowed', }); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 968c5a9fa..afc323efc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -498,6 +498,8 @@ test('the routing proxy declines a non-GET before rate limiting or upstream', as method, ); assert.equal(response.statusCode, 405, method); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts. + assert.equal(response.headers.allow, 'GET', method); assert.deepEqual(response.body, { error: 'Method Not Allowed' }); } }); From daed0be99a03d8f416c59617f77c7cea455616eb Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:17:20 +0700 Subject: [PATCH 005/113] 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 006/113] 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 007/113] 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 d3370d6bc4ce5f264fd29eece459052e6565cb1a Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:40 +0700 Subject: [PATCH 008/113] docs(changelog): move the entry into a changelog.d fragment Main now takes pending entries as changelog.d/-.md files (#787), so the entry leaves CHANGELOG.md for changelog.d/794-api-method-guard.md. --- changelog.d/794-api-method-guard.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/794-api-method-guard.md diff --git a/changelog.d/794-api-method-guard.md b/changelog.d/794-api-method-guard.md new file mode 100644 index 000000000..530be060e --- /dev/null +++ b/changelog.d/794-api-method-guard.md @@ -0,0 +1,7 @@ +- The read-only query proxies state their method contract. `/api/flights`, + `/api/military` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). From 69777700674fa5f4dfcc3729a553b96a9a91ffe0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:42 +0700 Subject: [PATCH 009/113] 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 586ed4f8ea7f89ff24f82635419cee2e618b8856 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:04 +0100 Subject: [PATCH 010/113] fix(tools): bound untrusted display text at output boundaries --- docs/CURRENT-STATE.md | 7 ++++++ scripts/package-boundaries.json | 6 +++++ src/data/alprCameras.test.mjs | 21 +++++++++++++++++ src/data/displayText.js | 12 ++++++++++ src/layers/alpr/records.js | 3 ++- src/tools/queries/surveillance.js | 11 +++++---- src/tools/queries/surveillance.test.mjs | 31 +++++++++++++++++++++++++ src/voice/gevActions.js | 19 +++++++-------- src/voice/gevActions.test.mjs | 13 +++++++++++ 9 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 src/data/displayText.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..b3a48f66e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +External ALPR tags, surveillance-tool display fields, voice context text and +compact analyst strings collapse controls and whitespace before a final +180-character cap (scene labels retain their 120-character cap). Empty ALPR +values remain null and Unicode text is preserved without splitting a surrogate +pair. These fields remain untrusted source data in structured tool output; text +normalization does not establish trust or eliminate prompt injection. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..859b030e7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1450,6 +1450,7 @@ "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", "src/data/inputOwnership.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/data/tomtomTiles.js", "src/layers/alpr/tileRecords.js", @@ -2228,6 +2229,7 @@ "src/ui/visualSettings.js", "src/voice/commands.js", "src/voice/control.js", + "src/data/displayText.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", "src/voice/speech.js", @@ -2914,6 +2916,7 @@ "src/search/coordinateParser.js", "src/search/presetGeocoder.js", "src/app/localGeojsonServices.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", "src/app/layers/perimeters.js", @@ -3407,6 +3410,7 @@ "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/alpr/source.js", "src/data/tomtomTiles.js", @@ -3817,6 +3821,7 @@ "src/i18n/marker.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", + "src/data/displayText.js", "src/tools/queries/surveillance.js", "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", @@ -3923,6 +3928,7 @@ "src/layers/submarineCables/bundledSource.js", "src/layers/alpr/source.js", "src/layers/alpr/policy.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/alpr/tileRecords.js", "src/sources/infrastructureData.js", diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..e68b11da3 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1493,3 +1493,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set h.restore(); } }); + +test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => { + const record = normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { + 'surveillance:type': 'ALPR', + operator: ' Şehir\n\u0000 PD ', + manufacturer: '😀'.repeat(200), + 'camera:type': null, + 'surveillance:zone': '\t traffic zone ', + }, + }); + assert.equal(record.operator, 'Şehir PD'); + assert.equal(record.manufacturer.length, 180); + assert.equal(record.manufacturer, '😀'.repeat(90)); + assert.equal(record.cameraType, null); + assert.equal(record.zone, 'traffic zone'); +}); diff --git a/src/data/displayText.js b/src/data/displayText.js new file mode 100644 index 000000000..1138c4cc0 --- /dev/null +++ b/src/data/displayText.js @@ -0,0 +1,12 @@ +/** Bounded, single-line external display text; it remains untrusted source data. */ +export function normalizeDisplayText(value, maxLength = 180) { + if (value == null || typeof value === 'object' || typeof value === 'function') + return ''; + return String(value) + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength) + .replace(/[\uD800-\uDBFF]$/, '') + .trim(); +} diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..d8d2ac104 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -1,7 +1,8 @@ +import { normalizeDisplayText } from '../../data/displayText.js'; import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js'; export function textTag(value) { - const t = String(value ?? '').trim(); + const t = normalizeDisplayText(value); return t || null; } diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 06723007a..4ddafabc2 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -1,5 +1,6 @@ /** Automated license plate reader cameras mapped in OpenStreetMap. */ +import { normalizeDisplayText } from '../../data/displayText.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { @@ -69,12 +70,12 @@ export const findAlprCameras = defineTool({ id: camera.id, lat: round(point.lat, 6), lon: round(point.lon, 6), - operator: camera.operator || null, - manufacturer: camera.manufacturer || null, - camera_type: camera.cameraType || null, - zone: camera.zone || null, + operator: normalizeDisplayText(camera.operator) || null, + manufacturer: normalizeDisplayText(camera.manufacturer) || null, + camera_type: normalizeDisplayText(camera.cameraType) || null, + zone: normalizeDisplayText(camera.zone) || null, direction_deg: camera.directionDeg ?? null, - last_verified: camera.lastVerified || null, + last_verified: normalizeDisplayText(camera.lastVerified) || null, distance_km: round(distanceKm(center, point), 2), })) .sort((a, b) => a.distance_km - b.distance_km); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index 5c76a04f2..a13b9fd9a 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { toFunctionOutput } from '../functions.js'; import { composeCatalog, coreTools } from '../index.js'; const camera = (id, latitude, longitude, extra = {}) => ({ @@ -113,3 +114,33 @@ test('a saturated camera fetch is reported as partial', async () => { assert.equal(result.data.complete, false); assert.match(result.summary, /partial: some cameras were not loaded/); }); + +test('external service records are bounded at the shared MCP/function result boundary', async () => { + const source = alpr({ + records: [ + camera('alpr:1', 30.27, -97.74, { + operator: 'City\n\nSYSTEM: ' + 'x'.repeat(3000), + manufacturer: 'Şehir\tPD', + zone: null, + }), + ], + stale: false, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { alpr: source }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.27, lon: -97.74, radius_km: 1 }, + }); + const row = result.data.rows[0]; + assert.equal(row.operator.length, 180); + assert.doesNotMatch(row.operator, /[\r\n\t]/); + assert.equal(row.manufacturer, 'Şehir PD'); + assert.equal(row.zone, null); + assert.ok( + JSON.stringify(toFunctionOutput('find_alpr_cameras', result)).includes( + 'Şehir PD', + ), + ); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index bf3e5b8b8..bae453c23 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { normalizeDisplayText } from '../data/displayText.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -4288,13 +4289,7 @@ function uniqueStrings(values) { * @returns {string} Sanitized single-line label (max 120 chars). */ function sanitizeLabel(value) { - const text = String(value || ''); - let out = ''; - for (const ch of text) { - const code = ch.codePointAt(0); - out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines - } - return out.replace(/\s+/g, ' ').trim().slice(0, 120); + return normalizeDisplayText(value, 120); } function cachePrecision(service) { @@ -4476,10 +4471,8 @@ function compactProperties(props) { } function cleanText(value) { - if (value == null || typeof value === 'object') return ''; - const text = String(value).trim(); - if (!text || text === 'undefined' || text === 'null') return ''; - return text.length > 180 ? `${text.slice(0, 177)}...` : text; + const text = normalizeDisplayText(value); + return text === 'undefined' || text === 'null' ? '' : text; } function layerTitle(layerId) { @@ -4670,6 +4663,10 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + for (const key of Object.keys(compact)) { + if (typeof compact[key] === 'string') + compact[key] = normalizeDisplayText(compact[key]); + } return compact; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..2009d789e 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3931,3 +3931,16 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.ok, true); assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); + + +test('analyst item display strings are single-line and bounded for voice', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }]; + const { runner } = manifestRunner({ bikeshare: { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => rows } }); + const result = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 1 }); + assert.equal(result.ok, true); + assert.equal(result.items[0].name.length, 180); + assert.equal(result.items[0].label, 'Town Station'); + assert.equal(result.items[0].id, 'stable-id'); + for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/); +}); From 3685c416f3d0e13a2e19e9e70e777d3723b323cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:41 +0100 Subject: [PATCH 011/113] docs: record text fix --- changelog.d/1280-text-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1280-text-issue-fix.md diff --git a/changelog.d/1280-text-issue-fix.md b/changelog.d/1280-text-issue-fix.md new file mode 100644 index 000000000..d13f3f9cc --- /dev/null +++ b/changelog.d/1280-text-issue-fix.md @@ -0,0 +1 @@ +- Bound untrusted display text at output boundaries (asturksever, #1280). From cbca6a228d83ff13a19a9f20bd9003a4eefd3d3a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:31:18 +0100 Subject: [PATCH 012/113] fix(voice): keep canonical analyst IDs out of display-text normalization compactAnalystItem normalized every string field, including `id`. Bikeshare uses the raw station name as its ID, so a station with repeated spaces or a name over 180 code units was registered as a referent under a different key and a numbered fly_to_location reported it as no longer loaded. Normalize display fields only; layerKey, id, icao24, mmsi, noradId, registration and callsign pass through as the layer reports them. Adds an analyst_query -> referent -> fly_to_location test for repeated-space and long station names, and documents the rule in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 +++++-- src/voice/gevActions.js | 20 +++++++++++++++++- src/voice/gevActions.test.mjs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 721caabd4..0e875c1ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,8 +13,12 @@ External ALPR tags, surveillance-tool display fields, voice context text and compact analyst strings collapse controls and whitespace before a final 180-character cap (scene labels retain their 120-character cap). Empty ALPR values remain null and Unicode text is preserved without splitting a surrogate -pair. These fields remain untrusted source data in structured tool output; text -normalization does not establish trust or eliminate prompt injection. +pair. Only display fields are normalized: canonical analyst identifiers +(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`) +pass through unchanged, because bikeshare uses the raw station name as its ID +and numbered voice references resolve by that key. These fields remain +untrusted source data in structured tool output; text normalization does not +establish trust or eliminate prompt injection. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 308736ed5..71610992c 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4570,6 +4570,21 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([ 'noradId', ]); +/** + * Analyst item keys that identify a record rather than describe it: the + * referent registry, track_entity and locateLayerEntity match on them, so + * display-text normalization must leave them byte-for-byte unchanged. + */ +const ANALYST_CANONICAL_FIELDS = new Set([ + 'layerKey', + 'id', + 'icao24', + 'mmsi', + 'noradId', + 'registration', + 'callsign', +]); + /** * Compact one analyst record for the voice payload: identity, position (so a * follow-up can fly to it), distance, and that layer's manifest fields with @@ -4607,8 +4622,11 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + // Display text only: canonical keys are lookup identities (bikeshare uses + // the raw station name as its id), so the referent registry and + // locateLayerEntity must see them exactly as the layer reports them. for (const key of Object.keys(compact)) { - if (typeof compact[key] === 'string') + if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key)) compact[key] = normalizeDisplayText(compact[key]); } return compact; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 2009d789e..5367bd2f3 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3944,3 +3944,43 @@ test('analyst item display strings are single-line and bounded for voice', async assert.equal(result.items[0].id, 'stable-id'); for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/); }); + +test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + const referents = createReferentRegistry(); + const longName = 'Station ' + 'é'.repeat(200); + const spaced = 'Congress Ave & 2nd'; + const stations = [ + { id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 }, + { id: longName, name: longName, lat: 30.2711, lon: -97.7437 }, + ]; + const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations }; + const runner = createActionRunner({ + viewer, styleManager, + dataManager: { + layers: new Map([['bikeshare', { module: bikeshare }]]), + isEnabled: () => true, + getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }], + }, + deixis: { referents }, + }); + const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 }); + assert.equal(listed.ok, true); + const bySpaced = listed.items.find((item) => item.id === spaced); + const byLong = listed.items.find((item) => item.id === longName); + assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs'); + assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized'); + assert.equal(byLong.name.length, 180, 'display name is still bounded'); + assert.equal(byLong.id.length, longName.length); + const registered = referents.list(); + assert.equal(registered.length, 2, 'the analyst result became the numbered list'); + for (const ref of registered) { + const station = stations.find((row) => row.id === ref.id); + assert.ok(station, `referent ${ref.n} keeps the canonical ID`); + const flown = await runner('fly_to_location', { referent: ref.n }); + assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`); + assert.equal(flown.latitude, station.lat); + assert.equal(flown.longitude, station.lon); + } +}); From bbfb23a765e0bc2927993a4884317f38f92fa4bd Mon Sep 17 00:00:00 2001 From: BreezeDelegate <271538431+BreezeDelegate@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:04:43 +0200 Subject: [PATCH 013/113] fix(ci): run allocation gates on Node 26 --- changelog.d/766-node26-allocation-gates.md | 1 + docs/CURRENT-STATE.md | 17 +++++++------- scripts/qa-l9-matrix.mjs | 24 ++++++++++---------- scripts/run-unit-tests.mjs | 22 +++++++++--------- src/data/focusAllocations.test.mjs | 2 +- src/overlays/worldOverlayAllocation.test.mjs | 2 +- src/qaL9MatrixVerdicts.test.mjs | 3 ++- src/unitTestRunner.test.mjs | 23 ++++++++----------- 8 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 changelog.d/766-node26-allocation-gates.md diff --git a/changelog.d/766-node26-allocation-gates.md b/changelog.d/766-node26-allocation-gates.md new file mode 100644 index 000000000..a0fcb6a9f --- /dev/null +++ b/changelog.d/766-node26-allocation-gates.md @@ -0,0 +1 @@ +- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (#39). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d49d92238..a7c2e2f7f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3148,9 +3148,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > style-switch persistence state. Browser/GPU performance comparison remains > operator-side; the final accounting records only comparable population > reductions and deterministic Node allocation/solve measurements. Those -> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits -> supported product runtimes on Node 24 or 26, while the allocation runner -> separately enforces Node 24 for these two calibrated probes. Each probe +> allocation gates are calibrated on both supported Node majors (24 and 26); +> `package.json` permits those same product runtimes, and the existing CI matrix +> executes the two calibrated probes on both entries. Each probe > compiles synchronously and discards explicit-GC > transition chunks before applying the unchanged byte ceilings. The unit > runner executes ordinary test files with Node's default parallelism, then @@ -4987,12 +4987,11 @@ and unreachable upstream (502/504) separately from road geometry. > **Reading `npm test` totals:** the count depends on the Node major. The two > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` -> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the -> calibrated Node 24 runtime; on any other major the runner skips both files and -> their 14 tests are absent from the total. A branch total quoted without its -> Node version is therefore not reproducible. As of the fly_route cinematic -> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on -> `main` + 26 route pins; the same tree on Node 24 reports 2,295. +> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both +> supported Node 24 and Node 26. Unsupported majors may still skip those files +> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI +> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25 +> runs can therefore remain lower because their 14 allocation tests were skipped. - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 8709366de..d36bab9cb 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -239,11 +239,11 @@ function satisfiesEngines(version, range) { * re-resolve the interpreter from PATH and hand the gate a different Node than * the one the check believes it selected. * @param {string} version e.g. "v24.19.0" - * @returns {boolean} true only for a parseable Node 24. + * @returns {boolean} true for a parseable supported Node 24 or 26. */ function isCalibratedAllocationRuntime(version) { const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10)); - return maj === 24; + return maj === 24 || maj === 26; } /** @@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); }; // ─── A · REPO GATES ─────────────────────────────────────────────────────── check({ - id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)', + id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)', run: async () => { const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')); const range = pkg.engines?.node || '(unset)'; @@ -557,7 +557,7 @@ check({ if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`); // An off-range runtime is an environment problem, not a product defect — // but it silently disables the allocation gate, so say so loudly. A3 then - // runs that gate under a discovered Node 24. + // runs that gate under a discovered calibrated Node 24 fallback. return env.node24 ? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV') : fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`); @@ -579,13 +579,13 @@ check({ }); check({ - id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)', + id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors', run: async () => { // npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still - // exits 0 when the runtime is not Node 24. A green suite is therefore NOT - // proof the gate ran. Force it, under Node 24 when one is discoverable. + // exits 0 when the runtime is outside the calibrated supported majors. A green + // suite is therefore NOT proof the gate ran. Force it under a calibrated runtime. // - // ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm + // ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm // re-resolves the interpreter from PATH and can land back on the system // Node even when THIS process is already 24: running the whole matrix under // `mise exec node@24.19.0 --`, the npm shell-out still re-execed system @@ -597,10 +597,10 @@ check({ const v = await sh(bin, ['--version'], { timeoutMs: 60000 }); const version = (v.out || '').trim(); if (!isCalibratedAllocationRuntime(version)) { - // run-unit-tests.mjs refuses to measure calibrated budgets off Node 24, + // run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors, // so a wrong binary means the gate never ran. That is this check failing // to establish its own claim — not evidence about the product. - return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`); + return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`); } const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], { timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' }, @@ -611,9 +611,9 @@ check({ }; const [maj] = process.versions.node.split('.').map(Number); - if (maj === 24) return runGate(process.execPath, 'this runtime'); + if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime'); if (env.node24) return runGate(env.node24.bin, env.node24.label); - return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN'); + return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN'); }, }); diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 3712df28e..29c3e2b00 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/overlays/worldOverlayAllocation.test.mjs', ]); -/** Whether this runtime matches the one the allocation budgets were calibrated on. */ +/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */ export function isCalibratedAllocationRuntime(version = process.versions.node) { - return Number.parseInt(String(version).split('.')[0], 10) === 24; + const major = Number.parseInt(String(version).split('.')[0], 10); + return major === 24 || major === 26; } -/** Require the runtime on which allocation budgets were calibrated. */ -export function assertNode24AllocationRuntime(version = process.versions.node) { +/** Require a runtime on which allocation budgets are calibrated. */ +export function assertCalibratedAllocationRuntime(version = process.versions.node) { if (!isCalibratedAllocationRuntime(version)) { - throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`); + throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`); } return version; } @@ -86,19 +87,18 @@ export function runUnitTests() { const parallelStatus = runTests(['--test', ...plan.parallel]); if (parallelStatus !== 0) return parallelStatus; - // The GC-bracketed budgets are calibrated on Node 24 and are meaningless on - // other allocators. A contributor's suite must stay green on any supported - // engine (package.json permits >=24), so uncalibrated runtimes skip the + // The GC-bracketed budgets are calibrated on every supported Node major. + // Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the // probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI / // release batteries) to make an uncalibrated runtime a hard failure. if (!isCalibratedAllocationRuntime()) { if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') { - assertNode24AllocationRuntime(); + assertCalibratedAllocationRuntime(); } console.warn( `[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: ` - + `budgets are calibrated for Node 24, running ${process.versions.node}. ` - + 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', + + `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. ` + + 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', ); return 0; } diff --git a/src/data/focusAllocations.test.mjs b/src/data/focusAllocations.test.mjs index 86f28db18..f58e5f121 100644 --- a/src/data/focusAllocations.test.mjs +++ b/src/data/focusAllocations.test.mjs @@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs' */ test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => { if (!isCalibratedAllocationRuntime()) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const result = spawnSync( process.execPath, diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..70197097a 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') { for (const workload of WORKLOADS) { test(`steady moving-source frames stay in budget ${workload.name}`, (t) => { if (!CALIBRATED_ALLOCATION_RUNTIME) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const payload = runAllocationProbe(workload.entries, workload.profile); diff --git a/src/qaL9MatrixVerdicts.test.mjs b/src/qaL9MatrixVerdicts.test.mjs index 867e34410..2ec826e8d 100644 --- a/src/qaL9MatrixVerdicts.test.mjs +++ b/src/qaL9MatrixVerdicts.test.mjs @@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () => // A full pass run under `mise exec node@24 --` still re-execed system Node 25 // through npm; the forced gate refused ("received 25.6.1") and A3 reported it // as a product failure. A3 now invokes the binary directly and checks it. -test('only a parseable Node 24 counts as the calibrated allocation runtime', () => { +test('every supported Node major counts as a calibrated allocation runtime', () => { assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional'); + assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true); assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case'); assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false); for (const junk of ['', ' ', 'node', undefined, null]) { diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 80d9091e0..e206726e8 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -7,7 +7,7 @@ import { ALLOCATION_TEST_FILES, UNIT_TEST_ROOTS, allocationTestArgs, - assertNode24AllocationRuntime, + assertCalibratedAllocationRuntime, buildUnitTestPlan, discoverUnitTestFiles, isCalibratedAllocationRuntime, @@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => { } }); -test('allocation runtime calibration is explicit and pinned to Node 24', () => { - assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); +test('allocation runtime calibration covers both supported Node majors', () => { + assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0'); assert.throws( - () => assertNode24AllocationRuntime('22.23.1'), - /calibrated Node 24 runtime/, - ); - assert.throws( - () => assertNode24AllocationRuntime('26.0.0'), - /calibrated Node 24 runtime/, + () => assertCalibratedAllocationRuntime('22.23.1'), + /calibrated Node 24 or 26/, ); + assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0'); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('22.23.1'), false); - assert.equal(isCalibratedAllocationRuntime('26.3.0'), false); + assert.equal(isCalibratedAllocationRuntime('26.3.0'), true); }); -test('npm test stays green on every supported engine, not only the calibrated one', () => { +test('npm test calibrates both supported engines while retaining off-range warnings', () => { // package.json wiring: `npm test` must invoke this runner, and the engines // range it advertises must not be narrower than what the runner tolerates. const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs'); const enginesNode = String(pkg.engines?.node || ''); assert.ok(enginesNode, 'engines.node must be declared'); - // The runner throws for uncalibrated runtimes ONLY behind the explicit - // opt-in env; by default it skips, so a supported non-24 engine cannot fail. + // Unsupported runtimes only skip probes without the explicit opt-in hard gate. + // Node 26 is calibrated and runs those probes with the same budgets. const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8'); assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/); assert.match(runner, /SKIPPED .*allocation microbenchmarks/); From ae55963a0e5638b0aa2e2a90d7a66d8f10a40597 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:10:06 -0700 Subject: [PATCH 014/113] fix(tools): wrap whole-degree headings at 360 to 0 The query tools rounded courses, headings, bearings and azimuths with Number(value.toFixed(0)) and no wrap, so a direction of 359.5 degrees or more came back as 360: find_vessel with course 359.7 and heading 359.6 returned course_deg 360 and heading_deg 360. Round them through the shared roundHeadingDegrees helper the layers and UI already use (src/data/headingDegrees.js), which wraps into 0 to 359: course_deg and heading_deg (vessels), course_deg (aircraft), bearing_deg (transit), heading_deg (cameras), and rise_azimuth_deg and azimuth_deg (satellites). Other rounding is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++++- src/tools/queries/aviation.js | 3 ++- src/tools/queries/aviation.test.mjs | 13 +++++++++++++ src/tools/queries/maritime.js | 5 +++-- src/tools/queries/maritime.test.mjs | 19 +++++++++++++++++++ src/tools/queries/media.js | 3 ++- src/tools/queries/media.test.mjs | 14 ++++++++++++++ src/tools/queries/mobility.js | 3 ++- src/tools/queries/mobility.test.mjs | 17 +++++++++++++++++ src/tools/queries/space.js | 5 +++-- src/tools/queries/space.test.mjs | 18 ++++++++++++++++++ 11 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..35db1b1bb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -204,7 +204,10 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft +`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite +`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a +direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 153e36e03..7c988c5ea 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -19,6 +19,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const ICAO24_SCHEMA = Object.freeze({ type: 'string', @@ -75,7 +76,7 @@ function aircraftRow(record, from) { altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), on_ground: record.onGround === true, speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), vertical_rate_mps: round(record.verticalRateMps, 1), type_code: record.typeCode || null, registration: record.registration || null, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 484719790..59a928672 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async () ); }); +test('an aircraft course that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed([ + record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }), + ]), + }, + }); + const result = await catalog.call('find_aircraft', { callsign: 'N360' }); + assert.equal(result.data.rows[0].course_deg, 0); +}); + test('aircraft are found by exactly one identifier across feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5798a0936..0e9d80373 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ @@ -57,8 +58,8 @@ function vesselRow(record, from) { lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), - heading_deg: round(record.headingDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), + heading_deg: roundHeadingDegrees(record.headingDeg), observed_at: isoTime(record.observedAtMs), ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d86bdf352..f89e6bce3 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () => ); }); +test('a vessel course or heading that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000003', 37.78, -122.4, { + courseDeg: 359.7, + headingDeg: 359.6, + }), + ], + }), + }, + }); + const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data + .rows; + assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]); +}); + test('a destination with no letters counts as unknown', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a03e88bb4..d154619db 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -10,6 +10,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_FRAME_BYTES = 3 * 1024 * 1024; @@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({ provider: text(camera.provider), lat: round(camera.lat, 6), lon: round(camera.lon, 6), - heading_deg: round(camera.headingDeg, 0), + heading_deg: roundHeadingDegrees(camera.headingDeg), feed_type: text(camera.feedType), credit: text(camera.credit), distance_km: round(distanceKm(center, camera), 2), diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index 28c037a57..f690966c5 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => { assert.equal(result.data.rows[1].credit, null); }); +test('a camera heading that rounds to 360 reads as 0', async () => { + const source = { + async getCatalog() { + return { sources: [{ ...cameras[0], headingDeg: 359.6 }] }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const result = await catalog.call('find_cctv_cameras', { area: downtown }); + assert.equal(result.data.rows[0].heading_deg, 0); +}); + test('a snapshot returns the frame as an image with its credit', async () => { const source = cctv(); const catalog = composeCatalog({ diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0a57ae3ae..c31f03863 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -19,6 +19,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const MAX_SYSTEMS = 3; @@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({ trip_id: vehicle.tripId ?? null, lat: vehicle.lat, lon: vehicle.lon, - bearing_deg: round(vehicle.bearing, 0), + bearing_deg: roundHeadingDegrees(vehicle.bearing), speed_mps: round(vehicle.speedMps, 1), status: vehicle.status ?? null, occupancy: vehicle.occupancy ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 0ea882727..aaaa97bad 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.equal(rapid.data.rows[0].id, 'v2'); }); +test('a transit bearing that rounds to 360 reads as 0', async () => { + const north = [{ ...vehicles[0], bearing: 359.6 }]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + transit: transit({ + ok: true, + status: 200, + json: async () => ({ vehicles: north }), + }), + clock, + }, + }); + const result = await catalog.call('get_transit_vehicles', { area: downtown }); + assert.equal(result.data.rows[0].bearing_deg, 0); +}); + test('transit reports uncovered areas and unavailable feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ef59c0be5..a589e801c 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({ peak: isoTime(pass.maxElevMs), set: isoTime(pass.setMs), max_elevation_deg: round(pass.maxElevDeg, 1), - rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg), rise_direction: compass(pass.riseAzDeg), visible: pass.visible, }, @@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({ name: entry.name, norad: entry.norad, elevation_deg: round(look.elevDeg, 1), - azimuth_deg: round(look.azDeg, 0), + azimuth_deg: roundHeadingDegrees(look.azDeg), direction: compass(look.azDeg), }, ] diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0eb5ce346..0e44fe208 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -296,6 +296,24 @@ test('satellites overhead are those above the elevation, highest first', async ( ); }); +test('satellite azimuths that round to 360 read as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + const pass = await catalog.call('next_satellite_pass', { + location: { lat: 10, lon: 30 }, + }); + assert.equal(pass.data.pass.rise_azimuth_deg, 0); + assert.equal(pass.data.pass.rise_direction, 'N'); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 39.25, lon: 65.75 }, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.equal(overhead.data.rows[0].azimuth_deg, 0); +}); + test('stale orbit and launch data are reported', async () => { const stale = { readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }), From d1ed2e8e6f14d0c6e23c4b9f51949621d498dad6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:49:55 -0800 Subject: [PATCH 015/113] changelog: tool headings wrap at 360 (#1524) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1524-tools-heading-wrap.md diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md new file mode 100644 index 000000000..3aefce6c5 --- /dev/null +++ b/changelog.d/1524-tools-heading-wrap.md @@ -0,0 +1,4 @@ +- The agent and voice query tools report a direction of 359.5° or more as 0 + instead of 360: vessel course and heading, aircraft course, transit bearing, + camera heading and satellite azimuths now round through the shared heading + helper the layers use (@quanticsoul4772, #1524). From ccd83c5fa2ffdd9ad4f0564c4ec95976330d6109 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:27 -0800 Subject: [PATCH 016/113] docs(tools): say heading values wrap into 0-359, not that 359.5 and up read 0 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md index 3aefce6c5..2a630f600 100644 --- a/changelog.d/1524-tools-heading-wrap.md +++ b/changelog.d/1524-tools-heading-wrap.md @@ -1,4 +1,4 @@ -- The agent and voice query tools report a direction of 359.5° or more as 0 - instead of 360: vessel course and heading, aircraft course, transit bearing, - camera heading and satellite azimuths now round through the shared heading - helper the layers use (@quanticsoul4772, #1524). +- The agent and voice query tools report a direction that rounds to 360 as 0: + vessel course and heading, aircraft course, transit bearing, camera heading + and satellite azimuths now round through the shared heading helper the + layers use, which wraps into 0–359 (@quanticsoul4772, #1524). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35db1b1bb..ecbdf5e0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -207,7 +207,7 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a -direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also +direction that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; 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 017/113] 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 018/113] 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 84c1fef8d34dfd4dd556b6dca6bda9e3d2cf6b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:15:34 -0300 Subject: [PATCH 019/113] fix(firms): stop requesting the retired Suomi NPP source NASA stopped delivering Suomi NPP data on 2026-11-02 (FIRMS news page). With a live MAP_KEY on 2026-11-04, VIIRS_SNPP_NRT came back ok with zero rows while NOAA-20, NOAA-21 and MODIS returned 57k, 49k and 11k, so the source only spent one transaction per refresh. The proxy now requests NOAA-20, NOAA-21 and MODIS NRT. The "N" satellite label stays, so cached or archived Suomi NPP rows still read SNPP. Tests count sources from the list instead of assuming four, and a regression test keeps the retired source out of the production list. Refs #849 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 7 ++++--- docs/CURRENT-STATE.md | 5 +++++ server/providers/firms.js | 14 ++++++-------- src/data/firmsProxy.test.mjs | 18 ++++++++++++++---- src/tooling/environmentProviders.test.mjs | 4 ++-- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index cac0b5ab7..efee6b9ff 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -273,11 +273,12 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons > We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS). FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the -`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21, -Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY +`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21) +and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. -The former bundled 2026-05-25 snapshot was removed 2026-07-16. +The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`) +is no longer requested: NASA stopped delivering its data on 2026-11-02. ### Natural Earth physical regions (`natural_earth/`) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e868dfc95..102efb997 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2325,6 +2325,11 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and +Aqua). It no longer requests VIIRS Suomi NPP: NASA stopped delivering Suomi NPP +data on 2026-11-02, and the source returned no rows while still costing one +MAP_KEY transaction per refresh. + 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. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times diff --git a/server/providers/firms.js b/server/providers/firms.js index 20a2f4bab..51ea4fc56 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -131,8 +131,8 @@ export async function fetchFirms( * NASA FIRMS live active-fire proxy with a memory + disk cache. * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * - * Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent - * satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, + * Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no + * cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, * ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy) * with `days=2` (`days=1` means "current UTC day", nearly empty just after * 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS @@ -141,6 +141,9 @@ export async function fetchFirms( * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream * fetch across dev-server restarts. Pattern mirrors celestrakProxy. * + * VIIRS_SNPP_NRT is not requested: NASA stopped delivering Suomi NPP data on + * 2026-11-02, so that source only spent a transaction per refresh (#849). + * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} @@ -153,12 +156,7 @@ export async function fetchFirms( export function firmsProxy() { const TTL_MS = 30 * 60_000; const STATUS_TTL_MS = 5 * 60_000; - const SOURCES = [ - 'VIIRS_NOAA20_NRT', - 'VIIRS_NOAA21_NRT', - 'VIIRS_SNPP_NRT', - 'MODIS_NRT', - ]; + const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; /** * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 14668c825..135002ca7 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT']; +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -63,7 +63,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => { if (source === SOURCES[1]) throw new Error('upstream unavailable'); return [recent]; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 1 ? 0 : 1, ok: index !== 1, }))); @@ -77,7 +77,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy // Fault injection for aggregation; ordinary parsed CSV returns an array. return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 0 ? 0 : 1, ok: index !== 0, }))); @@ -107,7 +107,7 @@ function wrappedAddressSelectionFailure() { { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner }); return new TypeError('fetch failed', { cause }); } -const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2'; +const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2'; test('FIRMS failures log the socket cause, never the keyed URL', () => { const described = describeFetchError(addressSelectionFailure()); @@ -307,3 +307,13 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => assert.ok(calls[0][2] instanceof AbortSignal); assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline'); }); + +test('FIRMS requests only the satellites NASA still delivers', () => { + const listed = config.match(/const SOURCES = \[([^\]]*)\];/); + assert.ok(listed, 'FIRMS source list exists'); + const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); + assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); + // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it + // returns no rows and still spends a MAP_KEY transaction on every refresh. + assert.ok(!production.includes('VIIRS_SNPP_NRT')); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index da3cfc335..feca9432d 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.equal(json(await request()).count, 130001); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, From b342e405d7514505861ab26244f38496a59350cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:20:09 -0300 Subject: [PATCH 020/113] docs(changelog): add fragment for #1552 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1552-firms-drop-snpp.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1552-firms-drop-snpp.md diff --git a/changelog.d/1552-firms-drop-snpp.md b/changelog.d/1552-firms-drop-snpp.md new file mode 100644 index 000000000..fcb375bda --- /dev/null +++ b/changelog.d/1552-firms-drop-snpp.md @@ -0,0 +1,4 @@ +- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data + on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing + a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20, + VIIRS NOAA-21 and MODIS (IMBrandao, #1552). From e2ccac56ae9c8e8b087197c925c726281ae4fd7c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 21:58:42 -0500 Subject: [PATCH 021/113] fix(perimeters): show a 0.25-acre fire as 0.25 ac, not 0 ac The incident card rounded the WFIGS size to whole acres, so every fire under half an acre read "0 ac". The live perimeter feed carries many of these (0.01 to 0.37 acres). Sizes under one acre now keep the reported hundredths. --- docs/CURRENT-STATE.md | 4 ++++ src/layers/perimeters/cards.js | 5 ++++- src/layers/perimeters/cards.test.mjs | 31 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..e954ae082 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4239,6 +4239,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +The incident card rounds the WFIGS size to whole acres, except under one acre, +where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not +`0 ac`. + Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index aef5913ce..a6cb3ac4d 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -159,7 +159,10 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) { // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { - const acres = Math.round(row.acres); + // WFIGS reports sizes to the hundredth of an acre; a fire under one acre + // keeps them rather than reading "0 ac". + const acres = + row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres); facts.push( tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }), ); diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs index 0e064bff0..fe4aac06b 100644 --- a/src/layers/perimeters/cards.test.mjs +++ b/src/layers/perimeters/cards.test.mjs @@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => { 'Catron County · $85K to date', ]); }); + +test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => { + const row = { + stableId: 'x', + name: 'Grey', + acres: 0.25, + containedPct: null, + state: 'US-WA', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }; + assert.equal( + buildIncidentCard(row, 1758000000000).details[0], + '0.25 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0], + '0.01 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0], + '1 ac · containment unknown · US-WA', + ); +}); From 79c867c8cbd923b58bbed7873217eb43be12b643 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 22:14:16 -0500 Subject: [PATCH 022/113] docs(changelog): add the perimeter size fragment for #1569 --- changelog.d/1569-perimeters-sub-acre-size.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1569-perimeters-sub-acre-size.md diff --git a/changelog.d/1569-perimeters-sub-acre-size.md b/changelog.d/1569-perimeters-sub-acre-size.md new file mode 100644 index 000000000..5ee4ffa98 --- /dev/null +++ b/changelog.d/1569-perimeters-sub-acre-size.md @@ -0,0 +1,3 @@ +- Show a fire perimeter under one acre at its reported size. The incident card + rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac" + (Rudy Celekli, @rudycelekli, #1569). From b37025414d90d6b696dcecd252c222972c00c557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:57:27 -0300 Subject: [PATCH 023/113] test(firms): pin the refresh call count and read sources from production Review follow-up for #1552: - environmentProviders: assert exactly 3 upstream calls per refresh again, so a returning fourth source would fail the test. - firmsProxy: build the test's SOURCES from server/providers/firms.js, so there is one list instead of a mirrored copy. - firmsProxy: drop the unused fourth count entry. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsProxy.test.mjs | 13 ++++++------- src/tooling/environmentProviders.test.mjs | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 135002ca7..dc9ed8144 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; +// Read the source list from production so these tests share one source of truth. +const listed = config.match(/const SOURCES = \[([^\]]*)\];/); +assert.ok(listed, 'FIRMS source list exists'); +const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () = assert.equal(result.fires[199_999], large.at(-1)); assert.equal(result.fires.at(-1), last); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ - source, count: [200_000, 1, 0, 0][index], ok: true, + source, count: [200_000, 1, 0][index], ok: true, }))); }); @@ -309,11 +312,7 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => }); test('FIRMS requests only the satellites NASA still delivers', () => { - const listed = config.match(/const SOURCES = \[([^\]]*)\];/); - assert.ok(listed, 'FIRMS source list exists'); - const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); - assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it // returns no rows and still spends a MAP_KEY transaction on every refresh. - assert.ok(!production.includes('VIIRS_SNPP_NRT')); + assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT')); }); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index feca9432d..22eb39ad3 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.equal(json(await request()).count, 130001); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, From b6ad32009fec418d1a5bbff4aa18adb1fbf5cd4c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:52:28 +0400 Subject: [PATCH 024/113] Say why Transit and Bike Share rows are empty outside coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over a city with no feed or system (London, Tokyo) both rows read "ON · — · never", the same as a feed that never answered. Transit already computed a coverage note but put it only in `coverage`, which the row does not show; Bike Share reported nothing at all. Both layers now report a `zoom-in` guidance status with a `statusMessage`: "Fly below 3,000 km to a covered region" / "No feed here yet · N regions available" for Transit, and "Fly below 48 km to a covered city" / "No bike share here yet · N systems available" for Bike Share. A loading or failed Bike Share city keeps its own text. Leaving coverage clears the layer's last-update time, so the row no longer dates data it no longer draws. The coverage-note translations move from the voice card into `translateStatusMessage` (ui/statusText.js), which the Layers row and the voice card now share. --- docs/CURRENT-STATE.md | 13 ++ src/i18n/locales/ar.json | 9 ++ src/i18n/locales/de.json | 5 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 4 + src/i18n/locales/pt.json | 6 + src/i18n/locales/ru.json | 7 ++ src/i18n/locales/uk.json | 7 ++ src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-Hant.json | 4 + src/layers/bikeshare/controls.js | 22 +++- src/layers/bikeshare/coverageStatus.test.mjs | 124 +++++++++++++++++++ src/layers/bikeshare/viewport.js | 3 + src/layers/transit/lifecycle.test.mjs | 39 ++++++ src/layers/transit/queries.js | 13 +- src/layers/transit/viewport.js | 3 + src/ui/layerPanel.js | 9 +- src/ui/layerPanel.test.mjs | 54 ++++++++ src/ui/statusText.js | 63 ++++++++++ src/ui/statusText.test.mjs | 58 ++++++++- src/voice/resultDisplay.js | 31 +---- 23 files changed, 459 insertions(+), 37 deletions(-) create mode 100644 src/layers/bikeshare/coverageStatus.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..8368eeacf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1817,6 +1817,19 @@ Mapped-installation zoom guidance appears in the layer row without counting as a global loading failure. Guidance statuses do not suppress independent refresh errors. +Transit and Bike Share report the same kind of guidance when no feed or system +covers the view, instead of an undated `never`. Above their altitude gates the +rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +`GBFS · Fly below 48 km to a covered city`; lower down over a city neither +covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions +available` and `GBFS · No bike share here yet · N systems available`, N being +the enabled feeds and the catalogued systems. The +chip stays ON. A Bike Share city that is loading or failed keeps its own +loading or error text. Leaving every covered feed or system clears the layer's +last-update time, so the row never dates data it no longer draws. The same +English notes are the voice analyst's empty-result notes; both surfaces show +them translated through `translateStatusMessage` (`src/ui/statusText.js`). + ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..b7193b9e8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2912,6 +2912,15 @@ "many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة", "other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة" }, + "Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة", + "No bike share here yet · {n} systems available": { + "zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح", + "one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})", + "two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})", + "few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة", + "many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا", + "other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح" + }, "degraded": "متدهور", "stale": "متقادم", "partial": "جزئي", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..57be9f388 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2662,6 +2662,11 @@ "one": "Hier noch kein Feed · {n} Region verfügbar", "other": "Hier noch kein Feed · {n} Regionen verfügbar" }, + "Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen", + "No bike share here yet · {n} systems available": { + "one": "Hier noch kein Bikesharing · {n} System verfügbar", + "other": "Hier noch kein Bikesharing · {n} Systeme verfügbar" + }, "degraded": "eingeschränkt", "stale": "veraltet", "partial": "teilweise", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..2a3bb478d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2821,6 +2821,12 @@ "many": "Aún no hay fuente aquí · {n} de regiones disponibles", "other": "Aún no hay fuente aquí · {n} regiones disponibles" }, + "Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura", + "No bike share here yet · {n} systems available": { + "one": "Aún no hay bicis compartidas aquí · {n} sistema disponible", + "many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles", + "other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles" + }, "Wind speed field unavailable": "Campo de velocidad del viento no disponible", "Decoder feeds": "Fuentes del decodificador", "WebUSB + decoder feeds": "WebUSB + fuentes del decodificador", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..57186643b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2531,6 +2531,12 @@ "many": "Pas encore de flux ici · {n} de régions disponibles", "other": "Pas encore de flux ici · {n} régions disponibles" }, + "Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte", + "No bike share here yet · {n} systems available": { + "one": "Pas encore de vélos partagés ici · {n} système disponible", + "many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles", + "other": "Pas encore de vélos partagés ici · {n} systèmes disponibles" + }, "degraded": "dégradé", "stale": "obsolète", "partial": "partiel", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..cecbc7a03 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2531,6 +2531,12 @@ "many": "Ancora nessun feed qui · {n} di regioni disponibili", "other": "Ancora nessun feed qui · {n} regioni disponibili" }, + "Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta", + "No bike share here yet · {n} systems available": { + "one": "Ancora nessun bike sharing qui · {n} sistema disponibile", + "many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili", + "other": "Ancora nessun bike sharing qui · {n} sistemi disponibili" + }, "degraded": "degradato", "stale": "obsoleto", "partial": "parziale", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..47a84c9d4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2401,6 +2401,10 @@ "No feed here yet · {n} regions available": { "other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所" }, + "Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください", + "No bike share here yet · {n} systems available": { + "other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件" + }, "degraded": "性能低下", "stale": "更新遅延", "partial": "部分的", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..2ac851bc0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2533,6 +2533,12 @@ "many": "Ainda não há feed aqui · {n} de regiões disponíveis", "other": "Ainda não há feed aqui · {n} regiões disponíveis" }, + "Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura", + "No bike share here yet · {n} systems available": { + "one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível", + "many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis", + "other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis" + }, "degraded": "degradado", "stale": "desatualizado", "partial": "parcial", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..cba8c8796 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2884,6 +2884,13 @@ "many": "Здесь пока нет потока данных · доступно {n} регионов", "other": "Здесь пока нет потока данных · доступно {n} региона" }, + "Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием", + "No bike share here yet · {n} systems available": { + "one": "Здесь пока нет проката велосипедов · доступна {n} система", + "few": "Здесь пока нет проката велосипедов · доступны {n} системы", + "many": "Здесь пока нет проката велосипедов · доступно {n} систем", + "other": "Здесь пока нет проката велосипедов · доступно {n} системы" + }, "Wind speed field unavailable": "Поле скорости ветра недоступно", "Decoder feeds": "Потоки декодера", "WebUSB + decoder feeds": "WebUSB + потоки декодера", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..71054ea18 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2595,6 +2595,13 @@ "many": "Тут поки немає потоку даних · доступно {n} регіонів", "other": "Тут поки немає потоку даних · доступно {n} регіону" }, + "Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям", + "No bike share here yet · {n} systems available": { + "one": "Тут поки немає прокату велосипедів · доступна {n} система", + "few": "Тут поки немає прокату велосипедів · доступні {n} системи", + "many": "Тут поки немає прокату велосипедів · доступно {n} систем", + "other": "Тут поки немає прокату велосипедів · доступно {n} системи" + }, "degraded": "з перебоями", "stale": "застарів", "partial": "частково", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..0d4033ced 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2302,6 +2302,10 @@ "No feed here yet · {n} regions available": { "other": "此处暂无数据源 · 有 {n} 个覆盖区域" }, + "Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市", + "No bike share here yet · {n} systems available": { + "other": "此处暂无共享单车 · 有 {n} 个可用系统" + }, "degraded": "降级", "stale": "过期", "partial": "部分可用", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..457a32aeb 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2290,6 +2290,10 @@ "No feed here yet · {n} regions available": { "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" }, + "Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市", + "No bike share here yet · {n} systems available": { + "other": "此處暫無共享單車 · 有 {n} 個可用系統" + }, "degraded": "降級", "stale": "過期", "partial": "部分可用", diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 5f73a2dc9..993022506 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -1,5 +1,6 @@ import { msg, t } from '../../i18n/index.js'; -import { STATUS_POLL_MS } from './policy.js'; +import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js'; +import { GBFS_CITY_REGISTRY } from './registry.js'; export function createControls({ state: layerState, services, parts, source }) { const methods = { @@ -57,7 +58,7 @@ export function createControls({ state: layerState, services, parts, source }) { /** * Return current layer statistics for the UI status display. - * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }} + * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }} */ getStats() { const stats = { @@ -65,6 +66,23 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading: layerState._loading, }; + // No system in range is guidance, not an empty feed: the camera is too + // high, or no catalogued system is near the view. A city that failed + // leaves range empty too, and reports its failure below instead. + if ( + layerState._enabled && + !layerState._loading && + layerState._activeCityIds.size === 0 && + layerState._failedCityIds.size === 0 + ) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it through translateStatusMessage + // (ui/statusText.js). + const note = layerState._altitudeGateEnabled + ? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available` + : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; + return { ...stats, status: 'zoom-in', statusMessage: note }; + } if (layerState._loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs new file mode 100644 index 000000000..a9f17e73e --- /dev/null +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -0,0 +1,124 @@ +// BIKESHARE COVERAGE STATUS — a view with no system says so. +// +// Over a city with no catalogued system (London, Tokyo) the layer drew nothing +// and reported nothing, so its row read "ON · — · GBFS · never": the same as a +// feed that had never answered. These cases pin the rule that replaced it: +// with no system in range the layer reports a guidance status that says why, +// and a view it left keeps no date for data it no longer draws. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { GBFS_CITY_REGISTRY } from './registry.js'; + +const LONDON = { lat: 51.5072, lon: -0.1276 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createModel({ state, services, parts: {}, source: null }), + ingestion: { abortInFlight() {} }, + rendering: { removeCityPoints() {} }, + }; + parts.viewport = createViewport({ state, services, parts, source: null }); + const { methods } = createControls({ state, services, parts, source: {} }); + state._enabled = true; + return { state, methods, viewport: parts.viewport }; +} + +/** A viewer looking straight down at `at` from `height` metres. */ +function viewerAt(at, height) { + const rad = Math.PI / 180; + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +test('above the altitude gate the row says how low to fly', async () => { + const { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 2_000_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); + assert.equal(stats.error, undefined); +}); + +test('a city with no system says so and counts the ones there are', async () => { + const { state, methods, viewport } = makeLayer(); + state._viewer = viewerAt(LONDON, 8_000); + await viewport.runProximityCheck(); + + const stats = methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.statusMessage, + `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, + ); + assert.equal(stats.count, 0); +}); + +test('a city with a system in range is not given the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._activeCityIds.add('boston-bluebikes'); + + assert.equal(methods.getStats().status, undefined); +}); + +test('a loading or failed city reports itself, not the prompt', () => { + const { state, methods } = makeLayer(); + state._altitudeGateEnabled = true; + state._loading = true; + assert.equal(methods.getStats().status, undefined); + + // A city that failed is dropped from range, and still owns the row. + state._loading = false; + state._failedCityIds.add('boston-bluebikes'); + const stats = methods.getStats(); + assert.equal(stats.status, undefined); + assert.equal(stats.error, 'GBFS fetch error'); +}); + +test('a disabled layer reports no prompt', () => { + const { state, methods } = makeLayer(); + state._enabled = false; + assert.equal(methods.getStats().status, undefined); +}); + +test('leaving every system behind drops the date of the data it drew', async () => { + for (const height of [8_000, 2_000_000]) { + const { state, methods, viewport } = makeLayer(); + state._lastUpdate = Date.now(); + // Boston's stations were drawn and dated; the camera moves to London. + state._activeCityIds.add('boston-bluebikes'); + state._cityRuntime.set('boston-bluebikes', {}); + state._viewer = viewerAt(LONDON, height); + await viewport.runProximityCheck(); + + assert.equal(state._activeCityIds.size, 0); + assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 25a4df7d0..0739d341c 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -125,6 +125,8 @@ export function createViewport({ state: layerState, services, parts, source }) { // A failure belongs to a city in range; none are once everything is off. layerState._failedCityIds.clear(); layerState._count = 0; + // Nothing is left on the map to date. + layerState._lastUpdate = null; } /** @@ -227,6 +229,7 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; + layerState._lastUpdate = null; return; } diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..a269dc9e9 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3982,3 +3982,42 @@ test('parked transit reprojects held and reported courses at the bounded camera 'selected unknown course also stays screen-up', ); }); + +test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => { + const app = harness(t); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot'); + + // London has no registered feed: the row prompts instead of dating Boston's + // last snapshot over an empty map. + app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 }); + app.advance(1_000); + const away = app.layer.getStats(); + assert.equal(away.status, 'zoom-in'); + assert.equal(away.count, 0); + assert.equal(away.lastUpdate, null); + assert.match( + away.statusMessage, + /^No feed here yet · \d+ regions available$/, + ); + assert.equal(away.coverage, away.statusMessage); + + // Above the altitude gate the prompt says how low to fly. + app.viewer.camera.positionCartographic.height = 10_000_000; + app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 }); + app.advance(1_000); + assert.equal( + app.layer.getStats().statusMessage, + 'Fly below 3,000 km to a covered region', + ); +}); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..8023cdb39 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -240,17 +240,20 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { + // English data (the analyst's empty note to the model, and the Layers + // row's prompt); both show it through translateStatusMessage + // (ui/statusText.js). + const note = state._altitudeGateOpen + ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; return { count: 0, lastUpdate: state._lastUpdate, error: null, source: 'GTFS-RT', status: 'zoom-in', - // English data (the analyst's empty note to the model); the voice - // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). - coverage: state._altitudeGateOpen - ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, + statusMessage: note, + coverage: note, }; } // Age is shown by the same rule the row state uses, so the text and the diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 730126f10..27cc0569c 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -108,6 +108,9 @@ export function createViewport({ state, services, parts }) { parts.ingestion.removeFeedVehicles(feedId); } } + // Leaving coverage leaves nothing on the map to date: a row that read + // "26s ago" over an empty view would claim a feed that is no longer here. + if (desired.size === 0) state._lastUpdate = null; for (const [feedId, feed] of desired) { if (!state._activeFeeds.has(feedId)) { state._activeFeeds.set(feedId, feed); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..29a4bc3c0 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -11,14 +11,17 @@ import { } from './layerNames.js'; export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; -import { translateFeedError, translateStatusLine } from './statusText.js'; +import { + translateFeedError, + translateStatusLine, + translateStatusMessage, +} from './statusText.js'; import { formatNumber, getLocale, msg, onLocaleChange, t, - translateKnown, } from '../i18n/index.js'; // Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the @@ -691,7 +694,7 @@ export class LayerPanel { typeof stats.statusMessage === 'string' && stats.statusMessage.trim() ) { - return `${source} · ${translateKnown(stats.statusMessage.trim())}`; + return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`; } const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never'); if (stats.loading) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 73710e3fc..1be2df5d7 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -431,3 +431,57 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie restore(); } }); + +test('a layer with no feed in view says why instead of "never"', async () => { + const { LayerPanel, presentLayerCount, presentLayerToggle } = + await import('./layerPanel.js'); + const { setLocale } = await import('../i18n/index.js'); + const panel = LayerPanel.prototype; + const rows = [ + { + id: 'transit', + name: 'Transit', + source: 'GTFS-RT', + enabled: true, + stats: { + count: 0, + lastUpdate: null, + source: 'GTFS-RT', + status: 'zoom-in', + statusMessage: 'No feed here yet · 16 regions available', + }, + meta: 'GTFS-RT · No feed here yet · 16 regions available', + }, + { + id: 'bikeshare', + name: 'Bikeshare', + source: 'GBFS', + enabled: true, + stats: { + count: 0, + lastUpdate: null, + loading: false, + status: 'zoom-in', + statusMessage: 'Fly below 48 km to a covered city', + }, + meta: 'GBFS · Fly below 48 km to a covered city', + }, + ]; + for (const layer of rows) { + // Guidance is normal operation: the layer is on and nothing has failed. + assert.equal(presentLayerToggle(layer).text, 'ON'); + assert.equal(presentLayerCount(layer.stats).empty, true); + assert.equal(panel._buildMetaText(layer), layer.meta); + } + + setLocale('ru'); + try { + for (const layer of rows) { + const meta = panel._buildMetaText(layer); + assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta); + assert.match(meta, /^(GTFS-RT|GBFS) · /, meta); + } + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index 1af6b8b2a..f4cbb59a0 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -265,3 +265,66 @@ export function translateStatusLine(text) { .map(translateStatusPiece) .join(STATUS_SEPARATOR); } + +/** Thousands separators out of an English number ("3,000" → 3000). */ +const englishNumber = (text) => Number(text.replace(/,/g, '')); + +// Coverage notes of a layer whose view has no feed, whole lines with a number +// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). +// Their " · " is part of one sentence, so they are matched before the line is +// split into pieces. Keep these in step with the producers. +const COVERAGE_NOTE_PATTERNS = [ + [ + /^Fly below ([\d,]+) km to a covered region$/, + ([, km]) => + t('Fly below {km} km to a covered region', { + km: formatNumber(englishNumber(km)), + }), + ], + [ + /^No feed here yet · ([\d,]+) regions available$/, + ([, regions]) => { + const count = englishNumber(regions); + return tn( + count, + 'No feed here yet · {n} region available', + 'No feed here yet · {n} regions available', + { n: formatNumber(count) }, + ); + }, + ], + [ + /^Fly below ([\d,]+) km to a covered city$/, + ([, km]) => + t('Fly below {km} km to a covered city', { + km: formatNumber(englishNumber(km)), + }), + ], + [ + /^No bike share here yet · ([\d,]+) systems available$/, + ([, systems]) => { + const count = englishNumber(systems); + return tn( + count, + 'No bike share here yet · {n} system available', + 'No bike share here yet · {n} systems available', + { n: formatNumber(count) }, + ); + }, + ], +]; + +/** + * A layer's status message in the UI language: its catalog entry, a coverage + * note by its shape, or a status line translated piece by piece. + */ +export function translateStatusMessage(text) { + if (typeof text !== 'string') return text; + const known = translateKnown(text); + if (known !== text) return known; + for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) { + const match = pattern.exec(text); + if (match) return format(match); + } + return translateStatusLine(text); +} diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index a64975c48..1b6479b45 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { setLocale, t } from '../i18n/index.js'; -import { translateFeedError, translateStatusLine } from './statusText.js'; +import { + translateFeedError, + translateStatusLine, + translateStatusMessage, +} from './statusText.js'; function inLocale(locale, run) { setLocale(locale); @@ -75,3 +79,55 @@ for (const locale of ['es', 'ru']) { }); }); } + +// A layer's coverage note when no feed covers the view: one sentence with a +// " · " in it (transit/queries.js, bikeshare/controls.js). +const COVERAGE_NOTES = [ + 'Fly below 3,000 km to a covered region', + 'No feed here yet · 16 regions available', + 'Fly below 48 km to a covered city', + 'No bike share here yet · 61 systems available', +]; + +test('en: coverage notes show as they came', () => { + setLocale('en'); + for (const text of COVERAGE_NOTES) + assert.equal(translateStatusMessage(text), text); + assert.equal( + translateStatusMessage('No bike share here yet · 1 systems available'), + 'No bike share here yet · 1 system available', + ); +}); + +for (const locale of ['es', 'ru', 'ja']) { + test(`${locale}: a coverage note translates whole, numbers kept`, () => { + inLocale(locale, () => { + for (const text of COVERAGE_NOTES) { + const shown = translateStatusMessage(text); + assert.doesNotMatch( + shown, + /\b(Fly|below|covered|feed|bike share|available)\b/, + shown, + ); + const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0]; + assert.ok( + shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')), + shown, + ); + } + }); + }); + + test(`${locale}: a status message without a coverage note keeps its other forms`, () => { + inLocale(locale, () => { + assert.equal( + translateStatusMessage('Upstream response too large'), + t('Upstream response too large'), + ); + assert.equal( + translateStatusMessage('2 feeds live · 14 heard'), + translateStatusLine('2 feeds live · 14 heard'), + ); + }); + }); +} diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 112200f7f..cd909d2f7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -29,7 +29,7 @@ import { } from '../i18n/index.js'; import { layerNameForId, sourceLabel } from '../ui/layerNames.js'; import { localizePlaceName } from '../ui/placeNames.js'; -import { translateStatusLine } from '../ui/statusText.js'; +import { translateStatusMessage } from '../ui/statusText.js'; import { formatQuantity } from '../ui/unitLabels.js'; /** Split "a; b" caveat text into separate notes. */ @@ -397,27 +397,6 @@ const NOTE_PATTERNS = [ 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory', )) + period, ], - // Transit's empty note when no feed covers the view (transit/queries.js); - // the model gets the English. - [ - /^Fly below ([\d,]+) km to a covered region$/, - ([, km]) => - t('Fly below {km} km to a covered region', { - km: formatNumber(Number(km.replace(/,/g, ''))), - }), - ], - [ - /^No feed here yet · ([\d,]+) regions available$/, - ([, regions]) => { - const count = Number(regions.replace(/,/g, '')); - return tn( - count, - 'No feed here yet · {n} region available', - 'No feed here yet · {n} regions available', - { n: formatNumber(count) }, - ); - }, - ], [ /^([a-z0-9-]+) (off|loading|error)$/, ([, key, status]) => @@ -444,10 +423,10 @@ function shownNote(text) { const shown = match ? show(match) : null; if (shown) return shown; } - // A layer's empty note can be its status line, English pieces joined by - // " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). - const known = translateKnown(text); - return known !== text ? known : translateStatusLine(text); + // A layer's empty note can be its coverage note ("Fly below 3,000 km to a + // covered region") or its status line, English pieces joined by " · " + // ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). + return translateStatusMessage(text); } /** Feed states as the card's chips show them; the values stay English. */ From 25c33893f666bf77b8cb416151657ab84a4a1834 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:09 +0400 Subject: [PATCH 025/113] Add the changelog entry for #1576 --- changelog.d/1576-transit-bikeshare-coverage-prompt.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1576-transit-bikeshare-coverage-prompt.md diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md new file mode 100644 index 000000000..c3dcbe82a --- /dev/null +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -0,0 +1,6 @@ +- Say why Transit and Bike Share are empty outside coverage. Over a city with + no feed or system (London, Tokyo) their Layers rows read + `No feed here yet · 16 regions available` and + `No bike share here yet · 61 systems available`, or how low to fly, instead + of `never`; leaving coverage clears the last-update time (German, @gekh, + #1576). From 652f3c8ea110f3b583e9adfd542617b06808f39b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:17 +0400 Subject: [PATCH 026/113] Hide a vessel's detection callout while its own card paints With Vessels and Detection on, a vessel with an ambient card on the map also got its detection callout next to the bracket. The callout lane paints beneath the cards, so its text showed through the card's plate and covered the speed and course. The callout now gives way only to a card the shared overlay actually paints in the same frame. The overlay paints part of the published cards (shared budget, collisions, UI chrome), so a vessel whose card was dropped keeps its callout. The detection lane paints before the cards publish their paint rects, so getOverlayPaintRect has nothing for the current frame there. The new isOverlayEntryQueued answers from the paint queue that the overlay solves before painting. Vessel detection objects name their card in cardSourceId/cardEntryId. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 39 +++++++++++++++++++ src/data/detection.js | 24 +++++++++++- src/data/detectionHost.test.mjs | 62 ++++++++++++++++++++++++++++++ src/layers/vessels/queries.js | 7 +++- src/overlays/worldOverlay.js | 26 +++++++++++++ src/overlays/worldOverlay.test.mjs | 38 ++++++++++++++++++ 7 files changed, 194 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..370edcd7b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5322,7 +5322,7 @@ silently demoting every later lookup for the session. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. -- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. +- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 8e12bff94..edb3505f2 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1786,3 +1786,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked', assert.equal(result.activeCount, 1); for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); }); + +test('detection objects name the vessel card their callout gives way to', () => { + const vessel = (mmsi, name) => ({ + name, + mmsi, + type: 'Cargo', + destination: '', + speed: 9.5, + course: 80, + heading: 80, + lastPositionUtc: '', + missedRefreshes: 0, + position: POS, + billboard: null, + }); + const keyed = vessel('200', 'GOLDEN BEAR'); + const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 }; + _setVesselStateForTest({ + viewer: {}, + records: [keyed, unkeyed], + billboardCollection: { show: true, remove() {} }, + }); + try { + const objects = Object.fromEntries( + aisLiveVesselsLayer + .getDetectableObjects() + .map((object) => [object.id, object]), + ); + assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels'); + assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200'); + assert.equal( + objects['NO MMSI'].cardEntryId, + 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', + 'the same identity the vessel card is published under', + ); + } finally { + _setVesselStateForTest({ enabled: false }); + } +}); diff --git a/src/data/detection.js b/src/data/detection.js index 36ed6372e..8ea124649 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -21,7 +21,10 @@ import { getKeyholeFadeTuning, keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; -import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js'; +import { + isOverlayEntryQueued, + registerWorldOverlayPaintLane, +} from '../overlays/worldOverlay.js'; import { DETECTION_STYLE, DETECTION_THEME_MAP, @@ -576,6 +579,7 @@ export function getDetectionDiagnostics() { // sprites/strokes. Nothing is copied on the production paint path. result.calloutRects = _calloutPool .slice(0, _calloutCount) + .filter((row) => !row.yielded) .map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha })); return result; } @@ -1077,6 +1081,9 @@ function _stashCallout(entry, acquireFade, keyhole) { font: FONT, microFont: MICRO_FONT, alpha: 1, + cardSourceId: '', + cardEntryId: '', + yielded: false, }; _calloutPool[_calloutCount] = row; } @@ -1103,6 +1110,9 @@ function _stashCallout(entry, acquireFade, keyhole) { row.font = FONT; row.microFont = MICRO_FONT; row.alpha = alpha; + row.cardSourceId = candidate.cardSourceId; + row.cardEntryId = candidate.cardEntryId; + row.yielded = false; _calloutCount++; } @@ -1134,7 +1144,15 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - paintDetectionCallout(ctx, row, row.alpha); + // The contact's own card (an ambient vessel card) already names it, and + // this callout would show through the card's plate. Checked every frame + // against the cards queued for THIS frame: the host paints only part of + // the published cards, and a contact whose card did not paint keeps its + // callout. + row.yielded = + row.cardEntryId !== '' && + isOverlayEntryQueued(row.cardSourceId, row.cardEntryId); + if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha); } ctx.globalAlpha = 1; } @@ -1211,6 +1229,8 @@ function _materializeCandidate( primary, micro, hasMicro: card.hasMicro, + cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '', + cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '', }; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index f8858d602..7bcb9300e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -19,9 +19,11 @@ import { suspendDetection, } from './detection.js'; import { + clearOverlaySource, destroyWorldOverlay, initWorldOverlay, setOverlayEntries, + setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { @@ -579,6 +581,66 @@ test('every style paints its OWN plate token through the production callout path } }); +test('a callout gives way to its contact\'s own card only while that card paints', () => { + // A vessel's ambient card already shows its name, speed and course, and the + // callout drawn beneath it shows through the card's plate. The host paints + // only part of the published cards (shared budget, collisions, UI chrome), + // so a contact whose card was published but not painted keeps its callout. + const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); + try { + const ships = [ + { x: -0.3, sourceId: 'carded', id: 'SHIP-CARDED', card: 'vessel:carded' }, + { x: 0.3, sourceId: 'dropped', id: 'SHIP-DROPPED', card: 'vessel:dropped' }, + ].map(({ x, sourceId, id, card }) => ({ + position: new Cesium.Cartesian3(x, 0, 6_356_752), + sourceId, + id, + metric: '6 kn', + type: 'SEA', + cardSourceId: card === 'vessel:carded' ? 'vessel-cards' : 'dropped-cards', + cardEntryId: card, + })); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {}); + const card = (id, title, x) => ({ + id, + position: new Cesium.Cartesian3(x, 0, 6_356_752), + variant: 'label', + title, + selected: true, + protected: true, + horizonCull: false, + edgeFade: 'none', + }); + setOverlayEntries('vessel-cards', [card('vessel:carded', 'CARD-CARDED', -0.3)]); + // Published, but the host does not paint it this frame. + setOverlayEntries('dropped-cards', [card('vessel:dropped', 'CARD-DROPPED', 0.3)]); + setOverlaySourceVisible('dropped-cards', false); + setMode('DENSE'); + settleFrame(env); + env.ctx.calls.length = 0; + settleFrame(env); + + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + assert.ok(painted('CARD-CARDED'), 'the fixture card reached the frame'); + assert.ok(!painted('CARD-DROPPED'), 'the hidden source paints no card'); + assert.ok(!painted('SHIP-CARDED'), 'no callout under the painted card'); + assert.ok(painted('SHIP-DROPPED'), 'the contact whose card did not paint keeps its callout'); + assert.equal(getDetectionDiagnostics().calloutRects.length, 1, + 'only the callout that painted is reported'); + + // The card leaves the host: the callout comes back on the next frame. + clearOverlaySource('vessel-cards'); + env.ctx.calls.length = 0; + settleFrame(env); + assert.ok(painted('SHIP-CARDED'), 'the callout returns once its card stops painting'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + test('space-tier callouts take the heavier plate while air contacts keep the light one', () => { // Satellites sit over the high-albedo lit Earth disc far more often than // aircraft do, so the space tier carries the heavier backing. Losing the diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..7b2b66825 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -21,6 +21,7 @@ import { REFRESH_MS, FOCUS_EVIDENCE_DEV, } from './policy.js'; +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; export function createQueries({ vesselState, @@ -469,7 +470,7 @@ export function createQueries({ * @param {Object} [options={}] - Options from the detection system. * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.seed] - Seed offset for stride sampling. - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>} */ getDetectableObjects(options = {}) { if ( @@ -504,6 +505,10 @@ export function createQueries({ id: record.name || record.mmsi || 'VESSEL', type: 'SEA', skipLabel: record === selected, + // The vessel's ambient card: while the overlay paints it, detection + // draws no callout under it. + cardSourceId: VESSEL_OVERLAY_SOURCE_ID, + cardEntryId: components.cards.vesselOverlayEntryId(record), klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 7b0b9d5fd..16699c548 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1065,6 +1065,22 @@ export function getOverlayPaintRect(sourceId, entryId) { return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null; } +/** + * Whether an entry is queued to paint in the frame being painted. Custom lanes + * run before the host's own entries, so `getOverlayPaintRect` has nothing for + * this frame yet while they paint; this answers from the solved paint queue. + * @param {string} sourceId + * @param {string} entryId + * @returns {boolean} + */ +export function isOverlayEntryQueued(sourceId, entryId) { + if (_destroyed) return false; + const record = _records.get(entryKey(String(sourceId), String(entryId))); + return ( + !!record && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001 + ); +} + /** * Resolve the topmost interactive world-overlay entry at CSS-pixel coords. * @param {number} x @@ -1737,6 +1753,8 @@ function getProjectionRecord(entry) { contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, + queuedStamp: 0, + queuedAlpha: 0, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2254,6 +2272,14 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.zIndex = record.entry.zIndex; item.key = record.key; _paintQueue[_paintCount++] = item; + // Read by `isOverlayEntryQueued` while the custom lanes paint, before this + // item publishes its paint rect. Keyhole and sonar fades are left out. + record.queuedStamp = _frameStamp; + record.queuedAlpha = + record.sourceAlpha * + temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha; } function sourceCanPaint(sourceId) { diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index f2e608b78..a0b5160b6 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -24,6 +24,7 @@ import { activateWorldOverlayAction, initWorldOverlay, subscribeWorldOverlayActions, + isOverlayEntryQueued, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, @@ -1213,6 +1214,43 @@ test('custom detection lane receives the shared host frame and paints below ordi env.cleanup(); }); +test('a detection lane sees which cards this frame will paint before they publish paint rects', () => { + // The detection lane paints first, so a card's paint rectangle for this + // frame does not exist yet when it runs. The queued check is what lets a + // contact's callout give way to that contact's own card in the same frame. + const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); + initWorldOverlay(env.viewer); + const seen = []; + registerWorldOverlayPaintLane('detection', () => { + seen.push({ + shown: isOverlayEntryQueued('cards', 'SHOWN'), + hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'), + unknown: isOverlayEntryQueued('cards', 'NOT-PUBLISHED'), + rect: getOverlayPaintRect('cards', 'SHOWN'), + }); + }, { id: 'detection', active: true, target: 'detection' }); + setOverlayEntries('cards', [selectedEntry('SHOWN', { + position: positionAtScreen(200, 150), + })]); + setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', { + position: positionAtScreen(100, 100), + })]); + setOverlaySourceVisible('hidden-cards', false); + + env.postRender.raise(); + assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null }, + 'the card is queued while its paint rect is still unpublished'); + assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane'); + assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null); + + clearOverlaySource('cards'); + env.postRender.raise(); + assert.equal(seen[1].shown, false, 'a card cleared since the last frame is no longer queued'); + destroyWorldOverlay(); + assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false); + env.cleanup(); +}); + test('custom-lane painter state is bracketed by host save/restore', () => { // A painter that leaks a clip or alpha must not corrupt later frames: the // host wraps every custom-lane painter in save/try/finally-restore. Pin the From 092e7e71a8cabcaa2ed1274f5f217a0e971e1bec Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:54:11 +0400 Subject: [PATCH 027/113] Add the changelog entry for #1577 --- changelog.d/1577-vessel-callout-under-card.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1577-vessel-callout-under-card.md diff --git a/changelog.d/1577-vessel-callout-under-card.md b/changelog.d/1577-vessel-callout-under-card.md new file mode 100644 index 000000000..579d805b9 --- /dev/null +++ b/changelog.d/1577-vessel-callout-under-card.md @@ -0,0 +1,3 @@ +- A vessel whose ambient card is on the map no longer gets a Detection callout + showing through the card and covering its speed and course. A vessel whose + card the overlay did not paint keeps its callout (German, @gekh, #1577). From ee3466dff93152204238bd7f83bd456d850bef00 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:14:00 +0400 Subject: [PATCH 028/113] Keep the queued-card check off the per-frame allocation path Writing a fractional alpha onto the overlay record for every paint item boxed a heap number per item per frame. The allocation gate caught it: the generic 60-entry workload went from 2,838 to 12,614 B/frame. The record now keeps only the frame stamp and a pointer to its paint item, and isOverlayEntryQueued multiplies the alphas when it is asked. The gate is back to main's numbers on Node 24. --- src/overlays/worldOverlay.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 16699c548..726e766ee 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1076,8 +1076,14 @@ export function getOverlayPaintRect(sourceId, entryId) { export function isOverlayEntryQueued(sourceId, entryId) { if (_destroyed) return false; const record = _records.get(entryKey(String(sourceId), String(entryId))); + if (!record || record.queuedStamp !== _frameStamp) return false; + // Same channels as the paint, minus the per-frame keyhole and sonar fades. return ( - !!record && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001 + record.sourceAlpha * + record.queuedItem.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha > + 0.001 ); } @@ -1754,7 +1760,7 @@ function getProjectionRecord(entry) { altitudeAlpha: 1, sourceAlpha: 1, queuedStamp: 0, - queuedAlpha: 0, + queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2273,13 +2279,10 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.key = record.key; _paintQueue[_paintCount++] = item; // Read by `isOverlayEntryQueued` while the custom lanes paint, before this - // item publishes its paint rect. Keyhole and sonar fades are left out. + // item publishes its paint rect. Only a stamp and a pointer: storing the + // item's alpha here would box a double per paint item per frame. record.queuedStamp = _frameStamp; - record.queuedAlpha = - record.sourceAlpha * - temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha; + record.queuedItem = item; } function sourceCanPaint(sourceId) { From e5ab5f945a3ea5ae28c21a200a63fd2277f4d0dd Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:36 -0800 Subject: [PATCH 029/113] test(nominatim): share one POSIX guard and say the cache is private only on POSIX Co-Authored-By: Claude Opus 5.5 --- src/tooling/nominatimOutlineRoute.test.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index 774099efe..74f0b2d6a 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,29 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - // Windows has no POSIX mode bits (directories stat as 0o666), so only - // POSIX systems can check privacy this way. - if (process.platform !== 'win32') + // The cache is private only through POSIX mode bits. Windows has none + // (directories stat as 0o666) and the cache keeps the checkout's ACLs, + // so these checks run, and privacy holds, only on POSIX systems. + const posixModes = process.platform !== 'win32'; + if (posixModes) { assert.equal( fs.statSync(dir).mode & 0o777, 0o700, - 'the query cache directory is private', + 'the query cache directory is private (POSIX modes)', ); + } const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - if (process.platform !== 'win32') + if (posixModes) { for (const name of fs .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) + .filter((entry) => entry.endsWith('.json'))) { assert.equal( fs.statSync(path.join(dir, name)).mode & 0o777, 0o600, - `${name} is private`, + `${name} is private (POSIX modes)`, ); + } + } fs.rmSync(dir, { recursive: true, force: true }); }); From df87419e64815164062637b00769c502999a3bb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:42 -0800 Subject: [PATCH 030/113] test(tools): pin the raw near-360 satellite azimuths and list the tools with the heading helper Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 9 ++++----- src/tools/queries/environment.js | 1 + src/tools/queries/space.test.mjs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e546fd563..d03064e8f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -246,10 +246,9 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft -`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite -`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a -direction that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Their whole-degree directions round through +`roundHeadingDegrees`, listed with the heading readouts below. +`aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; @@ -6091,7 +6090,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 08db732fc..0af696e36 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,6 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, + // Raw Open-Meteo value, like the fields above; it reports north as 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0e44fe208..41394e305 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; import { noradIdFromSatnum, parseTleText, @@ -301,7 +305,20 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + // These points are chosen so the ISS's raw azimuths fall just below 360°; + // pin that first, so the test cannot pass on a raw value already near 0. + const [, line1, line2] = TLE.split('\n'); + const iss = twoline2satrec(line1, line2); + const nearNorth = (deg) => deg > 359.5 && deg < 360; + const rawPass = findNextSatellitePass({ + satrec: iss, + latDeg: 10, + lonDeg: 30, + fromMs: CLOCK.now(), + }); + assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`); + const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75); + assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`); const pass = await catalog.call('next_satellite_pass', { location: { lat: 10, lon: 30 }, }); From da00b096d2bae42b28a5e34a6c7aed5543d0291b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:02 +0400 Subject: [PATCH 031/113] Fade a vessel's callout by its card's painted opacity The callout now asks for the opacity its own card paints with this frame, keyhole fade included, and fades out as the card fades in. A card faded out at the keyhole rim leaves the callout readable, and a card fading out after leaving its source still hides it. The vessel card id is cached on the record, and the allocation gate covers the lookup. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 1 + src/data/detection.js | 33 ++-- src/data/detectionHost.test.mjs | 174 ++++++++++++++++++ src/layers/vessels/cards.js | 8 + src/layers/vessels/records.js | 3 + src/overlays/worldOverlay.js | 91 +++++---- src/overlays/worldOverlay.test.mjs | 16 +- .../worldOverlayAllocation.worker.mjs | 3 + 9 files changed, 274 insertions(+), 57 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 370edcd7b..26cf5af18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5322,7 +5322,7 @@ silently demoting every later lookup for the session. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. -- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. +- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's detection callout fades out while its own ambient card paints in the same frame, judged by the card's painted opacity including the keyhole fade, so a card dropped by budget, collisions or UI chrome, or faded out at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index edb3505f2..e1c2fcc5a 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1821,6 +1821,7 @@ test('detection objects name the vessel card their callout gives way to', () => 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', 'the same identity the vessel card is published under', ); + assert.equal(keyed.overlayEntryId, 'vessel:200', 'cached on the record for later frames'); } finally { _setVesselStateForTest({ enabled: false }); } diff --git a/src/data/detection.js b/src/data/detection.js index 8ea124649..f4d1ddf3b 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -22,7 +22,7 @@ import { keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; import { - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, registerWorldOverlayPaintLane, } from '../overlays/worldOverlay.js'; import { @@ -110,6 +110,11 @@ const THEME_MAP = DETECTION_THEME_MAP; const SPARSE_ZONE_FRACTION = 0.5; /** Solve cadence for membership/collision; accepted identities still reproject every frame. */ const LABEL_SOLVE_INTERVAL_MS = 125; +/** + * A contact's own card at this opacity or more hides its callout; below it the + * callout fades in, so a card fading in or out never snaps the callout. + */ +const CARD_ALPHA_HIDING_CALLOUT = 0.5; /** Alpha bands retain bracket batching while approximating a continuous radial fade. */ const BRACKET_ALPHA_STEPS = 4; /** Finer bands for Global Context echo brackets, whose fade is animated. */ @@ -579,8 +584,8 @@ export function getDetectionDiagnostics() { // sprites/strokes. Nothing is copied on the production paint path. result.calloutRects = _calloutPool .slice(0, _calloutCount) - .filter((row) => !row.yielded) - .map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha })); + .filter((row) => row.paintAlpha > 0.001) + .map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha })); return result; } @@ -1083,7 +1088,7 @@ function _stashCallout(entry, acquireFade, keyhole) { alpha: 1, cardSourceId: '', cardEntryId: '', - yielded: false, + paintAlpha: 1, }; _calloutPool[_calloutCount] = row; } @@ -1112,7 +1117,7 @@ function _stashCallout(entry, acquireFade, keyhole) { row.alpha = alpha; row.cardSourceId = candidate.cardSourceId; row.cardEntryId = candidate.cardEntryId; - row.yielded = false; + row.paintAlpha = alpha; _calloutCount++; } @@ -1144,15 +1149,15 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - // The contact's own card (an ambient vessel card) already names it, and - // this callout would show through the card's plate. Checked every frame - // against the cards queued for THIS frame: the host paints only part of - // the published cards, and a contact whose card did not paint keeps its - // callout. - row.yielded = - row.cardEntryId !== '' && - isOverlayEntryQueued(row.cardSourceId, row.cardEntryId); - if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha); + // Under the contact's own card (an ambient vessel card) the callout would + // show through the plate. Only a card painting this frame counts. + const cardAlpha = + row.cardEntryId === '' + ? 0 + : getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId); + row.paintAlpha = + row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT); + if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha); } ctx.globalAlpha = 1; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 7bcb9300e..4c1c6dddc 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -21,10 +21,17 @@ import { import { clearOverlaySource, destroyWorldOverlay, + getOverlayPaintRect, initWorldOverlay, setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; +import { applyVesselOverlayPolicy } from './vesselLabels.js'; +import { + getKeyholeFadeTuning, + getKeyholeGeometry, + keyholeLabelAlphaFromGeometry, +} from '../celestialRing.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { clearAwarenessEchoField, @@ -641,6 +648,173 @@ test('a callout gives way to its contact\'s own card only while that card paints } }); +/** A vessel detection contact that names its ambient card, like the AIS layer does. */ +function shipWithCard(mmsi, position) { + return { + position, + sourceId: mmsi, + id: `SHIP-${mmsi}`, + metric: '6 kn', + type: 'SEA', + cardSourceId: 'ais-live-vessels', + cardEntryId: `vessel:${mmsi}`, + }; +} + +/** The production ambient vessel card (not protected: it goes through the arbiter). */ +function ambientVesselCard(mmsi, position) { + return applyVesselOverlayPolicy({ + id: `vessel:${mmsi}`, + position, + title: `CARD-${mmsi}`, + details: ['CARGO · 6KT · 80°'], + gapPx: 10, + }); +} + +test('a callout stays readable when its own card is faded out at the keyhole rim', () => { + // Default keyhole tuning: 7% feather, 1% outside opacity. + assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 }); + const width = 1600; + const height = 900; + const keyhole = getKeyholeGeometry(width, height); + const env = installEnvironment({ width, height, dpr: 1 }); + // Just outside the keyhole's left rim: the card sits above or below the + // anchor (outside, 1% alpha), the callout to its right (inside the feather). + const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752); + const ship = shipWithCard('200', position); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + const [alone] = getDetectionDiagnostics().calloutRects; + assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible'); + + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]); + for (let i = 0; i < 4; i++) settleFrame(env); + const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200'); + assert.ok(rect, 'the host paints the card (at the outside opacity)'); + const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole); + assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`); + + const callouts = getDetectionDiagnostics().calloutRects; + assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out'); + assert.ok(callouts[0].alpha >= 0.4, 'and stays readable'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a callout stays hidden while its card fades out after leaving the published cohort', () => { + const env = installEnvironment({ width: 1600, height: 900, dpr: 1 }); + const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752); + const ship = shipWithCard('200', at(0.1, 0.1)); + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + const frame = (ms) => { + env.ctx.calls.length = 0; + env.advance(ms); + env.postRender.raise(); + }; + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]); + for (let i = 0; i < 6; i++) frame(250); + frame(16); + assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only'); + + // The vessel layer republishes its screen-grid cohort after a camera move: + // this vessel's card is replaced by another one, the vessel stays detectable. + setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]); + frame(16); + assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out'); + assert.ok(painted('CARD-200'), 'and its text still paints'); + assert.ok(!painted('SHIP-200'), 'no full callout under the fading card'); + + for (let i = 0; i < 4; i++) frame(250); + assert.ok(!painted('CARD-200'), 'the card has faded out'); + assert.ok(painted('SHIP-200'), 'and the callout is back'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a vessel whose ambient card loses the collision keeps its callout', () => { + const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); + // Four vessels a few pixels apart: their cards collide (a card goes only + // above or below its anchor), so the arbiter paints only some of them. + const ships = [0, 1, 2, 3].map((i) => + shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)), + ); + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {}); + setOverlayEntries( + 'ais-live-vessels', + ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)), + ); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + env.ctx.calls.length = 0; + settleFrame(env); + + const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId)); + assert.ok( + winners.length >= 1 && winners.length < ships.length, + `the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`, + ); + for (const ship of ships) { + const carded = winners.includes(ship); + assert.equal( + painted(ship.id), + !carded, + carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`, + ); + } + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a callout fades out while its card fades in instead of vanishing at once', () => { + const env = installEnvironment({ width: 1600, height: 900, dpr: 1 }); + const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752)); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + const [alone] = getDetectionDiagnostics().calloutRects; + assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible'); + + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]); + const alphas = []; + for (let i = 0; i < 30; i++) { + env.advance(16); + env.postRender.raise(); + alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0); + } + assert.ok( + alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1), + `the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`, + ); + assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + test('space-tier callouts take the heavier plate while air contacts keep the light one', () => { // Satellites sit over the high-albedo lit Earth disc far more often than // aircraft do, so the space tier carries the heavier backing. Losing the diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..2563bf420 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -117,6 +117,14 @@ export function createCards({ /** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { + // Cached on the record: detection asks for it every frame for every vessel. + if (record?.overlayEntryId) return record.overlayEntryId; + const id = composeVesselOverlayEntryId(record); + if (record) record.overlayEntryId = id; + return id; + } + + function composeVesselOverlayEntryId(record) { const mmsi = String(record?.mmsi || '').trim(); if (mmsi) return `vessel:${mmsi}`; const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..aeb2f39bf 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,6 +23,9 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, + // Filled on first use by the cards' `vesselOverlayEntryId`. The id reads + // only the MMSI, or for an unkeyed row fields it never updates. + overlayEntryId: null, }; } diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 726e766ee..aabcc7be0 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -258,6 +258,10 @@ const _paintQueue = []; const _paintItemPool = []; const _paintRectPool = []; const _paintRectByKey = new Map(); +/** @type {Map>} This frame's paint items by source and entry id. */ +const _queuedItemBySource = new Map(); +/** Index rows that stop painting are dropped this many frames apart. */ +const QUEUED_ITEM_PRUNE_FRAMES = 600; const _hitRects = []; const _paintedBySource = Object.create(null); const _paintedSourceKeys = []; @@ -1066,24 +1070,26 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * Whether an entry is queued to paint in the frame being painted. Custom lanes - * run before the host's own entries, so `getOverlayPaintRect` has nothing for - * this frame yet while they paint; this answers from the solved paint queue. + * Opacity an entry will paint with in the frame being painted, or 0 when it is + * not queued. Valid from the custom lanes, which paint before the entries in + * later lanes publish their paint rects. Leaves out only the Cyber sonar fade. * @param {string} sourceId * @param {string} entryId - * @returns {boolean} + * @returns {number} */ -export function isOverlayEntryQueued(sourceId, entryId) { - if (_destroyed) return false; - const record = _records.get(entryKey(String(sourceId), String(entryId))); - if (!record || record.queuedStamp !== _frameStamp) return false; - // Same channels as the paint, minus the per-frame keyhole and sonar fades. +export function getQueuedOverlayEntryAlpha(sourceId, entryId) { + if (_destroyed) return 0; + const item = _queuedItemBySource.get(sourceId)?.get(entryId); + // Pooled items are reassigned every frame: check the frame and the entry. + if (!item || item.stamp !== _frameStamp) return 0; + const record = item.record; + if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0; return ( record.sourceAlpha * - record.queuedItem.temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha > - 0.001 + item.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha * + (_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1) ); } @@ -1759,8 +1765,6 @@ function getProjectionRecord(entry) { contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, - queuedStamp: 0, - queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2277,12 +2281,27 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.lane = paintLaneForOverlayEntry(record.entry); item.zIndex = record.entry.zIndex; item.key = record.key; + item.stamp = _frameStamp; _paintQueue[_paintCount++] = item; - // Read by `isOverlayEntryQueued` while the custom lanes paint, before this - // item publishes its paint rect. Only a stamp and a pointer: storing the - // item's alpha here would box a double per paint item per frame. - record.queuedStamp = _frameStamp; - record.queuedItem = item; + // Indexed by source and id rather than by `record.key`, so a lookup builds + // no string. A card fading out after it left its source's entries is still + // queued here, though `_records` no longer has it. + let bySource = _queuedItemBySource.get(record.entry.source); + if (!bySource) { + bySource = new Map(); + _queuedItemBySource.set(record.entry.source, bySource); + } + bySource.set(record.entry.id, item); +} + +/** Drop index rows for entries that stopped painting (bounded, not per frame). */ +function pruneQueuedItemIndex() { + for (const [sourceId, bySource] of _queuedItemBySource) { + for (const [entryId, item] of bySource) { + if (item.stamp !== _frameStamp) bySource.delete(entryId); + } + if (bySource.size === 0) _queuedItemBySource.delete(sourceId); + } } function sourceCanPaint(sourceId) { @@ -2354,6 +2373,7 @@ function solveDomains(timestamp) { solveRevision += domain.arbiter.solveRevision; } _paintQueue.length = _paintCount; + if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex(); _solveDirty = false; _diagnostics.solveMs = solveMs; _diagnostics.selectedCount = selectedCount; @@ -2482,23 +2502,25 @@ function paintCustomLane(lane) { } } +/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */ +function entryKeyholeAlpha(entry, placement, keyhole) { + if (entry.edgeFade !== 'keyhole') return 1; + const keyholeX = placement.centerX - keyhole.centerX; + const keyholeY = placement.centerY - keyhole.centerY; + return keyholeX * keyholeX + keyholeY * keyholeY <= + keyhole.radius * keyhole.radius + ? 1 + : keyholeLabelAlphaFromGeometry( + placement.centerX, + placement.centerY, + keyhole, + ); +} + function paintEntryItem(item, keyhole, sonar) { const { record, placement } = item; const entry = record.entry; - let keyholeAlpha = 1; - if (entry.edgeFade === 'keyhole') { - const keyholeX = placement.centerX - keyhole.centerX; - const keyholeY = placement.centerY - keyhole.centerY; - keyholeAlpha = - keyholeX * keyholeX + keyholeY * keyholeY <= - keyhole.radius * keyhole.radius - ? 1 - : keyholeLabelAlphaFromGeometry( - placement.centerX, - placement.centerY, - keyhole, - ); - } + const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole); // All five channels are normalized at their source. Keep the multiply on // the hot paint path so its intermediate doubles remain unboxed; the pure // `combinedOverlayAlpha` export still specifies/tests the same binding. @@ -2799,6 +2821,7 @@ export function destroyWorldOverlay() { _accessibleActivatorByKey.clear(); _accessibilitySignature = ''; _paintRectByKey.clear(); + _queuedItemBySource.clear(); _paintCount = 0; _paintRectCount = 0; _hitRectCount = 0; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index a0b5160b6..6e713a5da 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -24,7 +24,7 @@ import { activateWorldOverlayAction, initWorldOverlay, subscribeWorldOverlayActions, - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, @@ -1216,16 +1216,16 @@ test('custom detection lane receives the shared host frame and paints below ordi test('a detection lane sees which cards this frame will paint before they publish paint rects', () => { // The detection lane paints first, so a card's paint rectangle for this - // frame does not exist yet when it runs. The queued check is what lets a + // frame does not exist yet when it runs. The queued alpha is what lets a // contact's callout give way to that contact's own card in the same frame. const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); initWorldOverlay(env.viewer); const seen = []; registerWorldOverlayPaintLane('detection', () => { seen.push({ - shown: isOverlayEntryQueued('cards', 'SHOWN'), - hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'), - unknown: isOverlayEntryQueued('cards', 'NOT-PUBLISHED'), + shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'), + hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'), + unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'), rect: getOverlayPaintRect('cards', 'SHOWN'), }); }, { id: 'detection', active: true, target: 'detection' }); @@ -1238,16 +1238,16 @@ test('a detection lane sees which cards this frame will paint before they publis setOverlaySourceVisible('hidden-cards', false); env.postRender.raise(); - assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null }, + assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null }, 'the card is queued while its paint rect is still unpublished'); assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane'); assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null); clearOverlaySource('cards'); env.postRender.raise(); - assert.equal(seen[1].shown, false, 'a card cleared since the last frame is no longer queued'); + assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued'); destroyWorldOverlay(); - assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false); + assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0); env.cleanup(); }); diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index e2a4c6240..93b47e7f3 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) { : `FL${String(180 + (index % 220)).padStart(3, '0')}`, type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4], tier: index % 17 === 0 ? 'military' : undefined, + // Vessels name their ambient card, so each callout asks the host for it. + cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined, + cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined, }); } return { From 90db4eb2c9ed0c61adbfe6ffaf7a17b1ffe1d829 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:03 +0400 Subject: [PATCH 032/113] Name the real gate altitude and repaint coverage rows at once The Transit prompt said "Fly below 3,000 km", but the closed gate opens at 2,850 km; it now names the enter threshold, as Bike Share does. When the camera changes coverage, Transit and Bike Share ask the Layers panel to repaint instead of leaving the old prompt up until the next 15 s / 60 s tick. --- docs/CURRENT-STATE.md | 2 +- src/layers/bikeshare/controls.js | 9 ++ src/layers/bikeshare/lifecycle.js | 1 + src/layers/bikeshare/state.js | 4 + src/layers/bikeshare/viewport.js | 17 ++++ src/layers/promptRepaint.test.mjs | 122 +++++++++++++++++++++++++ src/layers/transit/gatePrompt.test.mjs | 66 +++++++++++++ src/layers/transit/lifecycle.test.mjs | 2 +- src/layers/transit/queries.js | 4 +- src/layers/transit/viewport.js | 7 ++ src/ui/statusText.test.mjs | 2 +- src/voice/analystEmptyNotes.test.mjs | 2 +- src/voice/resultDisplay.js | 2 +- 13 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 src/layers/promptRepaint.test.mjs create mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8368eeacf..946254fbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1819,7 +1819,7 @@ errors. Transit and Bike Share report the same kind of guidance when no feed or system covers the view, instead of an undated `never`. Above their altitude gates the -rows read `GTFS-RT · Fly below 3,000 km to a covered region` and +rows read `GTFS-RT · Fly below 2,850 km to a covered region` and `GBFS · Fly below 48 km to a covered city`; lower down over a city neither covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions available` and `GBFS · No bike share here yet · N systems available`, N being diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 993022506..78edd907b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -14,6 +14,15 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, + /** + * Keep the manager so camera-driven coverage changes can repaint the row + * before the next status poll. + * @param {object} dataManager DataLayerManager instance. + */ + attachDataManager(dataManager) { + layerState._dataManager = dataManager; + }, + /** * Return a sampled array of detectable station objects for HUD overlay rendering. * @param {Object} [options] - Sampling options (maxCount, seed). diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 3e46b5972..050269bfd 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -169,6 +169,7 @@ export function createLifecycle({ viewer.scene.primitives.remove(layerState._pointCollection); layerState._pointCollection = null; } + layerState._dataManager = null; layerState._viewer = null; }, }; diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 3617914bf..518c2397a 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,6 +19,10 @@ export function createState({ services }) { layerState._viewer = null; + /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + + layerState._dataManager = null; + /** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ layerState._pointCollection = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 0739d341c..dbc328f61 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,6 +116,14 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } + /** + * Repaint the layer's row now. Its text follows the camera (coverage + * prompt, station count), and the manager tick is 60 s. + */ + function refreshRow() { + layerState._dataManager?.refreshLayerStats?.(); + } + /** Deactivate all currently active cities and clear the active set. */ function deactivateAllCities() { @@ -205,9 +213,11 @@ export function createViewport({ state: layerState, services, parts, source }) { const generation = ++layerState._proximityGeneration; const altitude = getCameraAltitude(layerState._viewer); + const wasGateEnabled = layerState._altitudeGateEnabled; // Altitude gate: disable all cities when camera is too high if (!shouldActivateAtAltitude(altitude)) { deactivateAllCities(); + if (wasGateEnabled) refreshRow(); return; } @@ -226,12 +236,18 @@ export function createViewport({ state: layerState, services, parts, source }) { if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); } + const changed = + !wasGateEnabled || + nextActive.size !== layerState._activeCityIds.size || + [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { layerState._count = 0; layerState._lastUpdate = null; + if (changed) refreshRow(); return; } + if (changed) refreshRow(); // Only activate cities that don't already have rendered points const toActivate = []; @@ -243,6 +259,7 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); + if (generation === layerState._proximityGeneration) refreshRow(); } /** Schedule a debounced proximity check after camera movement. */ diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs new file mode 100644 index 000000000..563aaeac7 --- /dev/null +++ b/src/layers/promptRepaint.test.mjs @@ -0,0 +1,122 @@ +// When the camera changes which coverage prompt a Transit or +// Bike Share row shows, the layer asks the panel to repaint at once, instead +// of leaving the old prompt up until the manager's next tick (Transit 15 s, +// Bike Share 60 s). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState as createBikeState } from './bikeshare/state.js'; +import { createControls as createBikeControls } from './bikeshare/controls.js'; +import { createModel as createBikeModel } from './bikeshare/model.js'; +import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; +import { createState as createTransitState } from './transit/state.js'; +import { createViewport as createTransitViewport } from './transit/viewport.js'; +import { createQueries as createTransitQueries } from './transit/queries.js'; + +const rad = Math.PI / 180; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +const services = () => ({ + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, +}); + +function viewer(at, height) { + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +function spyManager() { + const dm = { repaints: 0 }; + dm.refreshLayerStats = () => { + dm.repaints++; + }; + return dm; +} + +test('Bike Share: flying from London into Boston repaints the prompt row', async () => { + const svc = services(); + const state = createBikeState({ services: svc }); + const parts = { + queries: { collectDetectableStations: () => [] }, + model: createBikeModel({ state, services: svc, parts: {}, source: null }), + ingestion: { + abortInFlight() {}, + loadCityStationInfo: async () => new Map(), + loadCityStationStatus: async () => new Map(), + }, + rendering: { + removeCityPoints() {}, + ensureCityPoints() {}, + applyStatusToPoints() {}, + }, + }; + parts.viewport = createBikeViewport({ + state, + services: svc, + parts, + source: null, + }); + const { methods } = createBikeControls({ + state, + services: svc, + parts, + source: {}, + }); + state._enabled = true; + const dm = spyManager(); + methods.attachDataManager(dm); + + state._viewer = viewer(LONDON, 8_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, 'zoom-in'); + + const before = dm.repaints; + state._viewer = viewer(BOSTON, 3_000); + await parts.viewport.runProximityCheck(); + assert.equal(methods.getStats().status, undefined, 'Boston is covered'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); + +test('Transit: leaving Boston for London repaints the row', () => { + const svc = services(); + const state = createTransitState({ services: svc }); + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed() {}, + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createTransitViewport({ state, services: svc, parts }); + const queries = createTransitQueries({ state, parts }).methods; + state._enabled = true; + const dm = spyManager(); + state._dataManager = dm; + + state._viewer = viewer(BOSTON, 4_000); + parts.viewport.runProximityCheck(); + assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); + state._lastUpdate = Date.now(); + + const before = dm.repaints; + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.ok(dm.repaints > before, 'the row was asked to repaint'); +}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs new file mode 100644 index 000000000..7f439fcdc --- /dev/null +++ b/src/layers/transit/gatePrompt.test.mjs @@ -0,0 +1,66 @@ +// The Transit coverage prompt names the altitude the closed gate +// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above +// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" +// asks for something the camera has already done. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createViewport } from './viewport.js'; +import { createQueries } from './queries.js'; +import { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; + +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const polled = []; + const parts = { + ingestion: { + feedStatus: () => ({ count: 0 }), + pollFeed: (feed) => polled.push(feed.id), + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createViewport({ state, services, parts }); + const queries = createQueries({ state, parts }).methods; + state._enabled = true; + const rad = Math.PI / 180; + state._viewer = { + camera: { + positionCartographic: { + height: 0, + latitude: BOSTON.lat * rad, + longitude: BOSTON.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; + return { state, queries, viewport: parts.viewport, polled }; +} + +test('below the prompted altitude but above the gate, the prompt names the gate', () => { + const { state, queries, viewport, polled } = makeLayer(); + state._viewer.camera.positionCartographic.height = 2_900_000; + viewport.runProximityCheck(); + + // Nothing loads at 2,900 km: the gate opens at the enter threshold. + assert.equal(state._altitudeGateOpen, false); + assert.deepEqual(polled, []); + + const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( + 'en-US', + ); + const { statusMessage } = queries.getStats(); + assert.equal(statusMessage, `Fly below ${km} km to a covered region`); +}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index a269dc9e9..dc18fd6f0 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4018,6 +4018,6 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async app.advance(1_000); assert.equal( app.layer.getStats().statusMessage, - 'Fly below 3,000 km to a covered region', + 'Fly below 2,850 km to a covered region', ); }); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 8023cdb39..748929213 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -1,5 +1,5 @@ import { - ACTIVATION_ALTITUDE_M, + ACTIVATION_ENTER_ALTITUDE_M, FEED_STALE_AFTER_MS, aggregateTransitFeedHealth, transitDetectionClass, @@ -245,7 +245,7 @@ export function createQueries({ state, parts }) { // (ui/statusText.js). const note = state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; + : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; return { count: 0, lastUpdate: state._lastUpdate, diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 27cc0569c..0e3b317d4 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -83,7 +83,9 @@ export function createViewport({ state, services, parts }) { if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); + const wasGateOpen = state._altitudeGateOpen; state._altitudeGateOpen = altitudeGateOpen(altitude); + let changed = state._altitudeGateOpen !== wasGateOpen; const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null; const desired = new Map(); if (center) { @@ -106,6 +108,7 @@ export function createViewport({ state, services, parts }) { state._activeFeeds.delete(feedId); state._feedStatus.delete(feedId); parts.ingestion.removeFeedVehicles(feedId); + changed = true; } } // Leaving coverage leaves nothing on the map to date: a row that read @@ -115,11 +118,15 @@ export function createViewport({ state, services, parts }) { if (!state._activeFeeds.has(feedId)) { state._activeFeeds.set(feedId, feed); void parts.ingestion.pollFeed(feed, state._generation); + changed = true; } } parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); governorRequestRender('transit-proximity'); + // The row's text follows the camera (coverage prompt, feed list), so it + // repaints now instead of at the next 15 s manager tick. + if (changed) state._dataManager?.refreshLayerStats?.(); } function onCameraChanged() { diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs index 1b6479b45..6d1648b6d 100644 --- a/src/ui/statusText.test.mjs +++ b/src/ui/statusText.test.mjs @@ -83,7 +83,7 @@ for (const locale of ['es', 'ru']) { // A layer's coverage note when no feed covers the view: one sentence with a // " · " in it (transit/queries.js, bikeshare/controls.js). const COVERAGE_NOTES = [ - 'Fly below 3,000 km to a covered region', + 'Fly below 2,850 km to a covered region', 'No feed here yet · 16 regions available', 'Fly below 48 km to a covered city', 'No bike share here yet · 61 systems available', diff --git a/src/voice/analystEmptyNotes.test.mjs b/src/voice/analystEmptyNotes.test.mjs index b0de16ebd..cfacee4bf 100644 --- a/src/voice/analystEmptyNotes.test.mjs +++ b/src/voice/analystEmptyNotes.test.mjs @@ -70,7 +70,7 @@ const cases = [ // Transit's coverage text when nothing is in view (transit/queries.js). const transitCoverage = [ - 'transit: Fly below 3,000 km to a covered region', + 'transit: Fly below 2,850 km to a covered region', 'transit: No feed here yet · 12 regions available', ]; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index cd909d2f7..72a929b94 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -423,7 +423,7 @@ function shownNote(text) { const shown = match ? show(match) : null; if (shown) return shown; } - // A layer's empty note can be its coverage note ("Fly below 3,000 km to a + // A layer's empty note can be its coverage note ("Fly below 2,850 km to a // covered region") or its status line, English pieces joined by " · " // ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)"). return translateStatusMessage(text); From 433b336c2d57087986d8114735861f53d790d7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:50:42 -0300 Subject: [PATCH 033/113] feat(transit): add Szczecin buses and trams (ZDiTM, CC0) Keyless GTFS-Realtime feed published by ZDiTM Szczecin under CC0 1.0. Speeds are m/s (checked against vehicle movement), the file refreshes about every 20 s, and the feed carries no route ids, so vehicles take the default bus mode. Co-Authored-By: Claude Sonnet 5.5 --- DATA_SOURCES.md | 3 ++- README.md | 2 +- changelog.d/szczecin-transit.md | 3 +++ docs/CURRENT-STATE.md | 4 ++-- src/data/transitFeeds.js | 23 +++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 26 ++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 changelog.d/szczecin-transit.md diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..6532837d8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -99,6 +99,7 @@ How to read this: | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | @@ -186,7 +187,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All nineteen registered feeds, covering eighteen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 6212f3991..6c8de0d79 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/szczecin-transit.md b/changelog.d/szczecin-transit.md new file mode 100644 index 000000000..5b2411211 --- /dev/null +++ b/changelog.d/szczecin-transit.md @@ -0,0 +1,3 @@ +- Add Szczecin, Poland to the Transit layer: buses and trams from ZDiTM's + keyless GTFS-Realtime feed, published under CC0 1.0. The feed carries no + route ids, so vehicles show as buses (IMBrandao). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index abfa3fc04..85bfd2e92 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4644,9 +4644,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Eighteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro -(municipal buses and the Mobi-Rio BRT, two feeds), and nine in +(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 48ca450a1..76b2764a6 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -788,6 +788,29 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: rioRouteLabel, }), + Object.freeze({ + id: 'zditm-szczecin', + name: 'Szczecin buses and trams', + operator: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin', + region: 'Szczecin, Poland', + // Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and + // 14.44–14.77 E, inside 20 km of this point. + center: Object.freeze({ lat: 53.44, lon: 14.56 }), + loadRadiusKm: 25, + url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb', + license: 'CC0 1.0', + licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + attribution: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.', + note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.', + }), + defaultMode: 'bus', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index d6dc93eb6..5fab7c27d 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -552,3 +552,29 @@ test('Rio feeds are CC BY 4.0 and live', () => { 'Rio has a DATA_SOURCES row', ); }); + +test('Szczecin is CC0, live, covers the city and not Berlin', () => { + const feed = getTransitFeed('zditm-szczecin'); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.license, 'CC0 1.0'); + assert.equal( + feed.licenseUrl, + 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + ); + assert.match(feed.terms.quote, /CC0 1\.0 licence/); + assert.match(feed.attribution, /ZDiTM/); + assert.equal(feed.routeLabel, undefined, 'the feed has no route ids'); + assert.equal(transitModeFor(feed, null), 'bus'); + const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre'); + assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge'); + assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin'); + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')), + 'Szczecin has a DATA_SOURCES row', + ); +}); From 1015b1666463f98bdcb512b8e0565e5f9d96e53a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:37 +0400 Subject: [PATCH 034/113] Prune queued-card rows whose pooled item paints another entry A pooled paint item reused by another card keeps a fresh frame stamp, so rows for cards that stopped painting survived every prune and the index grew for the whole session. The prune now also drops a row whose item paints a different entry id or source, and a test pins the bound. --- src/overlays/worldOverlay.js | 10 ++++++- src/overlays/worldOverlay.test.mjs | 47 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index aabcc7be0..df3055de6 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -2298,7 +2298,15 @@ function addPaintItem(record, placement, temporalAlpha, selected) { function pruneQueuedItemIndex() { for (const [sourceId, bySource] of _queuedItemBySource) { for (const [entryId, item] of bySource) { - if (item.stamp !== _frameStamp) bySource.delete(entryId); + // A pooled item that now paints another entry keeps a fresh stamp. + const entry = item.record.entry; + if ( + item.stamp !== _frameStamp || + entry.id !== entryId || + entry.source !== sourceId + ) { + bySource.delete(entryId); + } } if (bySource.size === 0) _queuedItemBySource.delete(sourceId); } diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 6e713a5da..39bc74c44 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -2353,3 +2353,50 @@ test('promoting an ambient entry to selected paints it once with no fading dupli assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown'); } finally { env.cleanup(); } }); + +test('the queued-card index stays bounded while card identities churn', () => { + // The index is internal: capture the per-source map the same way the + // projection-record test captures its map, through Map.prototype.set. + const originalSet = Map.prototype.set; + let queuedIndex = null; + Map.prototype.set = function captureQueuedIndex(key, value) { + if (typeof key === 'string' && key.startsWith('churn-card-') + && value && typeof value === 'object' && 'temporalAlpha' in value + && 'placement' in value && 'stamp' in value) { + queuedIndex = this; + } + return originalSet.call(this, key, value); + }; + const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); + try { + initWorldOverlay(env.viewer); + const FRAMES = 1300; + for (let frame = 0; frame < FRAMES; frame++) { + // One painted card whose identity changes every frame: the same pooled + // paint item serves every id (a vessel cohort republished as the camera + // moves, a CCTV or radio card swapping). + setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, { + position: positionAtScreen(200, 150), + })]); + env.advanceTime(16); + env.postRender.raise(); + assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1, + 'the live card is queued in the frame just painted'); + if (frame > 0) { + assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0, + 'a stale row never reports the previous card as queued'); + } + } + assert.ok(queuedIndex, 'the queued-card index was never populated'); + // Rows are pruned every 600 painted frames, so with one new id per frame + // the index holds at most one prune window of ids plus the live card. + const bound = 600 + 1; + assert.ok( + queuedIndex.size <= bound, + `queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`, + ); + } finally { + Map.prototype.set = originalSet; + env.cleanup(); + } +}); From 697b6b59e66291c6c690b6a77fd2030396233e03 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 14:45:39 +0400 Subject: [PATCH 035/113] Repaint coverage rows after loads, nudges and cleared failures --- src/data/lifecycle.js | 19 +- src/data/manager.js | 4 +- src/data/manager.test.mjs | 25 ++ src/layers/bikeshare/controls.js | 15 +- src/layers/bikeshare/rowRepaint.test.mjs | 295 ++++++++++++++++++ src/layers/bikeshare/state.js | 2 +- src/layers/bikeshare/viewport.js | 13 +- .../transit/refreshErrorRepaint.test.mjs | 98 ++++++ src/layers/transit/state.js | 2 +- src/layers/transit/viewport.js | 2 +- 10 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 src/layers/bikeshare/rowRepaint.test.mjs create mode 100644 src/layers/transit/refreshErrorRepaint.test.mjs diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c2290c72..c01926451 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2385,11 +2385,28 @@ export class LayerLifecycle { /** * Ask presentation to repaint layer rows now, for data that lands outside * the manager tick (Transit's proximity polls, Directions' route steps). + * @param {string} [layerId] - The layer whose row changed. */ - refreshLayerStats() { + refreshLayerStats(layerId) { + this._dropRecoveredRefreshError(layerId); this._publishActivity({ type: 'status' }); } + /** + * A tick's failure belongs to the feed it polled. Once the layer's own stats + * stop reporting one (the camera left that feed), the row stops showing it + * instead of waiting for the next tick. + */ + _dropRecoveredRefreshError(layerId) { + const entry = layerId ? this.layers.get(layerId) : null; + if (!entry?.managerRefreshError || entry.refreshing) return; + const failure = refreshFailureFromStats( + this._moduleStats(entry), + entry.module.name || layerId, + ); + if (!failure) entry.managerRefreshError = null; + } + _publishActivity(change) { for (const callback of this._activityListeners) { try { diff --git a/src/data/manager.js b/src/data/manager.js index cf7b31fae..688fb94d2 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -20,8 +20,10 @@ export class DataLayerManager extends LayerLifecycle { * manager tick (camera-driven loads such as Transit's proximity polls), so a * row shows its count when the data lands instead of at the next interval. * One DOM pass; skipped while the document is hidden. + * @param {string} [layerId] - The layer whose row changed. */ - refreshLayerStats() { + refreshLayerStats(layerId) { + this._dropRecoveredRefreshError(layerId); this._refreshTogglePanel(); } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 70f705ec6..de3502979 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async assert.deepEqual(changes, ['status']); }); +test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => { + const { LayerLifecycle } = await import('./lifecycle.js'); + const lifecycle = new LayerLifecycle({}); + let stats = { count: 0, error: 'Feed unavailable' }; + lifecycle.register({ + id: 'feed', + name: 'Feed', + getStats: () => stats, + async update() {}, + }); + const entry = lifecycle.layers.get('feed'); + entry.enabled = true; + entry.initialized = true; + entry.managerRefreshError = 'Feed unavailable'; + + lifecycle.refreshLayerStats('feed'); + assert.equal(entry.managerRefreshError, 'Feed unavailable'); + + stats = { count: 0 }; + lifecycle.refreshLayerStats(); + assert.equal(entry.managerRefreshError, 'Feed unavailable'); + lifecycle.refreshLayerStats('feed'); + assert.equal(entry.managerRefreshError, null); +}); + test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { const listeners = new Set(); const doc = { diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 78edd907b..03bb544ab 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -70,17 +70,26 @@ export function createControls({ state: layerState, services, parts, source }) { * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }} */ getStats() { + // A city in range without points is loading even before its fetch + // starts: the row repaints on entry, ahead of beginLoading(). + const loading = + layerState._loading || + [...layerState._activeCityIds].some( + (cityId) => + !layerState._cityRuntime.has(cityId) && + !layerState._failedCityIds.has(cityId), + ); const stats = { count: layerState._count, lastUpdate: layerState._lastUpdate, - loading: layerState._loading, + loading, }; // No system in range is guidance, not an empty feed: the camera is too // high, or no catalogued system is near the view. A city that failed // leaves range empty too, and reports its failure below instead. if ( layerState._enabled && - !layerState._loading && + !loading && layerState._activeCityIds.size === 0 && layerState._failedCityIds.size === 0 ) { @@ -92,7 +101,7 @@ export function createControls({ state: layerState, services, parts, source }) { : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; return { ...stats, status: 'zoom-in', statusMessage: note }; } - if (layerState._loading) { + if (loading) { stats.loadingLabel = layerState._activeCityIds.size > 0 ? t('syncing {count} city feeds...', { diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs new file mode 100644 index 000000000..144ff5502 --- /dev/null +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -0,0 +1,295 @@ +// Bike Share row repaints: entering a city, leaving it or nudging the camera +// mid-load, and leaving a failed city. +// Bike Share: after the camera settles, the last row the panel painted must +// match what the layer reports now. Each case drives the real viewport, +// ingestion, rendering and controls; the manager is a spy that records the +// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { CITY_BY_ID } from './registry.js'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; +import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js'; + +const BOSTON_ID = 'boston-bluebikes'; +const TORONTO_ID = 'toronto-bike-share'; +const BUFFALO_ID = 'buffalo-reddy'; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; +const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 }; +const BETWEEN = { lat: 43.25, lon: -79.1 }; +const TORONTO_ONLY = { lat: 43.7, lon: -79.6 }; +const LOW_M = 10_000; + +globalThis.document ??= { + addEventListener() {}, + removeEventListener() {}, +}; + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function makeSource() { + const cityOf = (url) => { + for (const [id, city] of CITY_BY_ID) { + if (url === city.stationInformationUrl) return { id, kind: 'info' }; + if (url === city.stationStatusUrl) return { id, kind: 'status' }; + } + throw new Error(`unexpected GBFS url ${url}`); + }; + const source = { + dead: new Set(), + /** `${cityId}:${kind}` → deferred that gates the answer. */ + gates: new Map(), + async getStations(url, { signal } = {}) { + const { id, kind } = cityOf(url); + const gate = source.gates.get(`${id}:${kind}`); + if (gate) { + await new Promise((resolve, reject) => { + gate.promise.then(resolve); + signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }); + } else { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + signal?.throwIfAborted(); + if (source.dead.has(id)) throw new Error('GBFS HTTP 503'); + const city = CITY_BY_ID.get(id); + if (kind === 'info') { + return { + data: { + stations: [ + { + station_id: `${id}-1`, + name: `${id} dock`, + lat: city.centerLat, + lon: city.centerLon, + capacity: 10, + }, + ], + }, + }; + } + return { + data: { + stations: [ + { + station_id: `${id}-1`, + num_bikes_available: 3, + num_docks_available: 7, + is_installed: true, + is_renting: true, + is_returning: true, + }, + ], + }, + }; + }, + }; + return source; +} + +/** The row as the panel draws it: state chip, count, meta text. */ +const rowText = (raw) => { + const stats = { + count: 0, + lastUpdate: null, + ...raw, + loading: raw.loading === true, + }; + const meta = LayerPanel.prototype._buildMetaText({ + id: 'bikeshare', + source: 'GBFS', + enabled: true, + lifecycleState: 'enabled', + stats, + }); + return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`; +}; + +function makeLiveLayer() { + const source = makeSource(); + const services = { + render: { governorRequestRender() {} }, + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + }; + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.ingestion = createIngestion(context); + parts.model = createModel(context); + parts.viewport = createViewport(context); + parts.rendering = createRendering(context); + parts.selection = { + _installClickHandler() {}, + _clearSelection() {}, + _onKeyDown() {}, + }; + parts.queries = { collectDetectableStations: () => [] }; + parts.lifecycle = createLifecycle(context); + const { methods: controls } = createControls(context); + + const camera = { ...LONDON, height: LOW_M }; + const viewer = { + camera: { + get positionCartographic() { + return Cesium.Cartographic.fromDegrees( + camera.lon, + camera.lat, + camera.height, + ); + }, + computeViewRectangle: () => undefined, + changed: { addEventListener() {}, removeEventListener() {} }, + }, + scene: { globe: {}, primitives: { add() {}, remove() {} } }, + }; + parts.lifecycle.methods.init(viewer); + + const painted = []; + controls.attachDataManager({ + refreshLayerStats() { + painted.push(rowText(controls.getStats())); + }, + }); + + return { + state, + source, + painted, + lastPainted: () => painted.at(-1), + live: () => rowText(controls.getStats()), + moveTo(place, height = LOW_M) { + Object.assign(camera, place, { height }); + return parts.viewport.runProximityCheck(); + }, + async enable() { + parts.lifecycle.methods.enable(viewer); + await settle(); + }, + }; +} + +async function settle() { + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setTimeout(resolve, 2)); +} + +test('entering a city paints the loading row, not "never"', async () => { + const layer = makeLiveLayer(); + const info = deferred(); + layer.source.gates.set(`${BOSTON_ID}:info`, info); + await layer.enable(); + const before = layer.painted.length; + const entering = layer.moveTo(BOSTON); + assert.ok(layer.painted.length > before, 'entering repainted'); + const atEntry = layer.lastPainted(); + const liveAfter = layer.live(); + info.resolve(); + await entering; + await settle(); + assert.match(liveAfter, /syncing 1 city feeds/); + assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading'); + assert.doesNotMatch(atEntry, /never/); +}); +test('re-entering a city with cached station info paints loading', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + await layer.moveTo(LONDON); + await settle(); + const status = deferred(); + layer.source.gates.set(`${BOSTON_ID}:status`, status); + const entering = layer.moveTo(BOSTON); + const atEntry = layer.lastPainted(); + await Promise.resolve(); + await Promise.resolve(); + const liveAfter = layer.live(); + status.resolve(); + await entering; + await settle(); + assert.match(liveAfter, /syncing 1 city feeds/); + assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading'); + assert.doesNotMatch(atEntry, /never/); +}); + +test('leaving a city mid-load does not leave the row on LOADING', async () => { + const layer = makeLiveLayer(); + layer.source.gates.set(`${BOSTON_ID}:info`, deferred()); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + await settle(); + assert.equal(layer.state._loading, true); + + await layer.moveTo(LONDON); + await entering; + await settle(); + assert.equal(layer.state._loading, false); + assert.match(layer.live(), /No bike share here yet/); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('a camera nudge during the first load still repaints the drawn stations', async () => { + const layer = makeLiveLayer(); + const status = deferred(); + layer.source.gates.set(`${BOSTON_ID}:status`, status); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + await settle(); + assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn'); + + await layer.moveTo(BOSTON_NUDGED); + status.resolve(); + await entering; + await settle(); + assert.equal(layer.state._count, 1, 'one station on the map'); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('leaving a city whose load failed repaints the error away', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BOSTON_ID); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + assert.match(layer.lastPainted(), /GBFS fetch error/i); + + await layer.moveTo(LONDON); + await settle(); + assert.match(layer.live(), /No bike share here yet/); + assert.equal(layer.lastPainted(), layer.live()); +}); + +test('leaving the failed city of two repaints DEGRADED away', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO_ID); + await layer.enable(); + await layer.moveTo(BETWEEN); + await settle(); + assert.match(layer.lastPainted(), /^degraded/); + + await layer.moveTo(TORONTO_ONLY); + await settle(); + assert.doesNotMatch(layer.live(), /^degraded/); + assert.equal(layer.lastPainted(), layer.live()); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 518c2397a..199c9b5ca 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -19,7 +19,7 @@ export function createState({ services }) { layerState._viewer = null; - /** @type {{ refreshLayerStats?: () => void }|null} Manager that repaints the layer's row. */ + /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */ layerState._dataManager = null; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index dbc328f61..008c01333 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -121,7 +121,7 @@ export function createViewport({ state: layerState, services, parts, source }) { * prompt, station count), and the manager tick is 60 s. */ function refreshRow() { - layerState._dataManager?.refreshLayerStats?.(); + layerState._dataManager?.refreshLayerStats?.('bikeshare'); } /** Deactivate all currently active cities and clear the active set. */ @@ -232,12 +232,17 @@ export function createViewport({ state: layerState, services, parts, source }) { } } // A failure never outlives its city's range. + let failureCleared = false; for (const cityId of layerState._failedCityIds) { - if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); + if (!nextActive.has(cityId)) { + layerState._failedCityIds.delete(cityId); + failureCleared = true; + } } const changed = !wasGateEnabled || + failureCleared || nextActive.size !== layerState._activeCityIds.size || [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; @@ -259,7 +264,9 @@ export function createViewport({ state: layerState, services, parts, source }) { await Promise.all( toActivate.map((cityId) => activateCity(cityId, generation)), ); - if (generation === layerState._proximityGeneration) refreshRow(); + // Superseded loads repaint too: a newer check may have painted the row + // while this one was still loading, or found nothing to load itself. + if (layerState._enabled) refreshRow(); } /** Schedule a debounced proximity check after camera movement. */ diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs new file mode 100644 index 000000000..d1a5c16b8 --- /dev/null +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -0,0 +1,98 @@ +// Transit: a manager tick that failed over Boston must not keep its error on +// the row the coverage repaint draws after the camera leaves for London. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createViewport } from './viewport.js'; +import { createQueries } from './queries.js'; +import { LayerLifecycle } from '../../data/lifecycle.js'; +import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js'; + +const rad = Math.PI / 180; +const LONDON = { lat: 51.5072, lon: -0.1276 }; +const BOSTON = { lat: 42.3601, lon: -71.0589 }; + +function viewer(at, height) { + return { + camera: { + positionCartographic: { + height, + latitude: at.lat * rad, + longitude: at.lon * rad, + }, + computeViewRectangle: () => undefined, + }, + scene: { globe: {} }, + }; +} + +test('leaving a feed whose tick failed repaints its error away', async () => { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + render: { governorRequestRender() {} }, + }; + const state = createState({ services }); + const feedStatus = (feedId) => { + if (!state._feedStatus.has(feedId)) + state._feedStatus.set(feedId, { count: 0, error: null, loading: false }); + return state._feedStatus.get(feedId); + }; + const parts = { + ingestion: { + feedStatus, + // As pollFeed's catch does when the proxy answers 503. + pollFeed(feed) { + Object.assign(feedStatus(feed.id), { + error: `${feed.name} feed unavailable`, + loading: false, + }); + return Promise.resolve(); + }, + removeFeedVehicles() {}, + sweepAgedVehicles() {}, + }, + rendering: { requestVisibility() {}, syncRenderHold() {} }, + }; + parts.viewport = createViewport({ state, services, parts }); + const queries = createQueries({ state, parts }).methods; + state._enabled = true; + + const lifecycle = new LayerLifecycle(null); + lifecycle.register({ + id: 'transit', + name: 'Transit', + source: 'GTFS-RT', + getStats: () => queries.getStats(), + async update() {}, + }); + const entry = lifecycle.layers.get('transit'); + entry.enabled = true; + entry.lifecycleState = 'enabled'; + entry.initialized = true; + + const row = () => { + const layer = lifecycle.getAll()[0]; + return `${layerFeedState(layer.stats)} | ${LayerPanel.prototype._buildMetaText(layer)}`; + }; + const painted = []; + state._dataManager = { + refreshLayerStats(layerId) { + lifecycle.refreshLayerStats(layerId); + painted.push(row()); + }, + }; + + state._viewer = viewer(BOSTON, 4_000); + parts.viewport.runProximityCheck(); + await lifecycle.refreshLayer('transit'); + assert.match(row(), /feed unavailable/, 'the tick recorded the failure'); + + state._viewer = viewer(LONDON, 4_000); + parts.viewport.runProximityCheck(); + assert.equal(queries.getStats().status, 'zoom-in'); + assert.doesNotMatch(painted.at(-1), /feed unavailable/); +}); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..569298e6e 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -112,7 +112,7 @@ export function createState({ services }) { state._cockpitVision = false; /** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */ state._styleListeners = []; - /** @type {{ refreshLayerStats?: () => void }|null} */ + /** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */ state._dataManager = null; /** @type {number|null} */ state._lastUpdate = null; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 0e3b317d4..a62d49009 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -126,7 +126,7 @@ export function createViewport({ state, services, parts }) { governorRequestRender('transit-proximity'); // The row's text follows the camera (coverage prompt, feed list), so it // repaints now instead of at the next 15 s manager tick. - if (changed) state._dataManager?.refreshLayerStats?.(); + if (changed) state._dataManager?.refreshLayerStats?.('transit'); } function onCameraChanged() { From 5c4918affb792bbfe4ac863031b3dcd55734a30c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 036/113] Tighten coverage prompt comments and docs --- .../1576-transit-bikeshare-coverage-prompt.md | 2 +- docs/CURRENT-STATE.md | 22 +++++++++---------- src/data/lifecycle.js | 5 ++--- src/layers/bikeshare/controls.js | 13 +++++------ src/layers/bikeshare/coverageStatus.test.mjs | 7 ++---- src/layers/bikeshare/rowRepaint.test.mjs | 2 -- src/layers/bikeshare/viewport.js | 5 +---- src/layers/promptRepaint.test.mjs | 6 ++--- src/layers/transit/gatePrompt.test.mjs | 6 ++--- src/layers/transit/queries.js | 5 ++--- src/layers/transit/viewport.js | 6 ++--- src/ui/statusText.js | 6 ++--- 12 files changed, 31 insertions(+), 54 deletions(-) diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index c3dcbe82a..660c1f30b 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +1,6 @@ - Say why Transit and Bike Share are empty outside coverage. Over a city with no feed or system (London, Tokyo) their Layers rows read - `No feed here yet · 16 regions available` and + `No feed here yet · 17 regions available` and `No bike share here yet · 61 systems available`, or how low to fly, instead of `never`; leaving coverage clears the last-update time (German, @gekh, #1576). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 69d83d759..13cce0050 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1975,18 +1975,16 @@ Mapped-installation zoom guidance appears in the layer row without counting as a global loading failure. Guidance statuses do not suppress independent refresh errors. -Transit and Bike Share report the same kind of guidance when no feed or system -covers the view, instead of an undated `never`. Above their altitude gates the -rows read `GTFS-RT · Fly below 2,850 km to a covered region` and -`GBFS · Fly below 48 km to a covered city`; lower down over a city neither -covers (London, Tokyo) they read `GTFS-RT · No feed here yet · N regions -available` and `GBFS · No bike share here yet · N systems available`, N being -the enabled feeds and the catalogued systems. The -chip stays ON. A Bike Share city that is loading or failed keeps its own -loading or error text. Leaving every covered feed or system clears the layer's -last-update time, so the row never dates data it no longer draws. The same -English notes are the voice analyst's empty-result notes; both surfaces show -them translated through `translateStatusMessage` (`src/ui/statusText.js`). +Transit and Bike Share say the same when no feed or system covers the view, +instead of an undated `never`. Above their altitude gates the rows read +`GTFS-RT · Fly below 2,850 km to a covered region` and +`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers +(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and +`GBFS · No bike share here yet · N systems available` (covered regions, +catalogued systems). The chip stays ON. A loading or failed Bike Share city +shows its own text. Leaving coverage clears the last-update time. The voice +analyst gets the same English notes; both translate them through +`translateStatusMessage` (`src/ui/statusText.js`). ## Places and CCTV request bounds diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c01926451..3ea02eca9 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2393,9 +2393,8 @@ export class LayerLifecycle { } /** - * A tick's failure belongs to the feed it polled. Once the layer's own stats - * stop reporting one (the camera left that feed), the row stops showing it - * instead of waiting for the next tick. + * Drop a tick failure the layer's stats no longer report (the camera left + * that feed) instead of showing it until the next tick. */ _dropRecoveredRefreshError(layerId) { const entry = layerId ? this.layers.get(layerId) : null; diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 03bb544ab..d90a62b5b 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -15,8 +15,7 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: STATUS_POLL_MS, /** - * Keep the manager so camera-driven coverage changes can repaint the row - * before the next status poll. + * Kept so camera moves can repaint the row before the next status poll. * @param {object} dataManager DataLayerManager instance. */ attachDataManager(dataManager) { @@ -84,18 +83,16 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, }; - // No system in range is guidance, not an empty feed: the camera is too - // high, or no catalogued system is near the view. A city that failed - // leaves range empty too, and reports its failure below instead. + // No system in range: the camera is too high or no system is near. A + // failed city also leaves range empty and reports its failure below. if ( layerState._enabled && !loading && layerState._activeCityIds.size === 0 && layerState._failedCityIds.size === 0 ) { - // English data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // English: the analyst's empty note and the row's prompt, both shown + // through translateStatusMessage (ui/statusText.js). const note = layerState._altitudeGateEnabled ? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available` : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`; diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs index a9f17e73e..004c75c94 100644 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ b/src/layers/bikeshare/coverageStatus.test.mjs @@ -1,10 +1,7 @@ // BIKESHARE COVERAGE STATUS — a view with no system says so. // -// Over a city with no catalogued system (London, Tokyo) the layer drew nothing -// and reported nothing, so its row read "ON · — · GBFS · never": the same as a -// feed that had never answered. These cases pin the rule that replaced it: -// with no system in range the layer reports a guidance status that says why, -// and a view it left keeps no date for data it no longer draws. +// With no system in range (London, Tokyo) the layer says why instead of +// "never", and keeps no date for data it no longer draws. // // Run with: npm test import { test } from 'node:test'; diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 144ff5502..5f5538168 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,5 +1,3 @@ -// Bike Share row repaints: entering a city, leaving it or nudging the camera -// mid-load, and leaving a failed city. // Bike Share: after the camera settles, the last row the panel painted must // match what the layer reports now. Each case drives the real viewport, // ingestion, rendering and controls; the manager is a spy that records the diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 008c01333..d4cc44cb6 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -116,10 +116,7 @@ export function createViewport({ state: layerState, services, parts, source }) { parts.rendering.removeCityPoints(cityId); } - /** - * Repaint the layer's row now. Its text follows the camera (coverage - * prompt, station count), and the manager tick is 60 s. - */ + /** Repaint the row now: it follows the camera, the manager tick is 60 s. */ function refreshRow() { layerState._dataManager?.refreshLayerStats?.('bikeshare'); } diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs index 563aaeac7..38193ca72 100644 --- a/src/layers/promptRepaint.test.mjs +++ b/src/layers/promptRepaint.test.mjs @@ -1,7 +1,5 @@ -// When the camera changes which coverage prompt a Transit or -// Bike Share row shows, the layer asks the panel to repaint at once, instead -// of leaving the old prompt up until the manager's next tick (Transit 15 s, -// Bike Share 60 s). +// A camera move that changes a Transit or Bike Share coverage prompt repaints +// the row at once, not at the next manager tick (15 s, 60 s). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState as createBikeState } from './bikeshare/state.js'; diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs index 7f439fcdc..dc4c3e863 100644 --- a/src/layers/transit/gatePrompt.test.mjs +++ b/src/layers/transit/gatePrompt.test.mjs @@ -1,7 +1,5 @@ -// The Transit coverage prompt names the altitude the closed gate -// actually opens at. Descending over Boston to 2,900 km (below 3,000 km, above -// the 2,850 km enter threshold) loads nothing, so a prompt "Fly below 3,000 km" -// asks for something the camera has already done. +// The Transit prompt names the altitude the gate opens at: at 2,900 km over +// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createState } from './state.js'; diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 2efe928d5..7a9f2e074 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -245,9 +245,8 @@ export function createQueries({ state, parts }) { const health = aggregateTransitFeedHealth(statuses, now); const count = state._vehicles.size; if (state._enabled && active.length === 0) { - // English data (the analyst's empty note to the model, and the Layers - // row's prompt); both show it through translateStatusMessage - // (ui/statusText.js). + // English: the analyst's empty note and the row's prompt, both shown + // through translateStatusMessage (ui/statusText.js). const note = state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available` : `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index a62d49009..38315e4ae 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -111,8 +111,7 @@ export function createViewport({ state, services, parts }) { changed = true; } } - // Leaving coverage leaves nothing on the map to date: a row that read - // "26s ago" over an empty view would claim a feed that is no longer here. + // Nothing is left on the map to date. if (desired.size === 0) state._lastUpdate = null; for (const [feedId, feed] of desired) { if (!state._activeFeeds.has(feedId)) { @@ -124,8 +123,7 @@ export function createViewport({ state, services, parts }) { parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); governorRequestRender('transit-proximity'); - // The row's text follows the camera (coverage prompt, feed list), so it - // repaints now instead of at the next 15 s manager tick. + // Repaint now: the row follows the camera, the manager tick is 15 s. if (changed) state._dataManager?.refreshLayerStats?.('transit'); } diff --git a/src/ui/statusText.js b/src/ui/statusText.js index f4cbb59a0..d13faff7e 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -269,10 +269,8 @@ export function translateStatusLine(text) { /** Thousands separators out of an English number ("3,000" → 3000). */ const englishNumber = (text) => Number(text.replace(/,/g, '')); -// Coverage notes of a layer whose view has no feed, whole lines with a number -// in them: transit (transit/queries.js) and bikeshare (bikeshare/controls.js). -// Their " · " is part of one sentence, so they are matched before the line is -// split into pieces. Keep these in step with the producers. +// Coverage notes (transit/queries.js, bikeshare/controls.js). Their " · " is +// inside one sentence, so they match whole before the line is split. const COVERAGE_NOTE_PATTERNS = [ [ /^Fly below ([\d,]+) km to a covered region$/, From c9f40a837cd08d025d5c1d58d31d3dff5ef37561 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:20:11 +0400 Subject: [PATCH 037/113] Tighten callout fade comments and docs --- docs/CURRENT-STATE.md | 2 +- src/data/detection.js | 9 +++------ src/data/detectionHost.test.mjs | 6 ++---- src/layers/vessels/queries.js | 3 +-- src/layers/vessels/records.js | 3 +-- src/overlays/worldOverlay.js | 11 +++++------ src/overlays/worldOverlay.test.mjs | 14 ++++++-------- 7 files changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c4d66de8a..6d9193078 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5483,7 +5483,7 @@ silently demoting every later lookup for the session. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. -- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's detection callout fades out while its own ambient card paints in the same frame, judged by the card's painted opacity including the keyhole fade, so a card dropped by budget, collisions or UI chrome, or faded out at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. +- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. diff --git a/src/data/detection.js b/src/data/detection.js index f4d1ddf3b..b4dc29522 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -110,10 +110,7 @@ const THEME_MAP = DETECTION_THEME_MAP; const SPARSE_ZONE_FRACTION = 0.5; /** Solve cadence for membership/collision; accepted identities still reproject every frame. */ const LABEL_SOLVE_INTERVAL_MS = 125; -/** - * A contact's own card at this opacity or more hides its callout; below it the - * callout fades in, so a card fading in or out never snaps the callout. - */ +/** Own-card opacity that fully hides a callout; below it the callout fades in. */ const CARD_ALPHA_HIDING_CALLOUT = 0.5; /** Alpha bands retain bracket batching while approximating a continuous radial fade. */ const BRACKET_ALPHA_STEPS = 4; @@ -1149,8 +1146,8 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - // Under the contact's own card (an ambient vessel card) the callout would - // show through the plate. Only a card painting this frame counts. + // The callout would show through its contact's own card: fade it by the + // opacity that card paints with this frame. const cardAlpha = row.cardEntryId === '' ? 0 diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 4c1c6dddc..f479869f9 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -589,10 +589,8 @@ test('every style paints its OWN plate token through the production callout path }); test('a callout gives way to its contact\'s own card only while that card paints', () => { - // A vessel's ambient card already shows its name, speed and course, and the - // callout drawn beneath it shows through the card's plate. The host paints - // only part of the published cards (shared budget, collisions, UI chrome), - // so a contact whose card was published but not painted keeps its callout. + // The callout shows through its card's plate. The host paints only some + // published cards (budget, collisions, UI chrome); an unpainted one keeps it. const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); try { const ships = [ diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index f26ada86b..0be276913 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -505,8 +505,7 @@ export function createQueries({ id: record.name || record.mmsi || 'VESSEL', type: 'SEA', skipLabel: record === selected, - // The vessel's ambient card: while the overlay paints it, detection - // draws no callout under it. + // Detection hides the callout while this card paints. cardSourceId: VESSEL_OVERLAY_SOURCE_ID, cardEntryId: components.cards.vesselOverlayEntryId(record), klass: record.type diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index aeb2f39bf..f6f581784 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,8 +23,7 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, - // Filled on first use by the cards' `vesselOverlayEntryId`. The id reads - // only the MMSI, or for an unkeyed row fields it never updates. + // Cached by `vesselOverlayEntryId`; the fields it reads never change. overlayEntryId: null, }; } diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index c569e03b8..71f4b849f 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1047,9 +1047,9 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * Opacity an entry will paint with in the frame being painted, or 0 when it is - * not queued. Valid from the custom lanes, which paint before the entries in - * later lanes publish their paint rects. Leaves out only the Cyber sonar fade. + * Opacity an entry paints with this frame, or 0 when it is not queued. Custom + * lanes may call it before later lanes publish paint rects. Leaves out the + * Cyber sonar fade and yield-collision hiding. * @param {string} sourceId * @param {string} entryId * @returns {number} @@ -2253,9 +2253,8 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.collisionHidden = false; item.stamp = _frameStamp; _paintQueue[_paintCount++] = item; - // Indexed by source and id rather than by `record.key`, so a lookup builds - // no string. A card fading out after it left its source's entries is still - // queued here, though `_records` no longer has it. + // Keyed by source and id, not `record.key`, so a lookup builds no string. + // Also holds cards fading out after they left `_records`. let bySource = _queuedItemBySource.get(record.entry.source); if (!bySource) { bySource = new Map(); diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index dfc82cebd..1b8223bbc 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1224,9 +1224,8 @@ test('custom detection lane receives the shared host frame and paints below ordi }); test('a detection lane sees which cards this frame will paint before they publish paint rects', () => { - // The detection lane paints first, so a card's paint rectangle for this - // frame does not exist yet when it runs. The queued alpha is what lets a - // contact's callout give way to that contact's own card in the same frame. + // The detection lane paints before the cards publish this frame's paint + // rects, so it reads the queued alpha instead. const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); initWorldOverlay(env.viewer); const seen = []; @@ -2529,8 +2528,8 @@ test('promoting an ambient entry to selected paints it once with no fading dupli }); test('the queued-card index stays bounded while card identities churn', () => { - // The index is internal: capture the per-source map the same way the - // projection-record test captures its map, through Map.prototype.set. + // The index is internal: capture it through Map.prototype.set, like the + // projection-record test. const originalSet = Map.prototype.set; let queuedIndex = null; Map.prototype.set = function captureQueuedIndex(key, value) { @@ -2546,9 +2545,8 @@ test('the queued-card index stays bounded while card identities churn', () => { initWorldOverlay(env.viewer); const FRAMES = 1300; for (let frame = 0; frame < FRAMES; frame++) { - // One painted card whose identity changes every frame: the same pooled - // paint item serves every id (a vessel cohort republished as the camera - // moves, a CCTV or radio card swapping). + // One card whose id changes every frame reuses one pooled paint item, + // like a vessel cohort republished as the camera moves. setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, { position: positionAtScreen(200, 150), })]); From ea76fc4bc07327b9d8297aa6b2663660ca0d5947 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 038/113] Fold duplicate coverage tests into their neighbours --- .../1576-transit-bikeshare-coverage-prompt.md | 8 +- src/data/manager.js | 12 -- src/layers/bikeshare/coverageStatus.test.mjs | 121 ------------------ src/layers/bikeshare/rowRepaint.test.mjs | 79 +++++++++++- src/layers/bikeshare/viewport.js | 4 +- src/layers/promptRepaint.test.mjs | 120 ----------------- src/layers/transit/gatePrompt.test.mjs | 64 --------- src/layers/transit/lifecycle.test.mjs | 12 ++ .../transit/refreshErrorRepaint.test.mjs | 2 + 9 files changed, 94 insertions(+), 328 deletions(-) delete mode 100644 src/layers/bikeshare/coverageStatus.test.mjs delete mode 100644 src/layers/promptRepaint.test.mjs delete mode 100644 src/layers/transit/gatePrompt.test.mjs diff --git a/changelog.d/1576-transit-bikeshare-coverage-prompt.md b/changelog.d/1576-transit-bikeshare-coverage-prompt.md index 660c1f30b..4d13d8d3e 100644 --- a/changelog.d/1576-transit-bikeshare-coverage-prompt.md +++ b/changelog.d/1576-transit-bikeshare-coverage-prompt.md @@ -1,6 +1,4 @@ - Say why Transit and Bike Share are empty outside coverage. Over a city with - no feed or system (London, Tokyo) their Layers rows read - `No feed here yet · 17 regions available` and - `No bike share here yet · 61 systems available`, or how low to fly, instead - of `never`; leaving coverage clears the last-update time (German, @gekh, - #1576). + no feed or system (London, Tokyo) their Layers rows say so and count the + covered regions or systems, or say how low to fly, instead of `never`; + leaving coverage clears the last-update time (German, @gekh, #1576). diff --git a/src/data/manager.js b/src/data/manager.js index 688fb94d2..5de10f665 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -15,18 +15,6 @@ export class DataLayerManager extends LayerLifecycle { this._presentation.refresh(); } - /** - * Repaint the toggle panel now. For layers whose data arrives outside their - * manager tick (camera-driven loads such as Transit's proximity polls), so a - * row shows its count when the data lands instead of at the next interval. - * One DOM pass; skipped while the document is hidden. - * @param {string} [layerId] - The layer whose row changed. - */ - refreshLayerStats(layerId) { - this._dropRecoveredRefreshError(layerId); - this._refreshTogglePanel(); - } - _buildMetaText(layer) { return this._presentation.panel._buildMetaText(layer); } diff --git a/src/layers/bikeshare/coverageStatus.test.mjs b/src/layers/bikeshare/coverageStatus.test.mjs deleted file mode 100644 index 004c75c94..000000000 --- a/src/layers/bikeshare/coverageStatus.test.mjs +++ /dev/null @@ -1,121 +0,0 @@ -// BIKESHARE COVERAGE STATUS — a view with no system says so. -// -// With no system in range (London, Tokyo) the layer says why instead of -// "never", and keeps no date for data it no longer draws. -// -// Run with: npm test -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState } from './state.js'; -import { createControls } from './controls.js'; -import { createModel } from './model.js'; -import { createViewport } from './viewport.js'; -import { GBFS_CITY_REGISTRY } from './registry.js'; - -const LONDON = { lat: 51.5072, lon: -0.1276 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createModel({ state, services, parts: {}, source: null }), - ingestion: { abortInFlight() {} }, - rendering: { removeCityPoints() {} }, - }; - parts.viewport = createViewport({ state, services, parts, source: null }); - const { methods } = createControls({ state, services, parts, source: {} }); - state._enabled = true; - return { state, methods, viewport: parts.viewport }; -} - -/** A viewer looking straight down at `at` from `height` metres. */ -function viewerAt(at, height) { - const rad = Math.PI / 180; - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -test('above the altitude gate the row says how low to fly', async () => { - const { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 2_000_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - assert.equal(stats.status, 'zoom-in'); - assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); - assert.equal(stats.error, undefined); -}); - -test('a city with no system says so and counts the ones there are', async () => { - const { state, methods, viewport } = makeLayer(); - state._viewer = viewerAt(LONDON, 8_000); - await viewport.runProximityCheck(); - - const stats = methods.getStats(); - assert.equal(stats.status, 'zoom-in'); - assert.equal( - stats.statusMessage, - `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, - ); - assert.equal(stats.count, 0); -}); - -test('a city with a system in range is not given the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._activeCityIds.add('boston-bluebikes'); - - assert.equal(methods.getStats().status, undefined); -}); - -test('a loading or failed city reports itself, not the prompt', () => { - const { state, methods } = makeLayer(); - state._altitudeGateEnabled = true; - state._loading = true; - assert.equal(methods.getStats().status, undefined); - - // A city that failed is dropped from range, and still owns the row. - state._loading = false; - state._failedCityIds.add('boston-bluebikes'); - const stats = methods.getStats(); - assert.equal(stats.status, undefined); - assert.equal(stats.error, 'GBFS fetch error'); -}); - -test('a disabled layer reports no prompt', () => { - const { state, methods } = makeLayer(); - state._enabled = false; - assert.equal(methods.getStats().status, undefined); -}); - -test('leaving every system behind drops the date of the data it drew', async () => { - for (const height of [8_000, 2_000_000]) { - const { state, methods, viewport } = makeLayer(); - state._lastUpdate = Date.now(); - // Boston's stations were drawn and dated; the camera moves to London. - state._activeCityIds.add('boston-bluebikes'); - state._cityRuntime.set('boston-bluebikes', {}); - state._viewer = viewerAt(LONDON, height); - await viewport.runProximityCheck(); - - assert.equal(state._activeCityIds.size, 0); - assert.equal(methods.getStats().lastUpdate, null, `at ${height} m`); - } -}); diff --git a/src/layers/bikeshare/rowRepaint.test.mjs b/src/layers/bikeshare/rowRepaint.test.mjs index 5f5538168..4bfea999c 100644 --- a/src/layers/bikeshare/rowRepaint.test.mjs +++ b/src/layers/bikeshare/rowRepaint.test.mjs @@ -1,11 +1,13 @@ -// Bike Share: after the camera settles, the last row the panel painted must -// match what the layer reports now. Each case drives the real viewport, +// Bike Share's Layers row. With no system in range it says why instead of +// "never" and keeps no date for data it no longer draws. After the camera +// settles, the last row the panel painted must match what the layer reports +// now. Each case drives the real viewport, // ingestion, rendering and controls; the manager is a spy that records the // row text (LayerPanel._buildMetaText) at every refreshLayerStats() call. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { CITY_BY_ID } from './registry.js'; +import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js'; import { createState } from './state.js'; import { createControls } from './controls.js'; import { createIngestion } from './ingestion.js'; @@ -175,6 +177,7 @@ function makeLiveLayer() { source, painted, lastPainted: () => painted.at(-1), + stats: () => controls.getStats(), live: () => rowText(controls.getStats()), moveTo(place, height = LOW_M) { Object.assign(camera, place, { height }); @@ -291,3 +294,73 @@ test('leaving the failed city of two repaints DEGRADED away', async () => { assert.doesNotMatch(layer.live(), /^degraded/); assert.equal(layer.lastPainted(), layer.live()); }); + +test('above the altitude gate the row says how low to fly', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(LONDON, 2_000_000); + const stats = layer.stats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city'); + assert.equal(stats.error, undefined); +}); + +test('a city with no system says so and counts the ones there are', async () => { + const layer = makeLiveLayer(); + await layer.enable(); + const stats = layer.stats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.statusMessage, + `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`, + ); + assert.equal(stats.count, 0); +}); + +test('a city in range, loading, failed or a disabled layer gets no prompt', async () => { + const layer = makeLiveLayer(); + assert.equal(layer.stats().status, undefined, 'disabled'); + + const info = deferred(); + layer.source.gates.set(`${BOSTON_ID}:info`, info); + await layer.enable(); + const entering = layer.moveTo(BOSTON); + assert.equal(layer.stats().status, undefined, 'loading'); + info.resolve(); + await entering; + await settle(); + assert.equal(layer.stats().status, undefined, 'loaded'); + + // Left mid-load: range is empty, but the aborted load still owns the row. + const leaving = makeLiveLayer(); + leaving.source.gates.set(`${BOSTON_ID}:info`, deferred()); + await leaving.enable(); + const loading = leaving.moveTo(BOSTON); + await settle(); + const left = leaving.moveTo(LONDON); + assert.equal(leaving.state._activeCityIds.size, 0); + assert.equal(leaving.stats().loading, true); + assert.equal(leaving.stats().status, undefined, 'leaving mid-load'); + await Promise.all([loading, left]); + + const failed = makeLiveLayer(); + failed.source.dead.add(BOSTON_ID); + await failed.enable(); + await failed.moveTo(BOSTON); + await settle(); + assert.equal(failed.stats().status, undefined, 'failed'); + assert.equal(failed.stats().error, 'GBFS fetch error'); +}); + +test('leaving every system behind drops the date of the data it drew', async () => { + for (const height of [LOW_M, 2_000_000]) { + const layer = makeLiveLayer(); + await layer.enable(); + await layer.moveTo(BOSTON); + await settle(); + assert.ok(layer.stats().lastUpdate, 'Boston is dated'); + + await layer.moveTo(LONDON, height); + assert.equal(layer.stats().lastUpdate, null, `at ${height} m`); + } +}); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index d4cc44cb6..46b6b7d12 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -243,11 +243,9 @@ export function createViewport({ state: layerState, services, parts, source }) { nextActive.size !== layerState._activeCityIds.size || [...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId)); layerState._activeCityIds = nextActive; - if (layerState._activeCityIds.size === 0) { + if (nextActive.size === 0) { layerState._count = 0; layerState._lastUpdate = null; - if (changed) refreshRow(); - return; } if (changed) refreshRow(); diff --git a/src/layers/promptRepaint.test.mjs b/src/layers/promptRepaint.test.mjs deleted file mode 100644 index 38193ca72..000000000 --- a/src/layers/promptRepaint.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// A camera move that changes a Transit or Bike Share coverage prompt repaints -// the row at once, not at the next manager tick (15 s, 60 s). -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState as createBikeState } from './bikeshare/state.js'; -import { createControls as createBikeControls } from './bikeshare/controls.js'; -import { createModel as createBikeModel } from './bikeshare/model.js'; -import { createViewport as createBikeViewport } from './bikeshare/viewport.js'; -import { createState as createTransitState } from './transit/state.js'; -import { createViewport as createTransitViewport } from './transit/viewport.js'; -import { createQueries as createTransitQueries } from './transit/queries.js'; - -const rad = Math.PI / 180; -const LONDON = { lat: 51.5072, lon: -0.1276 }; -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -const services = () => ({ - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, -}); - -function viewer(at, height) { - return { - camera: { - positionCartographic: { - height, - latitude: at.lat * rad, - longitude: at.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; -} - -function spyManager() { - const dm = { repaints: 0 }; - dm.refreshLayerStats = () => { - dm.repaints++; - }; - return dm; -} - -test('Bike Share: flying from London into Boston repaints the prompt row', async () => { - const svc = services(); - const state = createBikeState({ services: svc }); - const parts = { - queries: { collectDetectableStations: () => [] }, - model: createBikeModel({ state, services: svc, parts: {}, source: null }), - ingestion: { - abortInFlight() {}, - loadCityStationInfo: async () => new Map(), - loadCityStationStatus: async () => new Map(), - }, - rendering: { - removeCityPoints() {}, - ensureCityPoints() {}, - applyStatusToPoints() {}, - }, - }; - parts.viewport = createBikeViewport({ - state, - services: svc, - parts, - source: null, - }); - const { methods } = createBikeControls({ - state, - services: svc, - parts, - source: {}, - }); - state._enabled = true; - const dm = spyManager(); - methods.attachDataManager(dm); - - state._viewer = viewer(LONDON, 8_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, 'zoom-in'); - - const before = dm.repaints; - state._viewer = viewer(BOSTON, 3_000); - await parts.viewport.runProximityCheck(); - assert.equal(methods.getStats().status, undefined, 'Boston is covered'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); - -test('Transit: leaving Boston for London repaints the row', () => { - const svc = services(); - const state = createTransitState({ services: svc }); - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed() {}, - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createTransitViewport({ state, services: svc, parts }); - const queries = createTransitQueries({ state, parts }).methods; - state._enabled = true; - const dm = spyManager(); - state._dataManager = dm; - - state._viewer = viewer(BOSTON, 4_000); - parts.viewport.runProximityCheck(); - assert.ok(state._activeFeeds.size > 0, 'Boston has a feed'); - state._lastUpdate = Date.now(); - - const before = dm.repaints; - state._viewer = viewer(LONDON, 4_000); - parts.viewport.runProximityCheck(); - assert.equal(queries.getStats().status, 'zoom-in'); - assert.ok(dm.repaints > before, 'the row was asked to repaint'); -}); diff --git a/src/layers/transit/gatePrompt.test.mjs b/src/layers/transit/gatePrompt.test.mjs deleted file mode 100644 index dc4c3e863..000000000 --- a/src/layers/transit/gatePrompt.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -// The Transit prompt names the altitude the gate opens at: at 2,900 km over -// Boston (under 3,000 km, above the 2,850 km enter threshold) nothing loads. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createState } from './state.js'; -import { createViewport } from './viewport.js'; -import { createQueries } from './queries.js'; -import { ACTIVATION_ENTER_ALTITUDE_M } from './policy.js'; - -const BOSTON = { lat: 42.3601, lon: -71.0589 }; - -function makeLayer() { - const services = { - overlays: { - setOverlayEntries() {}, - setOverlaySourceVisible() {}, - clearOverlaySource() {}, - }, - render: { governorRequestRender() {} }, - }; - const state = createState({ services }); - const polled = []; - const parts = { - ingestion: { - feedStatus: () => ({ count: 0 }), - pollFeed: (feed) => polled.push(feed.id), - removeFeedVehicles() {}, - sweepAgedVehicles() {}, - }, - rendering: { requestVisibility() {}, syncRenderHold() {} }, - }; - parts.viewport = createViewport({ state, services, parts }); - const queries = createQueries({ state, parts }).methods; - state._enabled = true; - const rad = Math.PI / 180; - state._viewer = { - camera: { - positionCartographic: { - height: 0, - latitude: BOSTON.lat * rad, - longitude: BOSTON.lon * rad, - }, - computeViewRectangle: () => undefined, - }, - scene: { globe: {} }, - }; - return { state, queries, viewport: parts.viewport, polled }; -} - -test('below the prompted altitude but above the gate, the prompt names the gate', () => { - const { state, queries, viewport, polled } = makeLayer(); - state._viewer.camera.positionCartographic.height = 2_900_000; - viewport.runProximityCheck(); - - // Nothing loads at 2,900 km: the gate opens at the enter threshold. - assert.equal(state._altitudeGateOpen, false); - assert.deepEqual(polled, []); - - const km = Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString( - 'en-US', - ); - const { statusMessage } = queries.getStats(); - assert.equal(statusMessage, `Fly below ${km} km to a covered region`); -}); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index dc18fd6f0..4711eebdf 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4021,3 +4021,15 @@ test('a view with no feed reads its coverage prompt, not an undated feed', async 'Fly below 2,850 km to a covered region', ); }); + +test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) => { + // 2,900 km is under ACTIVATION_ALTITUDE_M but above the enter threshold. + const app = harness(t, { altitude: 2_900_000 }); + app.layer.enable(app.viewer); + await app.layer.update(); + assert.deepEqual(app.requested, [], 'nothing loads over Boston'); + assert.equal( + app.layer.getStats().statusMessage, + 'Fly below 2,850 km to a covered region', + ); +}); diff --git a/src/layers/transit/refreshErrorRepaint.test.mjs b/src/layers/transit/refreshErrorRepaint.test.mjs index d1a5c16b8..d85a4a375 100644 --- a/src/layers/transit/refreshErrorRepaint.test.mjs +++ b/src/layers/transit/refreshErrorRepaint.test.mjs @@ -91,8 +91,10 @@ test('leaving a feed whose tick failed repaints its error away', async () => { await lifecycle.refreshLayer('transit'); assert.match(row(), /feed unavailable/, 'the tick recorded the failure'); + const before = painted.length; state._viewer = viewer(LONDON, 4_000); parts.viewport.runProximityCheck(); assert.equal(queries.getStats().status, 'zoom-in'); + assert.ok(painted.length > before, 'leaving Boston repainted the row'); assert.doesNotMatch(painted.at(-1), /feed unavailable/); }); From 4c62435910a191403dc7368ed9d31b5cbafd759e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 22:01:39 +0400 Subject: [PATCH 039/113] Drop the duplicate callout test and the card id cache --- src/data/aisLiveVessels.test.mjs | 1 - src/data/detection.js | 1 - src/data/detectionHost.test.mjs | 65 +++----------------------------- src/layers/vessels/cards.js | 8 ---- src/layers/vessels/records.js | 2 - 5 files changed, 5 insertions(+), 72 deletions(-) diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index e1c2fcc5a..edb3505f2 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1821,7 +1821,6 @@ test('detection objects name the vessel card their callout gives way to', () => 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', 'the same identity the vessel card is published under', ); - assert.equal(keyed.overlayEntryId, 'vessel:200', 'cached on the record for later frames'); } finally { _setVesselStateForTest({ enabled: false }); } diff --git a/src/data/detection.js b/src/data/detection.js index b4dc29522..0ec9e9ba2 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1114,7 +1114,6 @@ function _stashCallout(entry, acquireFade, keyhole) { row.alpha = alpha; row.cardSourceId = candidate.cardSourceId; row.cardEntryId = candidate.cardEntryId; - row.paintAlpha = alpha; _calloutCount++; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index f479869f9..db01ed18a 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -19,12 +19,10 @@ import { suspendDetection, } from './detection.js'; import { - clearOverlaySource, destroyWorldOverlay, getOverlayPaintRect, initWorldOverlay, setOverlayEntries, - setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { applyVesselOverlayPolicy } from './vesselLabels.js'; import { @@ -588,64 +586,6 @@ test('every style paints its OWN plate token through the production callout path } }); -test('a callout gives way to its contact\'s own card only while that card paints', () => { - // The callout shows through its card's plate. The host paints only some - // published cards (budget, collisions, UI chrome); an unpainted one keeps it. - const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); - try { - const ships = [ - { x: -0.3, sourceId: 'carded', id: 'SHIP-CARDED', card: 'vessel:carded' }, - { x: 0.3, sourceId: 'dropped', id: 'SHIP-DROPPED', card: 'vessel:dropped' }, - ].map(({ x, sourceId, id, card }) => ({ - position: new Cesium.Cartesian3(x, 0, 6_356_752), - sourceId, - id, - metric: '6 kn', - type: 'SEA', - cardSourceId: card === 'vessel:carded' ? 'vessel-cards' : 'dropped-cards', - cardEntryId: card, - })); - initWorldOverlay(env.viewer); - initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {}); - const card = (id, title, x) => ({ - id, - position: new Cesium.Cartesian3(x, 0, 6_356_752), - variant: 'label', - title, - selected: true, - protected: true, - horizonCull: false, - edgeFade: 'none', - }); - setOverlayEntries('vessel-cards', [card('vessel:carded', 'CARD-CARDED', -0.3)]); - // Published, but the host does not paint it this frame. - setOverlayEntries('dropped-cards', [card('vessel:dropped', 'CARD-DROPPED', 0.3)]); - setOverlaySourceVisible('dropped-cards', false); - setMode('DENSE'); - settleFrame(env); - env.ctx.calls.length = 0; - settleFrame(env); - - const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); - assert.ok(painted('CARD-CARDED'), 'the fixture card reached the frame'); - assert.ok(!painted('CARD-DROPPED'), 'the hidden source paints no card'); - assert.ok(!painted('SHIP-CARDED'), 'no callout under the painted card'); - assert.ok(painted('SHIP-DROPPED'), 'the contact whose card did not paint keeps its callout'); - assert.equal(getDetectionDiagnostics().calloutRects.length, 1, - 'only the callout that painted is reported'); - - // The card leaves the host: the callout comes back on the next frame. - clearOverlaySource('vessel-cards'); - env.ctx.calls.length = 0; - settleFrame(env); - assert.ok(painted('SHIP-CARDED'), 'the callout returns once its card stops painting'); - } finally { - destroyDetection(); - destroyWorldOverlay(); - env.cleanup(); - } -}); - /** A vessel detection contact that names its ambient card, like the AIS layer does. */ function shipWithCard(mmsi, position) { return { @@ -776,6 +716,11 @@ test('a vessel whose ambient card loses the collision keeps its callout', () => carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`, ); } + assert.equal( + getDetectionDiagnostics().calloutRects.length, + ships.length - winners.length, + 'only the callouts that painted are reported', + ); } finally { destroyDetection(); destroyWorldOverlay(); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 2563bf420..bcf3459f1 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -117,14 +117,6 @@ export function createCards({ /** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { - // Cached on the record: detection asks for it every frame for every vessel. - if (record?.overlayEntryId) return record.overlayEntryId; - const id = composeVesselOverlayEntryId(record); - if (record) record.overlayEntryId = id; - return id; - } - - function composeVesselOverlayEntryId(record) { const mmsi = String(record?.mmsi || '').trim(); if (mmsi) return `vessel:${mmsi}`; const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index f6f581784..ad6d85cbd 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,8 +23,6 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, - // Cached by `vesselOverlayEntryId`; the fields it reads never change. - overlayEntryId: null, }; } From b86c650428db01c0b09fadda54eabbf523ca506b Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:03 -0500 Subject: [PATCH 040/113] i18n(fr): typography, terminology and wording fixes --- src/i18n/locales/fr.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 738800324..485d3a93a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1018,11 +1018,11 @@ "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", "Box must enclose an area": "La zone doit délimiter une surface", - "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", - "Loading the Starlink shell…": "Chargement de la constellation Starlink…", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la couche de la constellation Starlink (des milliers de points en plus)", + "Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", "load failed": "échec du chargement", - "Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement", + "Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement", "DENSE ···": "DENSE ···", "DENSE ✕": "DENSE ✕", "DENSE": "DENSE", @@ -1105,7 +1105,7 @@ "Hybrid": "Hybride", "Lines": "Lignes", "Dots": "Points", - "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", @@ -1121,7 +1121,7 @@ "LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue", "LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %", - "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %", @@ -1218,7 +1218,7 @@ "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", - "Color: {units}": "Couleur : {units}", + "Color: {units}": "Couleur : {units}", "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", @@ -1350,7 +1350,7 @@ "{label} is unavailable": "{label} est indisponible", "This map stack": "Ce fond de carte", "{label} unavailable: {hint}": "{label} indisponible : {hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau", "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion", "Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra", "Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu", @@ -1489,7 +1489,7 @@ "Apply details": "Appliquer les détails", "Select a scene first": "Sélectionnez d'abord une scène", "Share selected scene": "Partager la scène sélectionnée", - "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", "Download scene JSON": "Télécharger le JSON de scène", "Choose data-pack files": "Choisir des fichiers de paquet de données", "Or choose a data-pack folder": "Ou choisir un dossier de paquet de données", @@ -2574,7 +2574,7 @@ "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée sur le flux {source}, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but its aircraft feed is unavailable": "La couche {layer} est activée, mais son flux d'aéronefs est indisponible", - "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais son actualisation à destination ne s'est pas terminée", + "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais l'actualisation de ses destinations ne s'est pas terminée", "{layer} is enabled, but {source} is unavailable": "La couche {layer} est activée, mais {source} est indisponible", "Could not arrive at {place}": "Impossible d'atteindre {place}", "{name} is no longer loaded": "{name} n'est plus chargé", @@ -2810,7 +2810,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", @@ -2831,7 +2831,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous demandez sa lecture à la voix. Votre IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", @@ -3194,7 +3194,7 @@ "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", "SIGHTING": "OBSERVATION", - "Show {name} sighting": "Afficher l’observation de {name}", + "Show {name} sighting": "Afficher l'observation de {name}", "seen {age} ago": "vu il y a {age}", "observed {age} ago": "observé il y a {age}", "Be Whale Wise": "Respectez les cétacés", @@ -3232,7 +3232,7 @@ "Cetacean": "Cétacé", "Wildlife Sightings": "Observations naturalistes", "Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation", - "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation", + "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation", "Wildlife": "Faune et flore", "Zoom in for local sightings": "Zoomez pour les observations locales", "Loading photo...": "Chargement de la photo…", @@ -3242,18 +3242,18 @@ "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "{count} observations dans la vue", - "Close sighting": "Fermer l’observation", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", + "Close sighting": "Fermer l'observation", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", - "{days}d": "{days}j", + "{days}d": "{days} j", "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", - "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "event age filter|All": "Tous", "Event age": "Ancienneté", - "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } From 36c29d6ab49ada6ae6b89f912a10d6640e96ded5 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:15 -0500 Subject: [PATCH 041/113] changelog: french catalog review --- changelog.d/1611-fr-catalog-review.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1611-fr-catalog-review.md diff --git a/changelog.d/1611-fr-catalog-review.md b/changelog.d/1611-fr-catalog-review.md new file mode 100644 index 000000000..dad302092 --- /dev/null +++ b/changelog.d/1611-fr-catalog-review.md @@ -0,0 +1,2 @@ +- French catalog review: missing narrow no-break spaces, apostrophes, one + mistranslated message and the Starlink shell wording (abiyeenzo, #1611). From c5bdcdf37f575543deea0424a46db1b221046e8c Mon Sep 17 00:00:00 2001 From: daika Date: Sun, 8 Nov 2026 04:02:47 +0700 Subject: [PATCH 042/113] chore(format): adopt responseCompression.test.mjs into the format scope --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b23ab75b0..6c5354aa2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1120,6 +1120,7 @@ "src/sky/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs", + "src/tooling/responseCompression.test.mjs", "scripts/qa-anime-sky.mjs", "scripts/qa-sky-model-lighting.mjs", "server/providers/overpass/requestAdmission.test.mjs", From e0ba12e5a9db5835c6cfed2ace11aefaea47ada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:24 -0300 Subject: [PATCH 043/113] feat(transit): add Lisbon's Carris Metropolitana buses Keyless GTFS-Realtime feed from Carris Metropolitana, documented in the operator's public API repository. No licence is stated for the realtime endpoint (the static GTFS is CC BY 4.0), so it is credited as a courtesy on the same basis as the Metro Transit and OVapi feeds. The feed publishes km/h, so a new `speedUnit` registry field lets the proxy convert to m/s. Route ids wrap the GTFS route id in an area prefix and are read as the line number. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 3 +- README.md | 2 +- changelog.d/szczecin-lisbon-transit.md | 6 ++++ changelog.d/szczecin-transit.md | 3 -- docs/CURRENT-STATE.md | 4 +-- src/data/transitFeeds.js | 39 ++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 33 ++++++++++++++++++++++ src/data/transitProxy.js | 6 +++- src/data/transitProxy.test.mjs | 21 ++++++++++++++ 9 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 changelog.d/szczecin-lisbon-transit.md delete mode 100644 changelog.d/szczecin-transit.md diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6532837d8..2dc33dad5 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -100,6 +100,7 @@ How to read this: | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | +| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | @@ -187,7 +188,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All nineteen registered feeds, covering eighteen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **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 twenty registered feeds, covering nineteen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 6c8de0d79..fff61fd9f 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/szczecin-lisbon-transit.md b/changelog.d/szczecin-lisbon-transit.md new file mode 100644 index 000000000..103657e8f --- /dev/null +++ b/changelog.d/szczecin-lisbon-transit.md @@ -0,0 +1,6 @@ +- Add Szczecin, Poland and the Lisbon Metropolitan Area to the Transit layer. + Szczecin's buses and trams come from ZDiTM's keyless feed under CC0 1.0; it + carries no route ids, so vehicles show as buses. Lisbon's buses come from + Carris Metropolitana's keyless feed, which states no licence of its own and + is credited as a courtesy like Metro Transit and OVapi; its km/h speeds are + converted to m/s and its route ids read as the line number (IMBrandao). diff --git a/changelog.d/szczecin-transit.md b/changelog.d/szczecin-transit.md deleted file mode 100644 index 5b2411211..000000000 --- a/changelog.d/szczecin-transit.md +++ /dev/null @@ -1,3 +0,0 @@ -- Add Szczecin, Poland to the Transit layer: buses and trams from ZDiTM's - keyless GTFS-Realtime feed, published under CC0 1.0. The feed carries no - route ids, so vehicles show as buses (IMBrandao). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 85bfd2e92..b5820c6b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4644,9 +4644,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Eighteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Nineteen 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), Szczecin, and nine in +(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, the Lisbon Metropolitan Area, and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 76b2764a6..7515b8417 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -389,6 +389,19 @@ function rioRouteLabel(routeId) { : routeId; } +/** + * Carris Metropolitana realtime route ids wrap the GTFS route id in an + * operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS + * route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per + * the operator's own `/v2/routes` API. Anything else passes through. + * @param {string|null} routeId + * @returns {string|null} + */ +function carrisMetropolitanaRouteLabel(routeId) { + const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? ''); + return match ? match[1] : routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -398,7 +411,10 @@ function rioRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'km/h', * }>>} + * `speedUnit` marks a feed that publishes km/h where the spec requires m/s; + * the proxy converts it before the snapshot leaves the server. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -811,6 +827,29 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ }), defaultMode: 'bus', }), + Object.freeze({ + id: 'carris-metropolitana-lisboa', + name: 'Carris Metropolitana', + operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area', + region: 'Lisbon Metropolitan Area, Portugal', + // Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat + // between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point. + center: Object.freeze({ lat: 38.73, lon: -9.13 }), + loadRadiusKm: 45, + url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb', + license: 'No separate licence for the realtime feed', + licenseUrl: 'https://github.com/carrismetropolitana/api', + attribution: 'Carris Metropolitana', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.', + note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.', + }), + defaultMode: 'bus', + routeLabel: carrisMetropolitanaRouteLabel, + speedUnit: 'km/h', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 5fab7c27d..0e3d4ab37 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -578,3 +578,36 @@ test('Szczecin is CC0, live, covers the city and not Berlin', () => { 'Szczecin has a DATA_SOURCES row', ); }); + +test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => { + const feed = getTransitFeed('carris-metropolitana-lisboa'); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb'); + assert.equal(feed.speedUnit, 'km/h'); + assert.match(feed.terms.note, /no licence for the realtime endpoint/); + const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + for (const [place, lat, lon] of [ + ['Lisbon', 38.7223, -9.1393], + ['Mafra', 38.9369, -9.3277], + ['Setúbal', 38.5244, -8.8882], + ]) { + assert.ok(ids(lat, lon).includes(feed.id), place); + } + assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto'); + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')), + 'Lisbon has a DATA_SOURCES row', + ); +}); + +test('Carris Metropolitana route ids read as the line number on the bus', () => { + const feed = getTransitFeed('carris-metropolitana-lisboa'); + assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708'); + assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772'); + assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected'); + assert.equal(transitRouteLabel(feed, null), null); +}); diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 458240e9d..68280d132 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -257,7 +257,11 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + feed.speedUnit === 'km/h' + ? decoded.vehicles.map((v) => + v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 }, + ) + : decoded.vehicles, decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index f03ea2e30..7c173cb5f 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -294,6 +294,27 @@ test('Rio speeds pass through as the m/s they are published in', () => { assert.equal(rio.vehicles[0].speedMps, 10); }); +test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => { + // 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: 38.72, lon: -9.14, speed: 36 }, + { id: 'parked', lat: 38.73, lon: -9.15 }, + ], + }); + const feed = getTransitFeed('carris-metropolitana-lisboa'); + const lisbon = buildTransitSnapshot(feed, bytes, 1); + const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v])); + assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6); + assert.equal(byId.parked.speedMps, null, 'no speed stays no speed'); + const szczecin = buildTransitSnapshot( + getTransitFeed('zditm-szczecin'), + encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }), + 1, + ); + assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched'); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); From 71b694ab6245dc91ec20562acd2394ba65c8edd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:50:32 -0300 Subject: [PATCH 044/113] feat(transit): add 32 more keyless cities across Europe, Asia, Oceania and North America MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prague, Warsaw, Kraków (buses and trams), Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Lisbon's city Carris, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax and Victoria. Each entry records its licence, or that none is stated and the Metro Transit/OVapi basis it ships on. Montpellier and Toulon publish km/h and are converted. Route ids are read as the rider-facing line; Prague, Bordeaux and Le Havre trams and Venice waterbuses take their own mode. Adelaide needs `Accept: */*` to get binary protobuf. Every feed was fetched through the dev proxy on 2026-11-08. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 34 +- README.md | 2 +- changelog.d/szczecin-lisbon-transit.md | 6 - changelog.d/transit-more-cities.md | 11 + docs/CURRENT-STATE.md | 4 +- src/data/transitFeeds.js | 636 +++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 108 +++++ src/data/transitProxy.test.mjs | 10 + 8 files changed, 801 insertions(+), 10 deletions(-) delete mode 100644 changelog.d/szczecin-lisbon-transit.md create mode 100644 changelog.d/transit-more-cities.md diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 2dc33dad5..5ef902c0c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -101,6 +101,38 @@ How to read this: | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | | **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana | +| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus the line number; lines below 100 are trams. | Pražská integrovaná doprava (PID) / Golemio | +| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city and its API; the download date it asks for is the feed timestamp the layer already shows with each snapshot. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) | +| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. Trams run on lettered lines A–D; numbered lines are buses. | Bordeaux Métropole (TBM) | +| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT | +| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM and says processed; the snapshot timestamp is the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) | +| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku | +| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton | +| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Halifax Open Data Licence (https://www.halifax.ca/home/open-data/open-data-licence). The catalogue points at Halifax’s open-data licence; the licence page renders only in a browser and was not quoted. Speeds are m/s. | Halifax Regional Municipality (Halifax Transit) | +| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit | +| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) | +| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie | +| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie | +| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) | +| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) | +| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità | +| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) | +| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. | +| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. | +| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro | +| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA | +| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). RTD links a licence agreement for its realtime feeds; the page refused automated reads (403), so it is not quoted. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) | +| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca | +| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași | +| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów | +| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga | +| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris | +| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | No licence stated for the feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3174). Served keyless by Malaysia’s national open-data API; no licence for the feed was found. Same basis as Metro Transit and OVapi. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my | +| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | No licence stated for the feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3169). Same API and basis as Johor Bahru. | BAS.MY / data.gov.my | +| **Rouen Astuce** (gtfs.bus-tracker.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); this registry reads it through bus-tracker.fr, an unofficial relay that merges the city’s networks into one GTFS-RT file. Route ids carry the network prefix (`TCAR:`). | Métropole Rouen Normandie (Astuce) / bus-tracker.fr | +| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) | +| **Le Havre LiA** (gtfs.bus-tracker.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); read through the bus-tracker.fr relay, which converts its SIRI feed to GTFS-RT. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) / bus-tracker.fr | +| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | @@ -188,7 +220,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All twenty registered feeds, covering nineteen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index fff61fd9f..f8b280c6d 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/szczecin-lisbon-transit.md b/changelog.d/szczecin-lisbon-transit.md deleted file mode 100644 index 103657e8f..000000000 --- a/changelog.d/szczecin-lisbon-transit.md +++ /dev/null @@ -1,6 +0,0 @@ -- Add Szczecin, Poland and the Lisbon Metropolitan Area to the Transit layer. - Szczecin's buses and trams come from ZDiTM's keyless feed under CC0 1.0; it - carries no route ids, so vehicles show as buses. Lisbon's buses come from - Carris Metropolitana's keyless feed, which states no licence of its own and - is credited as a courtesy like Metro Transit and OVapi; its km/h speeds are - converted to m/s and its route ids read as the line number (IMBrandao). diff --git a/changelog.d/transit-more-cities.md b/changelog.d/transit-more-cities.md new file mode 100644 index 000000000..3e72ebebb --- /dev/null +++ b/changelog.d/transit-more-cities.md @@ -0,0 +1,11 @@ +- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern + Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris + Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań, + Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice + (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, + Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, + Edmonton, Halifax and Victoria. Each feed records its licence, or that none + is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h + (Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids + read as the line on the vehicle; Prague, Bordeaux and Le Havre trams and + Venice waterbuses take their own mode (IMBrandao). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5820c6b5..f9f1f8d30 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4644,9 +4644,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Nineteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Forty-nine 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), Szczecin, the Lisbon Metropolitan Area, and nine in +(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, the Lisbon Metropolitan Area and the city of Lisbon, Prague and Central Bohemia, Warsaw, Kraków (buses and trams, two feeds), Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses, two feeds), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 7515b8417..5ac28d5da 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -402,6 +402,86 @@ function carrisMetropolitanaRouteLabel(routeId) { return match ? match[1] : routeId; } +/** + * Route ids that wrap the rider-facing line in a fixed prefix, suffix or + * zero-padding. The returned function yields the captured line, or the id + * unchanged when it does not match the operator's pattern. + * @param {RegExp} pattern One capture group: the line a rider would say. + * @returns {(routeId: string|null) => string|null} + */ +function routeIdPattern(pattern) { + return (routeId) => { + const match = pattern.exec(routeId ?? ''); + return match ? match[1] : routeId; + }; +} + +/** Prague PID: `L125` is line 125. */ +const pidRouteLabel = routeIdPattern(/^L(\d+)$/); +/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */ +const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/); +/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */ +const gttRouteLabel = routeIdPattern(/^(\w+?)U$/); +/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */ +const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/); +/** BC Transit appends the system: `95-VIC` is Victoria route 95. */ +const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/); +/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */ +const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/); +/** bus-tracker.fr prefixes the network: `TCAR:93` is Rouen line 93. */ +const busTrackerRouteLabel = routeIdPattern(/^[A-Z]+:(.+)$/); +/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */ +const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/); + +/** + * Le Havre LiA runs its two tram lines as A and B; every other line is a bus. + * @param {string|null} routeId + * @returns {string} + */ +function liaRouteMode(routeId) { + if (!routeId) return 'unknown'; + return /^(?:[A-Z]+:)?[AB]$/.test(routeId) ? 'tram' : 'bus'; +} + +/** + * Prague PID numbers its day and night trams below 100 and its buses from 100 + * up; anything else (rail, ferries, the funicular) is left unresolved. + * @param {string|null} routeId + * @returns {string} + */ +function pidRouteMode(routeId) { + const match = /^L(\d+)$/.exec(routeId ?? ''); + if (!match) return 'unknown'; + return Number(match[1]) < 100 ? 'tram' : 'bus'; +} + +/** + * Bordeaux TBM runs trams on lettered lines A–D; every numbered line is a bus. + * @param {string|null} routeId + * @returns {string} + */ +function tbmRouteMode(routeId) { + if (!routeId) return 'unknown'; + return /^[A-D]$/.test(routeId) ? 'tram' : 'bus'; +} + +/** + * A registry entry, frozen with its centre and terms, and switched on. + * @param {object} fields + * @returns {Readonly} + */ +function transitFeed(fields) { + return Object.freeze({ + defaultEnabled: true, + ...fields, + center: Object.freeze({ ...fields.center }), + terms: Object.freeze({ ...fields.terms }), + ...(fields.headers + ? { headers: Object.freeze({ ...fields.headers }) } + : {}), + }); +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -850,6 +930,562 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ routeLabel: carrisMetropolitanaRouteLabel, speedUnit: 'km/h', }), + transitFeed({ + id: 'pid-praha', + name: 'Prague PID', + operator: 'Pražská integrovaná doprava (PID), via Golemio', + region: 'Prague and Central Bohemia, Czechia', + // PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles + // sat within 58 km of Prague, the farthest at 98 km. + center: { lat: 50.08, lon: 14.45 }, + loadRadiusKm: 60, + url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb', + license: 'CC BY', + licenseUrl: 'https://pid.cz/o-systemu/opendata/', + attribution: 'Pražská integrovaná doprava (PID) / Golemio', + terms: { + quote: + 'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY', + note: 'The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus the line number; lines below 100 are trams.', + }, + defaultMode: 'bus', + routeLabel: pidRouteLabel, + routeMode: pidRouteMode, + }), + transitFeed({ + id: 'ztm-warszawa', + name: 'Warsaw buses and trams', + operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl', + region: 'Warsaw, Poland', + center: { lat: 52.25, lon: 21.02 }, + loadRadiusKm: 30, + url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb', + license: 'Warsaw Open Data Project Usage Terms', + licenseUrl: 'https://mkuran.pl/gtfs/', + attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)', + terms: { + quote: + 'The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.', + note: 'Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city and its API; the download date it asks for is the feed timestamp the layer already shows with each snapshot. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'tbm-bordeaux', + name: 'Bordeaux TBM', + operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole', + region: 'Bordeaux, France', + center: { lat: 44.85, lon: -0.65 }, + loadRadiusKm: 20, + url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + license: 'Licence Ouverte / Etalab 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite', + attribution: 'Bordeaux Métropole (TBM)', + terms: { + quote: 'licence: fr-lo', + note: 'The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. Trams run on lettered lines A–D; numbered lines are buses.', + }, + defaultMode: 'bus', + routeLabel: unpaddedRouteLabel, + routeMode: tbmRouteMode, + }), + transitFeed({ + id: 'sendai-city-bus', + name: 'Sendai City Bus', + operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT', + region: 'Sendai, Japan', + center: { lat: 38.27, lon: 140.82 }, + loadRadiusKm: 22, + url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle', + license: 'CC BY 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by/4.0/', + attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT', + terms: { + quote: 'https://creativecommons.org/licenses/by/4.0/', + note: 'Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztm-poznan', + name: 'Poznań buses and trams', + operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)', + region: 'Poznań, Poland', + center: { lat: 52.39, lon: 16.92 }, + loadRadiusKm: 20, + url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb', + license: 'ZTM Poznań open-data terms', + licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/', + attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)', + terms: { + quote: + 'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.', + note: 'Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM and says processed; the snapshot timestamp is the update date. The feed carries no bearing, so heading comes from movement between fixes.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztm-gdansk', + name: 'Gdańsk buses and trams', + operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)', + region: 'Gdańsk, Poland', + center: { lat: 54.35, lon: 18.6 }, + loadRadiusKm: 20, + url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions', + license: 'CC BY 4.0', + licenseUrl: + 'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt', + attribution: 'Zarząd Transportu Miejskiego w Gdańsku', + terms: { + note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ets-edmonton', + name: 'Edmonton Transit Service', + operator: 'City of Edmonton (Edmonton Transit Service)', + region: 'Edmonton, AB', + center: { lat: 53.53, lon: -113.52 }, + loadRadiusKm: 25, + url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb', + license: 'City of Edmonton Open Data Agreement', + licenseUrl: + 'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf', + attribution: + 'Contains information licensed under the Open Data Agreement – City of Edmonton', + terms: { + note: 'The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s.', + }, + defaultMode: 'bus', + routeLabel: unpaddedRouteLabel, + }), + transitFeed({ + id: 'halifax-transit', + name: 'Halifax Transit', + operator: 'Halifax Regional Municipality (Halifax Transit)', + region: 'Halifax, NS', + center: { lat: 44.7, lon: -63.6 }, + loadRadiusKm: 22, + url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb', + license: 'Halifax Open Data Licence', + licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-licence', + attribution: 'Halifax Regional Municipality (Halifax Transit)', + terms: { + note: 'The catalogue points at Halifax’s open-data licence; the licence page renders only in a browser and was not quoted. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'bc-transit-victoria', + name: 'Victoria Regional Transit', + operator: 'BC Transit (Victoria Regional Transit System)', + region: 'Victoria, BC', + center: { lat: 48.53, lon: -123.43 }, + loadRadiusKm: 28, + url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48', + license: 'BC Transit Open Data Terms of Use', + licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use', + attribution: 'BC Transit', + terms: { + quote: + 'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.', + note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.', + }, + defaultMode: 'bus', + routeLabel: bcTransitRouteLabel, + }), + transitFeed({ + id: 'tam-montpellier', + name: 'Montpellier TaM', + operator: 'Montpellier Méditerranée Métropole (TaM)', + region: 'Montpellier, France', + center: { lat: 43.61, lon: 3.87 }, + loadRadiusKm: 20, + url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + license: 'ODbL', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain', + attribution: 'Montpellier Méditerranée Métropole (TaM)', + terms: { + quote: 'licence: odc-odbl', + note: 'The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them.', + }, + defaultMode: 'bus', + speedUnit: 'km/h', + }), + transitFeed({ + id: 'ztp-krakow-bus', + name: 'Kraków buses', + operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)', + region: 'Kraków, Poland', + center: { lat: 50.06, lon: 19.94 }, + loadRadiusKm: 22, + url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb', + license: 'No licence stated', + licenseUrl: 'https://gtfs.ztp.krakow.pl/', + attribution: 'Zarząd Transportu Publicznego w Krakowie', + terms: { + note: 'ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztp-krakow-tram', + name: 'Kraków trams', + operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)', + region: 'Kraków, Poland', + center: { lat: 50.06, lon: 19.94 }, + loadRadiusKm: 22, + url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb', + license: 'No licence stated', + licenseUrl: 'https://gtfs.ztp.krakow.pl/', + attribution: 'Zarząd Transportu Publicznego w Krakowie', + terms: { note: 'Same server and basis as the bus feed.' }, + defaultMode: 'tram', + }), + transitFeed({ + id: 'vilnius-transport', + name: 'Vilnius public transport', + operator: 'SĮ Susisiekimo paslaugos, Vilnius', + region: 'Vilnius, Lithuania', + center: { lat: 54.69, lon: 25.28 }, + loadRadiusKm: 20, + url: 'https://www.stops.lt/vilnius/vehicle_positions.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383', + attribution: 'SĮ Susisiekimo paslaugos (stops.lt)', + terms: { + note: 'Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'tpbi-bucuresti', + name: 'Bucharest public transport', + operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov', + region: 'Bucharest, Romania', + center: { lat: 44.43, lon: 26.1 }, + loadRadiusKm: 25, + url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104', + attribution: 'TPBI (Transport Public București–Ilfov)', + terms: { + note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'roma-mobilita', + name: 'Rome public transport', + operator: 'Roma Servizi per la Mobilità', + region: 'Rome, Italy', + center: { lat: 41.89, lon: 12.5 }, + loadRadiusKm: 35, + url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776', + attribution: 'Roma Servizi per la Mobilità', + terms: { + note: 'Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'gtt-torino', + name: 'Turin GTT', + operator: 'Gruppo Torinese Trasporti (GTT)', + region: 'Turin, Italy', + center: { lat: 45.07, lon: 7.68 }, + loadRadiusKm: 20, + url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696', + attribution: 'Gruppo Torinese Trasporti (GTT)', + terms: { + note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.', + }, + defaultMode: 'bus', + routeLabel: gttRouteLabel, + }), + transitFeed({ + id: 'actv-venezia-bus', + name: 'Venice ACTV buses', + operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)', + region: 'Venice, Italy', + center: { lat: 45.4, lon: 12.2 }, + loadRadiusKm: 38, + url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions', + license: 'ACTV open-data catalogue', + licenseUrl: + 'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv', + attribution: 'ACTV S.p.A.', + terms: { + note: 'ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`).', + }, + defaultMode: 'bus', + routeLabel: actvRouteLabel, + }), + transitFeed({ + id: 'actv-venezia-waterbus', + name: 'Venice ACTV waterbuses', + operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione', + region: 'Venice, Italy', + center: { lat: 45.43, lon: 12.33 }, + loadRadiusKm: 15, + url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions', + license: 'ACTV open-data catalogue', + licenseUrl: + 'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv', + attribution: 'ACTV S.p.A.', + terms: { note: 'Same catalogue and basis as the ACTV bus feed.' }, + defaultMode: 'ferry', + routeLabel: actvRouteLabel, + }), + transitFeed({ + id: 'adelaide-metro', + name: 'Adelaide Metro', + operator: 'Adelaide Metro (Government of South Australia)', + region: 'Adelaide, SA', + center: { lat: -34.93, lon: 138.6 }, + loadRadiusKm: 35, + url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions', + // Any Accept naming a protobuf type gets base64 text labelled JSON; only + // */* returns the binary feed (checked 2026-11-08). + headers: { Accept: '*/*' }, + license: 'No licence stated for the realtime feed', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772', + attribution: 'Adelaide Metro', + terms: { + note: 'Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'marta-atlanta', + name: 'MARTA buses', + operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)', + region: 'Atlanta, GA', + center: { lat: 33.78, lon: -84.39 }, + loadRadiusKm: 35, + url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601', + attribution: 'MARTA', + terms: { + note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'rtd-denver', + name: 'Denver RTD', + operator: 'Regional Transportation District (RTD), Denver', + region: 'Denver, CO', + center: { lat: 39.75, lon: -104.99 }, + loadRadiusKm: 55, + url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb', + license: 'RTD GTFS-realtime licence agreement', + licenseUrl: + 'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement', + attribution: 'Regional Transportation District (RTD)', + terms: { + note: 'RTD links a licence agreement for its realtime feeds; the page refused automated reads (403), so it is not quoted. The feed itself is keyless. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ctp-cluj', + name: 'Cluj-Napoca CTP', + operator: 'Compania de Transport Public Cluj-Napoca', + region: 'Cluj-Napoca, Romania', + center: { lat: 46.77, lon: 23.59 }, + loadRadiusKm: 15, + url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614', + attribution: 'Compania de Transport Public Cluj-Napoca', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ctp-iasi', + name: 'Iași CTP', + operator: 'Compania de Transport Public Iași', + region: 'Iași, Romania', + center: { lat: 47.16, lon: 27.59 }, + loadRadiusKm: 15, + url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118', + attribution: 'Compania de Transport Public Iași', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'mpk-rzeszow', + name: 'Rzeszów city transport', + operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)', + region: 'Rzeszów, Poland', + center: { lat: 50.04, lon: 22.0 }, + loadRadiusKm: 15, + url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb', + license: 'MPK Rzeszów open data', + licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/', + attribution: 'MPK Rzeszów', + terms: { + note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'emt-malaga', + name: 'Málaga EMT', + operator: 'Empresa Malagueña de Transportes (EMT)', + region: 'Málaga, Spain', + center: { lat: 36.72, lon: -4.42 }, + loadRadiusKm: 15, + url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213', + attribution: 'EMT Málaga', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'carris-lisboa', + name: 'Carris (Lisbon city)', + operator: 'Carris – Companhia Carris de Ferro de Lisboa', + region: 'Lisbon, Portugal', + center: { lat: 38.74, lon: -9.15 }, + loadRadiusKm: 15, + url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930', + attribution: 'Carris', + terms: { + note: 'Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant.', + }, + defaultMode: 'bus', + routeLabel: carrisRouteLabel, + }), + transitFeed({ + id: 'mybas-johor', + name: 'BAS.MY Johor Bahru', + operator: 'BAS.MY, via data.gov.my', + region: 'Johor Bahru, Malaysia', + center: { lat: 1.51, lon: 103.75 }, + loadRadiusKm: 35, + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/', + license: 'No licence stated for the feed', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3174', + attribution: 'BAS.MY / data.gov.my', + terms: { + note: 'Served keyless by Malaysia’s national open-data API; no licence for the feed was found. Same basis as Metro Transit and OVapi. Without the trailing slash the API redirects. Route ids append an operator code.', + }, + defaultMode: 'bus', + routeLabel: mybasJohorRouteLabel, + }), + transitFeed({ + id: 'mybas-kota-bharu', + name: 'BAS.MY Kota Bharu', + operator: 'BAS.MY, via data.gov.my', + region: 'Kota Bharu, Malaysia', + // Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08. + center: { lat: 6.06, lon: 102.22 }, + loadRadiusKm: 60, + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/', + license: 'No licence stated for the feed', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3169', + attribution: 'BAS.MY / data.gov.my', + terms: { note: 'Same API and basis as Johor Bahru.' }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'astuce-rouen', + name: 'Rouen Astuce', + operator: 'Métropole Rouen Normandie (Astuce), relayed by bus-tracker.fr', + region: 'Rouen, France', + center: { lat: 49.44, lon: 1.1 }, + loadRadiusKm: 20, + url: 'https://gtfs.bus-tracker.fr/gtfs-rt/tcar/?tcar=1&tae=1&tni=1', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie', + attribution: 'Métropole Rouen Normandie (Astuce) / bus-tracker.fr', + terms: { + quote: 'licence: lov2', + note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); this registry reads it through bus-tracker.fr, an unofficial relay that merges the city’s networks into one GTFS-RT file. Route ids carry the network prefix (`TCAR:`).', + }, + defaultMode: 'bus', + routeLabel: busTrackerRouteLabel, + }), + transitFeed({ + id: 'mpm-toulon', + name: 'Toulon Mistral', + operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)', + region: 'Toulon, France', + center: { lat: 43.12, lon: 5.94 }, + loadRadiusKm: 18, + url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee', + attribution: 'Métropole Toulon Provence Méditerranée (Mistral)', + terms: { + quote: 'licence: lov2', + note: 'Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded.', + }, + defaultMode: 'bus', + routeLabel: unpaddedRouteLabel, + speedUnit: 'km/h', + }), + transitFeed({ + id: 'lia-le-havre', + name: 'Le Havre LiA', + operator: 'Le Havre Seine Métropole (LiA), relayed by bus-tracker.fr', + region: 'Le Havre, France', + center: { lat: 49.49, lon: 0.11 }, + loadRadiusKm: 18, + url: 'https://gtfs.bus-tracker.fr/gtfs-rt/lia/vehicle-positions', + license: 'ODbL', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri', + attribution: 'Le Havre Seine Métropole (LiA) / bus-tracker.fr', + terms: { + quote: 'licence: odc-odbl', + note: 'The network publishes under ODbL (dataset record quoted); read through the bus-tracker.fr relay, which converts its SIRI feed to GTFS-RT. The layer draws positions and redistributes no database. Lines A and B are the tramway.', + }, + defaultMode: 'bus', + routeLabel: busTrackerRouteLabel, + routeMode: liaRouteMode, + }), + transitFeed({ + id: 'filbleu-tours', + name: 'Tours Fil Bleu', + operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)', + region: 'Tours, France', + center: { lat: 47.39, lon: 0.69 }, + loadRadiusKm: 18, + url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt', + attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine', + terms: { + quote: 'licence: lov2', + note: 'Dataset record’s licence field quoted.', + }, + defaultMode: 'bus', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 0e3d4ab37..8afe6ea8b 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -611,3 +611,111 @@ test('Carris Metropolitana route ids read as the line number on the bus', () => assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected'); assert.equal(transitRouteLabel(feed, null), null); }); + +// The 2026-11 batch: one landmark per feed, inside its own radius. +const NEW_CITY_LANDMARKS = { + 'pid-praha': [50.0875, 14.4213], + 'ztm-warszawa': [52.2297, 21.0122], + 'tbm-bordeaux': [44.8378, -0.5792], + 'sendai-city-bus': [38.2602, 140.8824], + 'ztm-poznan': [52.4064, 16.9252], + 'ztm-gdansk': [54.352, 18.6466], + 'ets-edmonton': [53.5461, -113.4938], + 'halifax-transit': [44.6488, -63.5752], + 'bc-transit-victoria': [48.4284, -123.3656], + 'tam-montpellier': [43.6108, 3.8767], + 'ztp-krakow-bus': [50.0614, 19.9366], + 'ztp-krakow-tram': [50.0614, 19.9366], + 'vilnius-transport': [54.6872, 25.2797], + 'tpbi-bucuresti': [44.4268, 26.1025], + 'roma-mobilita': [41.9028, 12.4964], + 'gtt-torino': [45.0703, 7.6869], + 'actv-venezia-bus': [45.4408, 12.3155], + 'actv-venezia-waterbus': [45.4408, 12.3155], + 'adelaide-metro': [-34.9285, 138.6007], + 'marta-atlanta': [33.749, -84.388], + 'rtd-denver': [39.7392, -104.9903], + 'ctp-cluj': [46.7712, 23.6236], + 'ctp-iasi': [47.1585, 27.6014], + 'mpk-rzeszow': [50.0412, 21.9991], + 'emt-malaga': [36.7213, -4.4214], + 'carris-lisboa': [38.7223, -9.1393], + 'mybas-johor': [1.4927, 103.7414], + 'mybas-kota-bharu': [6.1254, 102.2381], + 'astuce-rouen': [49.4432, 1.0999], + 'mpm-toulon': [43.1242, 5.928], + 'lia-le-havre': [49.4944, 0.1079], + 'filbleu-tours': [47.3941, 0.6848], +}; + +test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => { + for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) { + const feed = getTransitFeed(id); + assert.ok(feed, `${id} is routable`); + assert.ok( + publicTransitCatalog().some((f) => f.id === id), + `${id} is offered to the browser`, + ); + assert.ok(feed.terms?.note, `${id} records the basis it ships on`); + assert.ok( + transitFeedsInRange(lat, lon).some((f) => f.id === id), + `${id} covers its city`, + ); + } + // A feed's radius stays local: Warsaw is not polled for Kraków. + assert.ok( + !transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'), + ); +}); + +test('feeds with no stated licence say so and name the basis they ship on', () => { + for (const feed of TRANSIT_FEED_REGISTRY.filter((f) => + /^No licence stated/.test(f.license), + )) { + assert.match( + feed.terms.note, + /Metro Transit and OVapi|Same .*basis/, + `${feed.id} names its basis`, + ); + } +}); + +test('route ids in the 2026-11 batch read as the line on the vehicle', () => { + const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId); + assert.equal(label('pid-praha', 'L135'), '135'); + assert.equal(label('ets-edmonton', '009'), '9'); + assert.equal(label('mpm-toulon', '0002'), '2'); + assert.equal(label('gtt-torino', '10NU'), '10N'); + assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1'); + assert.equal(label('actv-venezia-bus', '53E_ES'), '53E'); + assert.equal(label('bc-transit-victoria', '95-VIC'), '95'); + assert.equal(label('carris-lisboa', '172_1'), '172'); + assert.equal(label('astuce-rouen', 'TCAR:93'), '93'); + assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33'); + // Unmatched and absent ids pass through untouched. + assert.equal(label('pid-praha', 'S4'), 'S4'); + assert.equal(label('gtt-torino', null), null); +}); + +test('trams, waterbuses and buses are told apart where the ids allow it', () => { + const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId); + assert.equal(mode('pid-praha', 'L22'), 'tram'); + assert.equal(mode('pid-praha', 'L135'), 'bus'); + assert.equal(mode('tbm-bordeaux', 'A'), 'tram'); + assert.equal(mode('tbm-bordeaux', '16'), 'bus'); + assert.equal(mode('lia-le-havre', 'B'), 'tram'); + assert.equal(mode('lia-le-havre', '3'), 'bus'); + assert.equal(mode('ztp-krakow-tram', null), 'tram'); + assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry'); +}); + +test('feeds that publish km/h are marked for conversion, and only those', () => { + const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h') + .map((f) => f.id) + .sort(); + assert.deepEqual(kmh, [ + 'carris-metropolitana-lisboa', + 'mpm-toulon', + 'tam-montpellier', + ]); +}); diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index 7c173cb5f..1c9f6679f 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -396,3 +396,13 @@ test('client history validates response bounds, epochs and cancellation through { name: 'AbortError' }, ); }); + +test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => { + const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro')); + assert.equal(headers.Accept, '*/*'); + // Every other feed keeps the protobuf-first Accept. + assert.match( + transitUpstreamHeaders(getTransitFeed('mbta')).Accept, + /protobuf/, + ); +}); From 923287eb92df38b24df9ff2b5b2f1913e5a638ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:56:19 -0300 Subject: [PATCH 045/113] chore(changelog): name the transit fragment after PR #1628 Co-Authored-By: Claude Opus 5.5 --- .../{transit-more-cities.md => 1628-transit-more-cities.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{transit-more-cities.md => 1628-transit-more-cities.md} (100%) diff --git a/changelog.d/transit-more-cities.md b/changelog.d/1628-transit-more-cities.md similarity index 100% rename from changelog.d/transit-more-cities.md rename to changelog.d/1628-transit-more-cities.md From e631839a4eee48bbcccc9fb39cd05dff5e4b5d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:08:40 -0300 Subject: [PATCH 046/113] fix(transit): quote Halifax's Open Government Licence The licence page refuses automated requests; its text was read in a browser. Credit now uses the licence's own attribution statement. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- src/data/transitFeeds.js | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 68f58b067..6a963c653 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -112,7 +112,7 @@ How to read this: | **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM and says processed; the snapshot timestamp is the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) | | **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku | | **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton | -| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Halifax Open Data Licence (https://www.halifax.ca/home/open-data/open-data-licence). The catalogue points at Halifax’s open-data licence; the licence page renders only in a browser and was not quoted. Speeds are m/s. | Halifax Regional Municipality (Halifax Transit) | +| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax | | **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit | | **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) | | **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie | diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 5ac28d5da..bb2642d07 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1069,11 +1069,14 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ center: { lat: 44.7, lon: -63.6 }, loadRadiusKm: 22, url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb', - license: 'Halifax Open Data Licence', - licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-licence', - attribution: 'Halifax Regional Municipality (Halifax Transit)', + license: 'Open Government Licence – Halifax', + licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license', + attribution: + 'Contains information licenced under the Open Government Licence – Halifax', terms: { - note: 'The catalogue points at Halifax’s open-data licence; the licence page renders only in a browser and was not quoted. Speeds are m/s.', + quote: + 'The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.', + note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.', }, defaultMode: 'bus', }), From a648c9b665fe2ea789cd11009b35ba01c8000072 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Mon, 9 Nov 2026 00:22:12 +0100 Subject: [PATCH 047/113] feat(satellites): owner, operator and COSPAR groundwork for the orbital view The orbital view reads a satellite's owner country, operator, COSPAR id, TLE epoch and whether its class can be ridden. #1558 now carries only what the class toggles read, so this brings the rest back here, where it is first used: the classes.js helpers and the UCS pack's operator and owner fields. --- DATA_SOURCES.md | 4 +- docs/CURRENT-STATE.md | 2 +- scripts/build-ucs-satellites.py | 16 +- src/data/dataCredits.js | 2 +- src/data/local_data/ucs_satellites/README.md | 32 +- .../ucs_satellites/ucs_satellites.json | 2 +- src/data/ucsSatellites.js | 11 +- src/data/ucsSatellites.test.mjs | 34 +- src/layers/satellites/catalog.js | 2 + src/layers/satellites/classes.js | 450 +++++++++++++++++- src/layers/satellites/classes.test.mjs | 206 ++++---- src/layers/satellites/ingestion.js | 2 + src/layers/satellites/state.js | 2 +- 13 files changed, 617 insertions(+), 148 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 7e11d6a03..52f72641c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -229,7 +229,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel | | **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | -| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" | +| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" | | **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | @@ -296,7 +296,7 @@ The former bundled 2026-05-25 snapshot was removed 2026-07-16. ### UCS Satellite Database (`ucs_satellites/`) -The purpose of each active satellite, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README. +Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README. ### Natural Earth physical regions (`natural_earth/`) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4ddc05b45..ab92553b2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5458,7 +5458,7 @@ silently demoting every later lookup for the session. - **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class. - Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. - The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. -- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: each satellite's purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. - **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. diff --git a/scripts/build-ucs-satellites.py b/scripts/build-ucs-satellites.py index cd7080fb3..d1a0b6fd8 100644 --- a/scripts/build-ucs-satellites.py +++ b/scripts/build-ucs-satellites.py @@ -2,9 +2,9 @@ '''Maintainer-only UCS Satellite Database pack build (openpyxl). Reads the UCS Excel release and writes the compact pack the satellites layer -loads: one row per satellite, keyed by NORAD number, with a string-table index -for its purpose, the one column the layer's classes read. Starlink rows are -left out: the layer files every STARLINK object under its own class by name. +loads: one row per satellite, keyed by NORAD number, with string-table indexes +for the repeated text columns. Starlink rows are left out: the layer files +every STARLINK object under its own class and operator without them. Usage: python scripts/build-ucs-satellites.py "/UCS-Satellite-Database 5-1-2023.xlsx" ''' @@ -26,7 +26,12 @@ URL = 'https://www.ucsusa.org/satellite_database' # Output column -> spreadsheet header. COLUMNS = { + 'operator': 'Operator/Owner', + 'operatorCountry': 'Country of Operator/Owner', 'purpose': 'Purpose', + 'detailedPurpose': 'Detailed Purpose', + 'users': 'Users', + 'orbitClass': 'Class of Orbit', } STARLINK = re.compile(r'\bstarlink\b', re.I) @@ -60,12 +65,11 @@ def build(xlsx: Path) -> dict: except (TypeError, ValueError): invalid_norad += 1 continue - if norad <= 0: - invalid_norad += 1 - continue record = [norad] for key, column in COLUMNS.items(): text = clean(row[header[column]]) + if key == 'orbitClass': + text = text.upper() if text not in index[key]: index[key][text] = len(tables[key]) tables[key].append(text) diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a30d7c2b5..a55bed9e4 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -150,7 +150,7 @@ export const DATA_CREDITS = [ { key: 'ucs-satellite-database', html: - 'Satellite purposes: ' + + 'Satellite operators and purposes: ' + 'UCS Satellite Database ' + '(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists', }, diff --git a/src/data/local_data/ucs_satellites/README.md b/src/data/local_data/ucs_satellites/README.md index 4e2a0cbb0..26cb06607 100644 --- a/src/data/local_data/ucs_satellites/README.md +++ b/src/data/local_data/ucs_satellites/README.md @@ -1,8 +1,9 @@ # UCS Satellite Database pack -Offline purpose records for active satellites, keyed by NORAD catalog number. -The satellites layer joins them to CelesTrak's positions to file each -satellite under a class (stations, imaging, telecom, probes) +Offline operator, owner-country and purpose records for active satellites, +keyed by NORAD catalog number. The satellites layer joins them to CelesTrak's +positions to file each satellite under a class (stations, imaging, telecom, +probes) and to name its operator and owner country (`src/data/ucsSatellites.js`, `src/layers/satellites/classes.js`). | File | Source dataset | Records | @@ -23,7 +24,7 @@ the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." -**Acknowledgement:** "Satellite purposes: UCS Satellite Database +**Acknowledgement:** "Satellite operators and purposes: UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database". It is also listed in the app's Data attribution popover and in DATA_SOURCES.md. @@ -33,13 +34,18 @@ DATA_SOURCES.md. **Curation:** -- Kept column: Purpose, the one the classes read. Everything else - (operators, owners, orbits, masses, contractors, launch data, sources) is - left out. +- Kept columns: Operator/Owner, Country of Operator/Owner, Purpose, Detailed + Purpose, Users, Class of Orbit (upper-cased, which folds the source's one + `LEo`). Everything else (masses, contractors, launch data, sources) is left + out. - Repeated text is stored once in `strings`; each record is - `[noradId, purpose]` with a string-table index, `0` meaning empty. -- The 3,996 Starlink rows are dropped: every Starlink object is classed by - name, so they would only add size. -- Rows without a readable, positive NORAD number are dropped and counted apart - from duplicate NORAD numbers (the first row of a duplicate is kept); both - counts are 0 in this release. + `[noradId, operator, operatorCountry, purpose, detailedPurpose, users, + orbitClass]` with string-table indexes, `0` meaning empty. +- The 3,996 Starlink rows are dropped: every Starlink object is classed and + credited to SpaceX by name, so they would only add size. +- Rows without a readable NORAD number are dropped and counted apart from + duplicate NORAD numbers (the first row of a duplicate is kept); both counts + are 0 in this release. +- Owner countries stay as UCS writes them ("USA", "USA/Japan", "ESA", + "Multinational"); `src/layers/satellites/classes.js` normalises them, together with + CelesTrak SATCAT owner codes, in one table. diff --git a/src/data/local_data/ucs_satellites/ucs_satellites.json b/src/data/local_data/ucs_satellites/ucs_satellites.json index 398fd2db9..f27ee92d8 100644 --- a/src/data/local_data/ucs_satellites/ucs_satellites.json +++ b/src/data/local_data/ucs_satellites/ucs_satellites.json @@ -1 +1 @@ -{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","purpose"],"strings":{"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,3],[19548,3],[20253,3],[20580,5],[20776,3],[21639,3],[22049,5],[22314,3],[22491,1],[22787,3],[22988,3],[23223,1],[23467,3],[23553,3],[23613,3],[23628,3],[23855,1],[23893,3],[24674,3],[24753,1],[24812,3],[24876,11],[24916,3],[24920,1],[24936,3],[25019,3],[25034,1],[25071,3],[25112,3],[25113,3],[25114,3],[25115,3],[25116,3],[25117,3],[25118,3],[25119,3],[25153,3],[25159,3],[25258,3],[25336,1],[25338,1],[25414,3],[25415,3],[25416,3],[25418,3],[25419,3],[25420,3],[25462,3],[25475,3],[25477,3],[25481,3],[25504,1],[25544,5],[25639,3],[25652,3],[25682,1],[25757,4],[25867,5],[25924,3],[25933,11],[25967,3],[25980,3],[25981,3],[25982,3],[25984,3],[25985,3],[25989,5],[25991,1],[25994,1],[26038,3],[26052,3],[26107,3],[26356,1],[26360,11],[26369,3],[26388,3],[26390,3],[26407,11],[26410,5],[26411,5],[26451,3],[26463,5],[26464,5],[26469,3],[26473,1],[26483,3],[26554,3],[26575,3],[26580,3],[26590,3],[26605,11],[26608,3],[26624,3],[26626,3],[26638,3],[26690,11],[26694,3],[26702,1],[26715,3],[26719,3],[26766,3],[26824,3],[26853,3],[26880,1],[26900,3],[26958,9],[26959,12],[26998,5],[27004,2],[27056,3],[27057,3],[27168,3],[27378,3],[27380,3],[27389,3],[27399,3],[27400,3],[27414,3],[27424,1],[27426,3],[27434,1],[27438,3],[27445,3],[27460,3],[27464,3],[27465,3],[27509,1],[27513,3],[27528,3],[27540,5],[27566,3],[27603,3],[27606,3],[27607,3],[27612,3],[27640,1],[27651,5],[27663,11],[27691,3],[27704,11],[27711,3],[27715,3],[27811,3],[27825,3],[27831,3],[27843,5],[27848,2],[27854,3],[27858,5],[27868,3],[27869,3],[27875,3],[27937,1],[27939,2],[27954,3],[28051,1],[28054,1],[28058,3],[28089,3],[28117,3],[28158,1],[28184,3],[28187,3],[28190,11],[28220,1],[28252,3],[28358,3],[28366,7],[28369,2],[28370,2],[28371,1],[28372,7],[28376,1],[28378,3],[28384,3],[28413,5],[28414,5],[28419,3],[28420,3],[28446,3],[28470,1],[28474,11],[28485,5],[28492,1],[28493,3],[28526,3],[28537,1],[28541,1],[28542,3],[28626,3],[28628,3],[28646,1],[28649,1],[28654,1],[28659,3],[28702,3],[28707,3],[28737,5],[28786,3],[28790,3],[28810,2],[28868,3],[28874,11],[28884,3],[28885,3],[28888,1],[28890,1],[28899,3],[28903,3],[28908,3],[28912,1],[28924,3],[28935,3],[28945,3],[28946,3],[29045,3],[29047,1],[29048,1],[29050,1],[29051,1],[29052,1],[29055,3],[29079,1],[29107,1],[29108,4],[29155,1],[29162,3],[29236,3],[29249,1],[29252,2],[29268,12],[29270,3],[29272,3],[29273,3],[29349,3],[29479,5],[29486,11],[29494,3],[29495,3],[29505,5],[29506,5],[29520,3],[29522,1],[29526,3],[29601,11],[29643,3],[29644,3],[29658,1],[29670,11],[29671,11],[29709,12],[29710,1],[30580,5],[30776,2],[30794,3],[30797,5],[30798,5],[31102,3],[31118,1],[31119,3],[31121,3],[31124,3],[31125,3],[31127,3],[31304,1],[31306,3],[31307,3],[31571,3],[31573,3],[31574,3],[31576,3],[31598,1],[31601,1],[31698,1],[31701,1],[31708,1],[31789,2],[31792,1],[31797,1],[31800,3],[31862,3],[32018,3],[32019,3],[32060,1],[32252,3],[32253,3],[32258,3],[32260,11],[32263,3],[32264,3],[32265,3],[32275,11],[32276,11],[32283,1],[32293,3],[32294,3],[32299,3],[32376,1],[32378,3],[32382,1],[32384,11],[32388,3],[32393,11],[32395,11],[32404,3],[32476,1],[32478,3],[32487,3],[32706,1],[32711,11],[32729,3],[32750,1],[32763,3],[32767,3],[32768,3],[32779,3],[32783,1],[32789,2],[32790,2],[32791,2],[32794,3],[32951,3],[32954,3],[32955,3],[32956,3],[33051,3],[33053,5],[33055,3],[33056,3],[33153,3],[33154,3],[33207,3],[33244,1],[33274,3],[33275,3],[33278,3],[33320,1],[33321,1],[33331,1],[33373,3],[33376,3],[33396,1],[33401,5],[33408,5],[33409,5],[33412,1],[33433,1],[33434,1],[33436,3],[33446,1],[33453,3],[33459,3],[33460,3],[33490,1],[33492,1],[33496,2],[33499,2],[33591,1],[33595,3],[33749,3],[33750,3],[33751,2],[33752,2],[34111,3],[34710,3],[34713,3],[34810,3],[34839,1],[34903,2],[34941,3],[35362,3],[35491,1],[35493,3],[35496,3],[35498,3],[35500,3],[35681,1],[35683,1],[35684,8],[35685,7],[35686,8],[35696,3],[35752,11],[35755,3],[35756,3],[35815,1],[35873,3],[35932,2],[35935,2],[35942,3],[35943,3],[35946,1],[35951,1],[36032,3],[36033,3],[36036,1],[36037,9],[36088,5],[36095,1],[36097,3],[36101,3],[36106,3],[36108,3],[36110,1],[36111,11],[36112,11],[36121,1],[36122,3],[36124,1],[36131,3],[36358,3],[36395,5],[36397,3],[36400,11],[36401,11],[36402,11],[36411,1],[36413,1],[36414,1],[36415,1],[36499,3],[36508,1],[36516,3],[36581,3],[36582,3],[36585,11],[36592,3],[36596,2],[36605,1],[36608,1],[36744,13],[36745,3],[36792,3],[36795,1],[36797,3],[36798,1],[36828,11],[36830,3],[36831,3],[36834,1],[36985,1],[37139,11],[37150,3],[37152,3],[37154,3],[37158,23],[37162,1],[37165,1],[37166,5],[37167,5],[37168,21],[37179,1],[37180,1],[37185,3],[37188,3],[37189,3],[37190,3],[37191,3],[37192,3],[37193,3],[37207,3],[37210,11],[37212,3],[37214,1],[37216,1],[37218,3],[37224,5],[37227,2],[37232,1],[37234,3],[37237,3],[37238,3],[37256,11],[37258,3],[37264,3],[37265,3],[37344,1],[37348,1],[37364,2],[37372,11],[37377,3],[37384,11],[37386,1],[37387,1],[37388,5],[37389,1],[37391,1],[37392,3],[37393,3],[37398,3],[37481,1],[37602,3],[37605,20],[37606,3],[37677,3],[37730,2],[37737,3],[37739,3],[37740,3],[37741,3],[37742,3],[37743,3],[37744,3],[37746,3],[37748,3],[37749,3],[37753,11],[37755,5],[37763,11],[37765,2],[37775,3],[37776,3],[37779,3],[37781,1],[37789,1],[37790,12],[37791,1],[37792,3],[37793,3],[37804,3],[37809,3],[37810,3],[37813,1],[37816,3],[37818,2],[37826,3],[37834,3],[37836,3],[37838,1],[37841,2],[37843,3],[37846,11],[37847,11],[37849,1],[37855,2],[37867,11],[37868,11],[37869,11],[37875,1],[37930,1],[37931,1],[37933,3],[37941,1],[37948,11],[37951,3],[37954,1],[38007,1],[38008,1],[38009,1],[38010,1],[38011,1],[38012,1],[38014,3],[38038,1],[38040,3],[38041,3],[38042,3],[38043,3],[38044,3],[38045,3],[38046,1],[38049,1],[38070,3],[38087,3],[38091,11],[38093,3],[38098,3],[38107,3],[38109,1],[38245,3],[38248,1],[38250,11],[38251,11],[38254,3],[38256,1],[38257,1],[38331,3],[38332,3],[38337,1],[38338,1],[38339,2],[38340,2],[38342,3],[38352,3],[38354,1],[38356,3],[38358,5],[38466,3],[38528,1],[38551,3],[38552,1],[38652,3],[38707,1],[38708,1],[38710,2],[38730,3],[38733,3],[38740,3],[38741,3],[38749,3],[38752,1],[38753,1],[38755,1],[38758,1],[38759,2],[38760,2],[38764,5],[38766,2],[38771,1],[38773,1],[38775,11],[38778,3],[38779,3],[38782,1],[38833,11],[38857,11],[38858,11],[38860,2],[38861,2],[38867,3],[38908,2],[38953,11],[38977,3],[38978,3],[38991,3],[38992,3],[38995,3],[38998,2],[38999,2],[39008,3],[39011,1],[39012,1],[39013,1],[39017,3],[39019,1],[39020,3],[39022,3],[39030,1],[39034,3],[39035,3],[39057,3],[39058,3],[39059,3],[39061,1],[39062,1],[39068,2],[39070,3],[39072,3],[39073,3],[39074,3],[39075,3],[39076,3],[39077,3],[39078,3],[39079,3],[39084,1],[39086,1],[39088,21],[39089,21],[39091,2],[39120,1],[39122,3],[39127,3],[39150,1],[39155,11],[39157,3],[39159,1],[39160,1],[39163,3],[39166,11],[39168,3],[39172,3],[39177,1],[39186,1],[39188,3],[39189,3],[39190,3],[39191,3],[39197,5],[39199,23],[39202,2],[39206,3],[39208,2],[39209,2],[39210,2],[39215,7],[39216,1],[39222,3],[39227,1],[39232,1],[39233,3],[39234,3],[39237,3],[39239,1],[39240,1],[39241,1],[39249,3],[39250,3],[39251,3],[39253,5],[39256,3],[39260,1],[39265,4],[39285,3],[39358,2],[39360,3],[39375,3],[39410,1],[39416,3],[39417,29],[39418,1],[39419,1],[39420,2],[39423,1],[39425,8],[39430,2],[39440,2],[39444,3],[39451,1],[39452,1],[39453,1],[39455,1],[39460,3],[39462,1],[39467,2],[39468,3],[39472,2],[39473,2],[39474,2],[39476,3],[39481,3],[39483,3],[39484,3],[39485,3],[39487,3],[39498,3],[39500,3],[39504,3],[39508,3],[39509,3],[39522,3],[39533,11],[39574,1],[39612,3],[39613,3],[39616,3],[39617,3],[39624,2],[39634,1],[39635,23],[39650,1],[39652,1],[39727,3],[39728,3],[39731,1],[39741,11],[39751,3],[39761,3],[39762,3],[39763,3],[39766,1],[39767,1],[39768,2],[39769,1],[39770,2],[39773,3],[40001,11],[40010,1],[40011,2],[40013,1],[40014,2],[40015,2],[40018,8],[40019,8],[40020,5],[40037,3],[40039,3],[40042,2],[40043,1],[40044,2],[40045,2],[40046,2],[40053,1],[40055,2],[40056,2],[40059,1],[40061,3],[40062,3],[40063,3],[40069,1],[40070,1],[40072,1],[40075,3],[40079,3],[40080,3],[40081,3],[40082,3],[40086,3],[40087,3],[40091,3],[40100,21],[40105,11],[40107,3],[40109,1],[40110,1],[40111,1],[40115,1],[40118,1],[40119,5],[40128,11],[40129,11],[40136,2],[40137,1],[40141,3],[40143,1],[40144,13],[40146,3],[40147,3],[40208,3],[40258,3],[40261,2],[40267,1],[40269,23],[40271,3],[40272,3],[40275,1],[40277,3],[40286,2],[40294,11],[40296,3],[40298,1],[40301,1],[40305,1],[40310,1],[40315,11],[40332,3],[40333,3],[40336,1],[40338,1],[40339,1],[40340,1],[40344,1],[40345,3],[40348,3],[40349,3],[40350,3],[40351,3],[40358,1],[40360,27],[40362,1],[40364,3],[40367,1],[40374,3],[40376,1],[40380,1],[40381,1],[40384,3],[40420,1],[40424,3],[40425,3],[40482,5],[40483,5],[40484,5],[40485,5],[40505,3],[40534,11],[40536,1],[40538,1],[40544,11],[40545,11],[40547,23],[40549,11],[40552,3],[40553,3],[40554,3],[40555,2],[40613,3],[40614,3],[40617,3],[40657,2],[40663,3],[40664,3],[40697,1],[40699,1],[40701,1],[40715,1],[40716,1],[40717,1],[40718,2],[40730,11],[40732,1],[40733,3],[40746,3],[40748,11],[40749,11],[40874,3],[40875,3],[40878,1],[40880,3],[40882,3],[40887,3],[40889,11],[40890,11],[40892,2],[40894,1],[40895,3],[40899,2],[40901,2],[40902,2],[40903,3],[40904,2],[40905,2],[40906,3],[40907,3],[40908,2],[40910,3],[40911,3],[40912,2],[40914,2],[40920,3],[40921,3],[40922,3],[40925,2],[40930,5],[40931,1],[40932,1],[40933,1],[40934,1],[40935,1],[40936,3],[40938,11],[40940,3],[40941,3],[40946,3],[40958,1],[40959,1],[40960,1],[40961,1],[40964,1],[40965,2],[40966,2],[40968,12],[40970,2],[40972,2],[40973,5],[40974,9],[40975,2],[40976,5],[40977,9],[40981,1],[40982,3],[40984,3],[40988,1],[41019,11],[41021,3],[41026,1],[41028,3],[41029,3],[41032,1],[41034,3],[41036,3],[41038,1],[41103,3],[41105,1],[41121,3],[41166,2],[41167,1],[41168,2],[41169,1],[41171,2],[41173,5],[41174,11],[41175,11],[41179,3],[41182,3],[41183,3],[41184,3],[41185,3],[41186,3],[41187,3],[41188,3],[41189,3],[41191,3],[41194,1],[41238,3],[41240,1],[41241,23],[41310,3],[41328,11],[41330,11],[41334,1],[41335,1],[41380,3],[41382,3],[41384,23],[41386,1],[41394,1],[41434,11],[41456,1],[41460,1],[41464,5],[41469,23],[41471,3],[41473,1],[41549,11],[41550,11],[41552,3],[41554,11],[41556,1],[41579,4],[41581,3],[41584,1],[41586,11],[41588,3],[41589,3],[41591,3],[41592,3],[41599,1],[41601,1],[41602,4],[41603,1],[41604,1],[41605,1],[41622,3],[41634,2],[41724,3],[41725,3],[41727,1],[41729,3],[41731,5],[41744,21],[41745,21],[41747,3],[41748,3],[41752,1],[41759,1],[41770,1],[41771,1],[41772,1],[41773,1],[41774,1],[41784,2],[41785,1],[41786,1],[41787,1],[41788,2],[41789,2],[41790,1],[41793,3],[41794,3],[41836,1],[41838,2],[41841,2],[41849,2],[41850,2],[41852,2],[41853,2],[41854,2],[41855,2],[41857,1],[41859,11],[41860,11],[41861,11],[41862,11],[41866,1],[41869,3],[41871,1],[41872,1],[41873,1],[41874,1],[41875,1],[41877,1],[41879,3],[41882,1],[41884,1],[41885,1],[41886,1],[41887,1],[41888,1],[41889,1],[41890,1],[41891,1],[41893,3],[41896,5],[41898,1],[41899,1],[41900,1],[41901,1],[41903,3],[41904,3],[41907,1],[41908,1],[41911,2],[41913,2],[41914,1],[41917,3],[41918,3],[41919,3],[41920,3],[41921,3],[41922,3],[41923,3],[41924,3],[41925,3],[41926,3],[41937,1],[41940,3],[41942,3],[41944,3],[41945,3],[41948,1],[41949,1],[41954,1],[41965,1],[41966,1],[41968,1],[41972,1],[41975,1],[41977,1],[41978,1],[41988,1],[41990,1],[41991,1],[41992,1],[41993,1],[41994,1],[41995,1],[41996,1],[41997,1],[41998,1],[42000,2],[42001,1],[42009,1],[42014,1],[42015,2],[42018,1],[42019,1],[42023,1],[42026,1],[42027,1],[42028,1],[42029,1],[42031,1],[42034,1],[42041,1],[42048,1],[42058,1],[42061,12],[42063,1],[42065,1],[42070,3],[42072,1],[42075,3],[42432,3],[42662,2],[42689,2],[42691,3],[42692,3],[42695,3],[42698,3],[42709,3],[42719,1],[42738,23],[42740,3],[42741,3],[42747,3],[42749,3],[42752,1],[42753,1],[42754,1],[42755,1],[42758,5],[42759,1],[42760,1],[42761,1],[42766,2],[42767,1],[42769,2],[42771,1],[42772,1],[42773,1],[42774,1],[42775,2],[42777,2],[42778,24],[42779,1],[42780,1],[42781,1],[42782,1],[42783,2],[42785,2],[42786,2],[42792,4],[42793,1],[42795,9],[42801,3],[42803,3],[42804,3],[42805,3],[42806,3],[42807,3],[42808,3],[42809,3],[42810,3],[42811,3],[42812,3],[42814,3],[42815,3],[42818,3],[42825,1],[42826,26],[42828,1],[42829,9],[42831,18],[42835,1],[42837,1],[42838,1],[42839,1],[42840,1],[42841,1],[42842,1],[42844,2],[42845,1],[42846,1],[42847,1],[42848,1],[42849,1],[42900,1],[42901,1],[42907,3],[42915,3],[42917,23],[42920,1],[42921,9],[42922,2],[42934,3],[42939,11],[42941,1],[42942,3],[42945,1],[42946,1],[42947,1],[42949,3],[42950,3],[42951,3],[42954,1],[42955,3],[42956,3],[42957,3],[42958,3],[42959,3],[42960,3],[42961,3],[42962,3],[42963,3],[42964,3],[42965,23],[42967,3],[42969,1],[42982,12],[42984,3],[42987,1],[42988,1],[42989,1],[42990,1],[42991,1],[42992,1],[42997,1],[43001,11],[43002,11],[43005,1],[43010,1],[43011,1],[43013,1],[43014,2],[43017,2],[43022,1],[43023,1],[43024,1],[43028,1],[43029,1],[43030,1],[43032,1],[43034,1],[43039,3],[43041,1],[43045,1],[43047,1],[43048,1],[43050,2],[43051,1],[43053,1],[43054,1],[43055,11],[43056,11],[43057,11],[43058,11],[43065,1],[43070,3],[43071,3],[43072,3],[43073,3],[43074,3],[43075,3],[43076,3],[43077,3],[43078,3],[43079,3],[43080,1],[43081,1],[43082,1],[43083,1],[43099,1],[43100,1],[43107,11],[43108,11],[43111,1],[43113,3],[43114,1],[43115,2],[43116,2],[43118,1],[43123,1],[43124,1],[43125,1],[43126,1],[43127,2],[43130,2],[43131,1],[43143,1],[43144,9],[43145,1],[43146,1],[43152,1],[43155,2],[43156,2],[43157,3],[43158,2],[43159,1],[43160,1],[43162,1],[43165,1],[43167,1],[43169,2],[43170,1],[43171,1],[43172,1],[43174,3],[43175,3],[43178,3],[43180,1],[43181,1],[43182,1],[43183,1],[43184,1],[43185,1],[43186,2],[43187,2],[43188,2],[43189,2],[43192,2],[43194,4],[43195,1],[43196,2],[43197,1],[43199,2],[43204,1],[43207,11],[43208,11],[43215,1],[43223,1],[43226,1],[43228,3],[43231,3],[43232,3],[43233,3],[43234,3],[43236,1],[43245,11],[43246,11],[43249,3],[43250,3],[43251,3],[43252,3],[43253,3],[43254,3],[43255,3],[43256,3],[43257,3],[43258,3],[43259,1],[43260,1],[43262,1],[43271,3],[43272,3],[43275,1],[43276,1],[43277,1],[43279,2],[43286,23],[43339,3],[43432,3],[43435,5],[43437,1],[43439,1],[43440,1],[43441,1],[43442,1],[43443,1],[43446,2],[43450,3],[43461,1],[43463,3],[43476,1],[43477,1],[43478,3],[43479,3],[43480,3],[43481,3],[43482,3],[43484,1],[43485,1],[43488,3],[43491,1],[43495,1],[43508,11],[43518,2],[43519,2],[43529,1],[43530,1],[43539,11],[43547,5],[43556,2],[43557,2],[43558,1],[43559,1],[43560,1],[43561,1],[43562,3],[43564,11],[43565,11],[43566,11],[43567,11],[43569,3],[43570,3],[43571,3],[43572,3],[43573,3],[43574,3],[43575,3],[43576,3],[43577,3],[43578,3],[43581,11],[43582,11],[43585,1],[43587,3],[43600,1],[43602,11],[43603,11],[43609,1],[43611,3],[43613,4],[43616,5],[43618,1],[43619,1],[43622,11],[43623,11],[43632,3],[43633,3],[43636,2],[43641,1],[43642,1],[43647,11],[43648,11],[43651,3],[43655,1],[43656,3],[43657,1],[43662,1],[43668,3],[43669,2],[43672,1],[43676,1],[43677,5],[43678,1],[43683,11],[43687,11],[43689,1],[43690,1],[43694,2],[43695,1],[43696,2],[43697,1],[43698,3],[43700,3],[43706,11],[43707,11],[43710,5],[43712,2],[43713,3],[43717,1],[43719,1],[43720,3],[43721,1],[43722,2],[43723,1],[43724,1],[43725,1],[43729,3],[43730,1],[43731,1],[43732,1],[43733,1],[43737,1],[43738,2],[43741,1],[43742,1],[43743,1],[43744,2],[43745,1],[43746,1],[43747,1],[43751,3],[43752,3],[43753,3],[43759,3],[43764,3],[43765,22],[43767,1],[43768,1],[43769,1],[43770,3],[43772,2],[43773,2],[43774,2],[43775,3],[43780,2],[43783,1],[43785,2],[43790,1],[43792,16],[43793,5],[43794,22],[43795,2],[43796,2],[43797,1],[43799,22],[43800,1],[43802,1],[43803,3],[43804,2],[43805,2],[43807,2],[43808,3],[43809,3],[43811,5],[43812,1],[43813,2],[43815,2],[43816,3],[43817,3],[43818,3],[43819,2],[43820,1],[43822,1],[43823,1],[43824,3],[43831,1],[43833,1],[43836,2],[43837,2],[43838,2],[43839,2],[43840,2],[43841,2],[43842,2],[43849,2],[43850,2],[43853,5],[43855,2],[43856,5],[43857,15],[43858,2],[43860,5],[43861,2],[43864,3],[43866,1],[43867,3],[43871,2],[43873,11],[43874,2],[43876,1],[43877,1],[43879,3],[43880,29],[43881,2],[43882,1],[43883,1],[43884,1],[43885,1],[43886,1],[43887,1],[43888,1],[43889,1],[43890,1],[43893,1],[43899,1],[43902,1],[43905,1],[43906,1],[43907,1],[43908,3],[43909,1],[43910,1],[43911,1],[43912,1],[43913,1],[43914,3],[43915,1],[43917,2],[43920,3],[43922,3],[43923,3],[43924,3],[43925,3],[43926,3],[43927,3],[43928,3],[43929,3],[43930,3],[43931,3],[43932,2],[43934,1],[43935,1],[43937,3],[43941,1],[43942,1],[43943,1],[43944,2],[43946,1],[44034,3],[44035,3],[44047,1],[44048,3],[44057,3],[44058,3],[44059,3],[44060,3],[44061,3],[44062,3],[44067,3],[44071,3],[44072,1],[44076,3],[44078,1],[44080,1],[44084,1],[44085,1],[44086,1],[44087,1],[44088,1],[44090,1],[44095,1],[44098,1],[44103,1],[44105,2],[44109,2],[44112,3],[44113,3],[44114,3],[44115,3],[44186,3],[44204,11],[44207,1],[44209,1],[44226,2],[44229,2],[44231,11],[44233,1],[44299,11],[44307,3],[44310,1],[44311,3],[44312,1],[44313,2],[44314,1],[44315,2],[44316,2],[44322,1],[44323,1],[44324,1],[44333,3],[44334,3],[44337,11],[44339,2],[44340,2],[44341,2],[44343,1],[44344,2],[44347,2],[44349,1],[44350,1],[44351,1],[44353,1],[44354,2],[44356,2],[44358,1],[44360,2],[44365,1],[44367,1],[44387,1],[44388,2],[44389,1],[44390,1],[44391,2],[44392,5],[44394,5],[44396,1],[44399,2],[44400,2],[44402,1],[44403,1],[44404,5],[44405,1],[44406,2],[44407,1],[44408,2],[44409,1],[44411,1],[44412,2],[44414,1],[44415,2],[44419,3],[44421,2],[44422,2],[44423,2],[44424,2],[44449,1],[44450,1],[44451,1],[44453,3],[44457,3],[44475,3],[44476,3],[44479,3],[44481,3],[44482,2],[44486,1],[44487,3],[44488,1],[44495,1],[44497,2],[44498,2],[44499,1],[44506,11],[44517,4],[44519,2],[44520,5],[44528,1],[44529,1],[44534,1],[44536,1],[44537,1],[44538,1],[44539,1],[44542,11],[44543,11],[44552,1],[44622,1],[44624,3],[44625,25],[44628,5],[44634,9],[44637,2],[44703,1],[44704,1],[44705,3],[44706,2],[44709,11],[44777,1],[44779,1],[44780,1],[44781,1],[44782,1],[44783,1],[44785,2],[44786,2],[44793,11],[44794,11],[44797,14],[44800,3],[44801,3],[44804,1],[44806,2],[44808,1],[44809,1],[44811,1],[44812,1],[44814,1],[44816,1],[44818,1],[44819,1],[44824,4],[44835,14],[44836,1],[44838,1],[44839,1],[44840,3],[44841,3],[44842,2],[44843,2],[44850,11],[44852,2],[44853,2],[44855,1],[44856,1],[44857,1],[44859,1],[44860,1],[44861,1],[44863,1],[44864,11],[44865,11],[44868,3],[44873,1],[44874,5],[44876,3],[44877,5],[44879,1],[44880,1],[44881,2],[44883,1],[44903,1],[44905,3],[44906,3],[44907,3],[44910,2],[44978,3],[45016,1],[45017,1],[45018,1],[45019,3],[45024,3],[45026,3],[45027,3],[45110,2],[45114,2],[45116,2],[45117,2],[45121,2],[45125,2],[45131,3],[45132,3],[45133,3],[45134,3],[45135,3],[45136,3],[45137,3],[45138,3],[45139,3],[45140,3],[45141,3],[45142,3],[45143,3],[45144,3],[45145,3],[45146,3],[45147,3],[45148,3],[45149,3],[45150,3],[45151,3],[45152,3],[45153,3],[45154,3],[45155,3],[45156,3],[45157,3],[45158,3],[45159,3],[45160,3],[45161,3],[45162,3],[45163,3],[45164,3],[45165,1],[45245,3],[45246,1],[45249,2],[45250,2],[45251,2],[45253,2],[45254,3],[45263,5],[45264,5],[45344,11],[45358,11],[45424,3],[45425,3],[45426,3],[45427,3],[45428,3],[45429,3],[45430,3],[45431,3],[45432,3],[45433,3],[45434,3],[45435,3],[45436,3],[45437,3],[45438,3],[45439,3],[45440,3],[45441,3],[45442,3],[45443,3],[45444,3],[45445,3],[45446,3],[45447,3],[45448,3],[45449,3],[45450,3],[45451,3],[45452,3],[45453,3],[45454,3],[45455,3],[45456,3],[45457,3],[45460,1],[45461,1],[45462,1],[45465,3],[45602,3],[45603,3],[45605,3],[45606,2],[45608,1],[45610,2],[45611,2],[45612,2],[45624,1],[45625,1],[45721,1],[45723,10],[45724,10],[45725,10],[45726,6],[45727,2],[45788,1],[45789,1],[45790,1],[45794,1],[45795,2],[45796,1],[45807,11],[45854,11],[45856,1],[45859,5],[45860,1],[45863,3],[45873,1],[45874,1],[45875,1],[45876,1],[45916,2],[45920,3],[45939,1],[45941,2],[45985,3],[45986,3],[46025,1],[46026,4],[46088,1],[46089,1],[46112,3],[46113,25],[46114,3],[46179,1],[46180,1],[46200,22],[46232,1],[46233,2],[46234,2],[46235,1],[46265,1],[46266,4],[46267,9],[46272,1],[46273,1],[46274,9],[46275,3],[46276,2],[46277,1],[46278,4],[46279,4],[46280,2],[46281,2],[46284,5],[46285,1],[46286,1],[46288,1],[46289,1],[46290,1],[46291,1],[46292,1],[46293,1],[46294,2],[46295,2],[46296,1],[46297,1],[46298,1],[46299,1],[46312,1],[46313,1],[46314,1],[46315,1],[46316,1],[46317,1],[46318,1],[46319,3],[46320,1],[46396,1],[46454,1],[46455,1],[46456,1],[46457,1],[46458,1],[46459,1],[46460,1],[46461,1],[46462,1],[46469,1],[46478,1],[46479,1],[46486,3],[46487,3],[46488,3],[46489,1],[46490,5],[46491,5],[46492,3],[46493,5],[46494,5],[46495,5],[46496,1],[46497,1],[46498,3],[46499,3],[46500,1],[46501,1],[46502,1],[46503,1],[46504,9],[46505,9],[46506,9],[46507,9],[46527,1],[46528,1],[46529,1],[46597,1],[46609,1],[46610,1],[46621,1],[46735,1],[46737,1],[46738,1],[46805,11],[46807,1],[46808,1],[46809,1],[46810,2],[46812,1],[46813,1],[46814,1],[46815,1],[46816,1],[46817,1],[46818,9],[46819,1],[46820,1],[46821,1],[46822,1],[46825,1],[46826,11],[46827,1],[46828,1],[46829,1],[46830,1],[46831,1],[46832,1],[46833,1],[46835,1],[46836,1],[46840,1],[46904,2],[46905,1],[46906,1],[46907,1],[46908,1],[46909,1],[46910,1],[46911,1],[46912,1],[46914,1],[46916,3],[46918,1],[46926,1],[46931,1],[46941,1],[46943,1],[46984,1],[47202,3],[47226,1],[47227,3],[47228,3],[47229,3],[47230,2],[47231,1],[47234,5],[47235,5],[47237,1],[47253,1],[47256,3],[47258,3],[47259,3],[47260,3],[47261,3],[47262,3],[47263,3],[47264,3],[47265,3],[47266,3],[47267,3],[47268,3],[47269,3],[47270,3],[47271,3],[47272,3],[47273,3],[47274,3],[47275,3],[47276,3],[47277,3],[47278,3],[47279,3],[47280,3],[47281,3],[47282,3],[47283,3],[47284,3],[47285,3],[47286,3],[47287,3],[47288,3],[47289,3],[47290,3],[47291,3],[47292,3],[47293,3],[47294,10],[47295,10],[47296,2],[47297,1],[47298,9],[47299,2],[47300,1],[47302,1],[47305,1],[47306,3],[47309,2],[47310,1],[47317,2],[47318,2],[47319,5],[47320,2],[47321,3],[47346,3],[47429,3],[47430,3],[47438,9],[47440,3],[47441,1],[47445,5],[47446,3],[47448,2],[47450,1],[47452,1],[47453,1],[47454,3],[47456,1],[47457,1],[47459,3],[47461,3],[47462,1],[47463,9],[47465,3],[47467,2],[47469,1],[47471,1],[47472,1],[47473,1],[47474,1],[47475,1],[47476,3],[47477,1],[47478,1],[47483,9],[47484,2],[47485,1],[47486,3],[47487,3],[47488,3],[47490,1],[47491,1],[47492,1],[47493,1],[47494,1],[47495,1],[47496,1],[47498,1],[47500,1],[47501,3],[47502,22],[47503,3],[47505,22],[47506,1],[47507,1],[47508,22],[47509,4],[47510,1],[47511,1],[47514,1],[47515,1],[47516,1],[47517,3],[47518,1],[47519,3],[47521,1],[47522,1],[47523,1],[47524,9],[47525,1],[47526,2],[47527,1],[47528,1],[47529,1],[47530,1],[47531,3],[47532,1],[47533,1],[47536,1],[47538,1],[47539,1],[47540,1],[47541,2],[47542,1],[47543,1],[47544,1],[47545,1],[47546,1],[47612,1],[47613,3],[47615,2],[47616,2],[47617,1],[47684,1],[47685,1],[47686,1],[47687,1],[47688,1],[47691,1],[47693,1],[47695,1],[47698,1],[47699,1],[47701,2],[47719,1],[47851,2],[47854,1],[47856,1],[47857,1],[47932,1],[47933,1],[47934,1],[47935,1],[47936,1],[47937,2],[47938,5],[47939,2],[47940,1],[47941,3],[47942,2],[47943,2],[47944,2],[47948,3],[47949,3],[47950,5],[47953,1],[47954,2],[47955,3],[47956,2],[47957,9],[47961,3],[47965,1],[47966,9],[47967,2],[47968,9],[47969,1],[47971,1],[47972,2],[48042,3],[48043,3],[48044,3],[48045,3],[48046,3],[48047,3],[48048,3],[48049,3],[48050,3],[48051,3],[48052,3],[48053,3],[48054,3],[48055,3],[48056,3],[48057,3],[48058,3],[48059,3],[48060,3],[48061,3],[48062,3],[48063,3],[48064,3],[48065,3],[48066,3],[48067,3],[48068,3],[48069,3],[48070,3],[48071,3],[48072,3],[48073,3],[48074,3],[48075,3],[48076,3],[48077,3],[48079,1],[48083,9],[48084,9],[48085,9],[48157,2],[48210,3],[48211,3],[48212,3],[48213,3],[48214,3],[48215,3],[48216,3],[48217,3],[48218,3],[48219,3],[48220,3],[48221,3],[48222,3],[48223,3],[48224,3],[48225,3],[48226,3],[48227,3],[48228,3],[48229,3],[48230,3],[48231,3],[48232,3],[48233,3],[48234,3],[48235,3],[48236,3],[48237,3],[48238,3],[48239,3],[48240,3],[48241,3],[48242,3],[48243,3],[48244,3],[48245,3],[48247,1],[48248,1],[48249,1],[48250,2],[48251,2],[48252,2],[48253,2],[48254,1],[48255,1],[48256,2],[48261,1],[48268,1],[48269,1],[48270,3],[48271,9],[48272,1],[48273,1],[48275,5],[48340,1],[48423,1],[48424,1],[48425,1],[48426,2],[48605,1],[48606,2],[48618,1],[48767,3],[48768,3],[48769,3],[48770,3],[48771,3],[48772,3],[48773,3],[48774,3],[48775,3],[48776,3],[48777,3],[48778,3],[48779,3],[48780,3],[48781,3],[48782,3],[48783,3],[48784,3],[48785,3],[48786,3],[48787,3],[48788,3],[48789,3],[48790,3],[48791,3],[48792,3],[48793,3],[48794,3],[48795,3],[48796,3],[48797,3],[48798,3],[48799,3],[48800,3],[48801,3],[48802,3],[48808,1],[48838,3],[48840,1],[48841,5],[48842,1],[48844,21],[48846,2],[48847,2],[48848,2],[48850,1],[48859,11],[48860,1],[48861,1],[48863,1],[48864,2],[48865,1],[48872,1],[48873,1],[48874,3],[48875,3],[48876,1],[48877,3],[48878,1],[48882,3],[48883,3],[48884,3],[48885,1],[48886,3],[48887,3],[48888,3],[48889,3],[48890,3],[48891,3],[48892,9],[48893,3],[48894,3],[48895,3],[48896,3],[48897,3],[48898,9],[48899,3],[48900,9],[48901,1],[48902,3],[48903,3],[48904,3],[48905,1],[48906,1],[48907,2],[48909,9],[48910,4],[48911,30],[48914,1],[48915,3],[48916,1],[48917,1],[48918,1],[48919,1],[48920,1],[48921,1],[48922,1],[48923,1],[48924,17],[48925,1],[48926,1],[48927,1],[48928,1],[48929,1],[48930,1],[48931,22],[48938,3],[48942,1],[48943,3],[48948,22],[48950,1],[48951,2],[48952,3],[48953,1],[48954,3],[48955,3],[48957,22],[48959,1],[48960,3],[48961,3],[48962,1],[48963,1],[48964,2],[48965,2],[48966,1],[48967,3],[48968,3],[48970,3],[48971,3],[48972,3],[48973,3],[48974,3],[48975,3],[48976,3],[48977,3],[48978,3],[48979,3],[48980,3],[48981,3],[48982,3],[48984,3],[48985,3],[48986,3],[48987,3],[48988,3],[48989,3],[48990,3],[48991,3],[48992,3],[48993,3],[48994,3],[48995,3],[48996,3],[48997,3],[48998,3],[48999,3],[49000,3],[49001,3],[49002,3],[49003,1],[49004,1],[49005,1],[49006,1],[49007,1],[49008,1],[49011,3],[49013,2],[49014,1],[49015,2],[49016,2],[49018,1],[49019,1],[49020,1],[49021,1],[49022,1],[49026,1],[49027,2],[49028,1],[49029,1],[49049,1],[49052,2],[49055,3],[49056,3],[49059,2],[49060,2],[49062,3],[49066,1],[49067,9],[49068,5],[49069,2],[49070,1],[49071,1],[49072,1],[49075,3],[49076,3],[49077,3],[49078,3],[49079,3],[49080,3],[49081,3],[49082,3],[49083,3],[49084,3],[49085,3],[49086,3],[49087,3],[49088,3],[49089,3],[49090,3],[49091,3],[49092,3],[49093,3],[49094,3],[49095,3],[49096,3],[49097,3],[49098,3],[49099,3],[49100,3],[49101,3],[49102,3],[49103,3],[49104,3],[49105,3],[49106,3],[49107,3],[49108,3],[49112,3],[49114,3],[49115,3],[49122,1],[49125,3],[49186,3],[49187,3],[49188,3],[49189,3],[49190,3],[49191,3],[49192,3],[49193,3],[49194,3],[49195,3],[49196,3],[49197,3],[49198,3],[49199,3],[49200,3],[49201,3],[49202,3],[49203,3],[49204,3],[49205,3],[49206,3],[49207,3],[49208,3],[49209,3],[49210,3],[49211,3],[49212,3],[49213,3],[49214,3],[49215,3],[49216,3],[49217,3],[49218,3],[49219,3],[49256,1],[49260,1],[49261,2],[49262,2],[49263,5],[49264,4],[49279,3],[49280,3],[49281,3],[49282,3],[49283,3],[49284,3],[49285,3],[49286,3],[49287,3],[49288,3],[49289,3],[49290,3],[49291,3],[49292,3],[49293,3],[49294,3],[49295,3],[49296,3],[49297,3],[49298,3],[49299,3],[49300,3],[49301,3],[49302,3],[49303,3],[49304,3],[49305,3],[49306,3],[49307,3],[49308,3],[49309,3],[49310,3],[49311,3],[49312,3],[49313,3],[49314,3],[49315,5],[49317,2],[49319,1],[49321,2],[49322,1],[49323,9],[49324,1],[49325,2],[49330,2],[49332,3],[49333,3],[49336,23],[49338,1],[49383,1],[49384,1],[49387,1],[49390,1],[49391,1],[49392,1],[49395,9],[49396,2],[49397,2],[49398,9],[49399,2],[49400,9],[49402,2],[49403,2],[49464,1],[49465,1],[49466,1],[49469,1],[49470,1],[49492,1],[49495,1],[49501,2],[49503,1],[49505,3],[49809,11],[49810,11],[49812,1],[49813,2],[49814,10],[49815,1],[49816,1],[49817,9],[49818,2],[49949,1],[49950,1],[49954,5],[49961,1],[49962,1],[50001,3],[50002,3],[50005,3],[50212,3],[50319,3],[50321,2],[50322,2],[50469,3],[50470,3],[50471,3],[50472,3],[50473,3],[50474,3],[50475,3],[50476,3],[50477,3],[50478,3],[50479,3],[50480,3],[50481,3],[50482,3],[50483,3],[50484,3],[50485,3],[50486,3],[50487,3],[50488,3],[50489,3],[50490,3],[50491,3],[50492,3],[50493,3],[50494,3],[50495,3],[50496,3],[50497,3],[50498,3],[50499,3],[50500,3],[50501,3],[50502,3],[50503,3],[50504,3],[50986,1],[50992,1],[50993,1],[50995,1],[50996,1],[50997,1],[50998,3],[50999,1],[51000,1],[51002,3],[51003,1],[51004,1],[51005,1],[51006,1],[51007,1],[51008,1],[51009,1],[51010,1],[51011,1],[51013,2],[51014,2],[51015,1],[51016,1],[51017,1],[51018,1],[51019,3],[51020,1],[51021,1],[51022,1],[51023,1],[51024,1],[51026,1],[51027,1],[51028,1],[51029,1],[51030,1],[51031,3],[51032,2],[51033,3],[51035,1],[51036,1],[51037,1],[51038,1],[51039,1],[51040,1],[51041,1],[51042,1],[51043,1],[51045,1],[51046,1],[51047,1],[51048,1],[51049,1],[51050,1],[51051,3],[51052,1],[51054,1],[51056,1],[51057,3],[51058,1],[51059,3],[51062,3],[51063,3],[51064,1],[51065,1],[51066,1],[51068,3],[51069,2],[51070,1],[51071,1],[51072,1],[51073,3],[51075,2],[51077,2],[51082,2],[51083,3],[51084,2],[51087,1],[51088,1],[51094,2],[51095,2],[51096,2],[51097,3],[51098,1],[51099,2],[51102,2],[51280,21],[51281,21],[51284,1],[51444,1],[51445,1],[51511,1],[51622,3],[51623,3],[51624,3],[51625,3],[51626,3],[51627,3],[51628,3],[51629,3],[51630,3],[51631,3],[51632,3],[51633,3],[51634,3],[51635,3],[51636,3],[51637,3],[51638,3],[51639,3],[51640,3],[51641,3],[51642,3],[51643,3],[51644,3],[51645,3],[51646,3],[51647,3],[51648,3],[51649,3],[51650,3],[51651,3],[51652,3],[51653,3],[51654,3],[51655,3],[51656,1],[51657,4],[51658,1],[51822,1],[51824,2],[51825,1],[51826,1],[51827,1],[51828,1],[51829,1],[51830,1],[51831,1],[51832,1],[51833,1],[51834,1],[51835,1],[51836,1],[51837,1],[51838,1],[51839,1],[51840,1],[51841,1],[51843,1],[51845,1],[51846,1],[51847,1],[51850,1],[51946,3],[51947,3],[51948,3],[51949,3],[51950,3],[51951,3],[51954,1],[52009,3],[52010,3],[52011,3],[52012,3],[52013,3],[52014,3],[52015,3],[52016,3],[52017,2],[52018,3],[52019,3],[52020,3],[52021,3],[52022,3],[52023,3],[52024,3],[52025,3],[52026,3],[52027,3],[52028,3],[52029,3],[52084,1],[52146,3],[52150,2],[52151,1],[52157,3],[52158,4],[52159,1],[52160,2],[52161,9],[52162,3],[52164,3],[52165,22],[52166,3],[52167,22],[52168,1],[52170,22],[52171,1],[52172,1],[52174,1],[52175,9],[52176,3],[52177,3],[52178,1],[52179,3],[52180,3],[52181,3],[52182,3],[52183,3],[52184,1],[52185,3],[52186,3],[52187,3],[52188,2],[52189,1],[52190,1],[52191,4],[52192,4],[52193,1],[52194,1],[52196,1],[52197,1],[52200,1],[52202,1],[52255,3],[52257,4],[52259,1],[52320,1],[52322,1],[52388,1],[52389,1],[52390,1],[52391,1],[52392,1],[52394,3],[52395,3],[52396,3],[52397,3],[52398,3],[52399,3],[52400,3],[52401,3],[52402,3],[52403,3],[52404,3],[52405,3],[52406,3],[52407,3],[52408,3],[52409,3],[52410,3],[52411,3],[52412,3],[52413,3],[52414,3],[52415,3],[52416,3],[52417,3],[52418,2],[52419,2],[52420,2],[52422,1],[52423,2],[52424,2],[52426,2],[52443,1],[52444,1],[52445,1],[52446,1],[52447,1],[52448,1],[52449,1],[52450,1],[52713,1],[52732,1],[52733,1],[52735,1],[52736,1],[52741,1],[52747,1],[52748,1],[52749,1],[52752,1],[52755,1],[52758,1],[52759,1],[52762,1],[52764,1],[52769,1],[52785,19],[52786,19],[52787,19],[52788,19],[52789,19],[52790,19],[52791,19],[52792,19],[52793,19],[52817,3],[52887,1],[52888,3],[52889,10],[52890,10],[52891,10],[52892,10],[52894,9],[52897,1],[52898,1],[52899,9],[52901,5],[52903,3],[52904,3],[52907,1],[52908,1],[52909,1],[52912,1],[52933,3],[52935,1],[52937,1],[52941,2],[52942,2],[52984,11],[53100,3],[53102,1],[53128,1],[53129,1],[53316,1],[53318,1],[53352,2],[53355,1],[53370,1],[53371,2],[53372,2],[53586,2],[53698,1],[53757,3],[53758,3],[53760,1],[53761,1],[53763,1],[53765,3],[53808,3],[53813,3],[53815,1],[53876,2],[53877,1],[53883,1],[53884,2],[53885,2],[53943,1],[53946,1],[53947,1],[53948,2],[53949,2],[53950,2],[53960,3],[53961,3],[54020,2],[54021,2],[54026,3],[54027,3],[54029,5],[54031,11],[54033,3],[54035,1],[54042,1],[54043,1],[54045,1],[54048,3],[54109,22],[54110,22],[54113,3],[54114,3],[54115,3],[54116,3],[54117,3],[54118,3],[54119,3],[54120,3],[54121,3],[54122,3],[54123,3],[54124,3],[54125,3],[54126,3],[54127,3],[54128,3],[54129,3],[54130,3],[54131,3],[54132,3],[54133,3],[54134,3],[54135,3],[54136,3],[54137,3],[54138,3],[54139,3],[54140,3],[54141,3],[54142,3],[54143,3],[54144,3],[54145,3],[54146,3],[54147,3],[54148,3],[54225,3],[54642,3],[54643,3],[54644,3],[54645,3],[54646,3],[54647,3],[54648,3],[54649,3],[54650,3],[54651,3],[54652,3],[54653,3],[54654,3],[54655,3],[54656,3],[54657,3],[54658,3],[54659,3],[54660,3],[54661,3],[54662,3],[54663,3],[54664,3],[54665,3],[54666,3],[54667,3],[54668,3],[54669,3],[54670,3],[54671,3],[54672,3],[54673,3],[54674,3],[54675,3],[54676,3],[54677,3],[54678,3],[54679,3],[54680,3],[54681,3],[54940,5],[55009,9],[55010,1],[55011,1],[55012,3],[55013,1],[55014,1],[55015,1],[55017,1],[55018,1],[55019,1],[55020,1],[55021,1],[55022,1],[55023,1],[55024,1],[55025,1],[55026,1],[55027,1],[55028,1],[55029,1],[55030,1],[55031,1],[55032,1],[55033,1],[55034,3],[55035,1],[55036,1],[55037,1],[55038,1],[55039,1],[55040,1],[55041,9],[55042,1],[55043,1],[55044,1],[55045,1],[55046,3],[55047,1],[55048,1],[55049,1],[55050,1],[55052,9],[55053,1],[55054,9],[55055,1],[55056,2],[55057,1],[55058,9],[55061,9],[55062,1],[55063,1],[55064,1],[55065,1],[55066,1],[55067,2],[55068,1],[55069,1],[55070,1],[55071,1],[55072,5],[55073,5],[55074,1],[55075,1],[55076,9],[55077,1],[55078,1],[55079,1],[55080,1],[55081,2],[55082,1],[55083,1],[55084,2],[55085,1],[55086,1],[55087,3],[55088,1],[55089,3],[55090,9],[55091,3],[55092,3],[55093,1],[55094,3],[55095,3],[55096,3],[55097,3],[55098,3],[55099,3],[55100,3],[55101,3],[55102,3],[55103,2],[55107,1],[55108,2],[55133,2],[55134,28],[55135,28],[55136,2],[55137,2],[55138,2],[55140,3],[55141,3],[55142,3],[55143,3],[55144,3],[55145,3],[55146,3],[55147,3],[55148,3],[55149,3],[55150,3],[55151,3],[55152,3],[55153,3],[55154,3],[55155,3],[55156,3],[55157,3],[55158,3],[55159,3],[55160,3],[55161,3],[55162,3],[55163,3],[55164,3],[55165,3],[55166,3],[55167,3],[55168,3],[55169,3],[55239,3],[55242,2],[55243,2],[55244,1],[55248,1],[55249,1],[55250,1],[55251,1],[55252,1],[55253,1],[55254,1],[55255,1],[55256,1],[55257,1],[55258,1],[55259,1],[55260,1],[55261,2],[55263,3],[55264,2],[55268,11],[55324,22],[55326,22],[55327,22],[55329,1],[55506,1],[55508,3],[55562,9],[55683,3],[55686,3],[55690,1],[55801,3],[55802,3],[55803,3],[55804,3],[55805,3],[55806,3],[55807,3],[55808,3],[55809,3],[55810,3],[55811,3],[55812,3],[55813,3],[55814,3],[55815,3],[55816,3],[55817,3],[55818,3],[55819,3],[55820,3],[55821,3],[55822,3],[55823,3],[55824,3],[55825,3],[55826,3],[55827,3],[55828,3],[55829,3],[55830,3],[55836,1],[55839,1],[55841,3],[55844,1],[55861,2],[55909,1],[55910,1],[55912,1],[55970,3],[55971,3],[55973,28],[55974,28],[55975,28],[55976,28],[55978,1],[55982,1],[55983,1],[56055,3],[56056,3],[56057,3],[56058,3],[56059,3],[56060,3],[56061,3],[56062,3],[56063,3],[56064,3],[56065,3],[56066,3],[56067,3],[56068,3],[56069,3],[56070,3],[56071,3],[56072,3],[56073,3],[56074,3],[56075,3],[56076,3],[56077,3],[56078,3],[56079,3],[56080,3],[56081,3],[56083,1],[56091,14],[56153,1],[56154,1],[56155,1],[56156,1],[56157,1],[56162,9],[56163,9],[56164,9],[56165,9],[56166,9],[56167,9],[56168,9],[56169,9],[56170,9],[56171,9],[56174,3],[56178,1],[56179,2],[56180,1],[56181,3],[56182,1],[56183,3],[56184,2],[56185,1],[56186,4],[56187,4],[56188,5],[56189,2],[56190,1],[56191,22],[56193,22],[56194,1],[56195,1],[56196,2],[56197,1],[56198,1],[56199,2],[56201,1],[56202,1],[56203,1],[56204,4],[56205,1],[56206,1],[56207,1],[56208,2],[56209,4],[56210,1],[56211,14],[56212,2],[56213,1],[56214,3],[56215,9],[56216,9],[56217,3],[56218,3],[56219,4],[56220,4],[56221,5],[56222,5],[56223,9]]} +{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose","detailedPurpose":"Detailed Purpose","users":"Users","orbitClass":"Class of Orbit"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose","detailedPurpose","users","orbitClass"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","Taiwan's National Space Organization/National Oceanographic and Atmospheric Admistration (NOAA)","National Space Program Office (NSPO)/University Corporation for Atmospheric Research (UCAR) Boulder, CO","Italian Space Agency/Ministry of Defense","California Polytechnic State University/NASA JPL","University of Colorado","Directorate General of Armaments (DGA)","Argentinian Ministry of Science, Technology and Productive Innovation/Radio Club Bariloche","University of Illinois Urbana-Champaign","Department of Astronautics, University of Tokyo","National Aeronautics and Space Administration (NASA)","Department of Astrophysical and Planetary Science, UC Boulder/National Aeronautics and Space Administration (NASA)","University of Michigan/NASA Earth Science Technology Office","German Orbital Systems","Mexican Space Agency/NanoAvionics","Japan Aerospace Exploration Agency (JAXA)","North Idaho STEM Charter Academy","ADA Space (?)","China Amateur Satellite - CAMSAT","Deimos Imaging/DMC International Imaging (DMCII)","Technical University, Delft","Massachusetts Institute of Technology","Dubai Electricity and Water Authority (DEWA)","Khalifa University","DARPA (Defense Advanced Research Projects Agency)","Sky and Space Global, UK","SpacePharma","IT University of Copenhagen, University of Southern Denmark, Aalborg University and Aarhus University","Philippines’ Department of Science and Technology, University of the Philippines Diliman, and Japan’s Hokkaido University and Tohoku University","Deutsches Zentrum für Luft- und Raumfahrt","Mohammend Bin Rashid Space Centre (MBRSC)","DoD/NOAA","University of Southern California, Space Engineering Research Center/Lockheed Martin","Planet Labs, Inc.","China National Space Administration","Kawasaki Heavy Industries","Singapore Armed Forces","Emirates Institution for Advanced Science & Technology (EIAST)","Academy of Military Science","ESPACE","Global Eagle Entertainment","OHB Italia","Echostar Satellite Services, LLC","HughesNet leased from Echostar Technologies, LLC","Echostar Satellite Services, LLC/Intelsat","Equator Technological University","Airbus","National Authority for Remote Sensing and Space Science (NARSS)","Momentus","Roshydromet - Planeta","University of California","DGA (Arms Procurement Agency)/Centre National d'Etudes Spatiales (CNES)","Clyde Space Ltd./EUTELSAT","Astroscale","Defence Research and Development Organization","EOS Data Analytics","Earth Sciences Enterprise (NASA)","Goddard Space Flight Center/EOS Data and Operations System","National Aeronautics and Space Administration (NASA) - Earth Science Enterprise/Japan Meteorological Agency/Brazilian Space Agency","Defense Ministry","ImageSat International, NV/Ministry of Defense","EUTELSAT S.A./Es'hailSat","Es’hailSat","ESA/European Maritime Safety Agency","European University Network","Ethiopian Space Science and Technology Institute","EUTELSAT Americas","EUTELSAT S.A./Nilesat","exactEarth","Defense Advanced Research Projects Agency (DARPA)","California Polytechnic Institute","Russian Satellite Communications Company","Exseed Space","CNES (Centre national d'études spatiales)","Colombian Air Force","US Air Force Academy","InSpace","University of Texas - Austin","Link Space Aerospace Technology","China Meteorological Administration","National Aeronautics and Space Administration (NASA)/GSFC","University of Stuttgart","US Navy","National Space Organization","Los Alamos National Laboratory","Jihua Laboratory","Copernicus Land and Marine Environment services","AMSAT-UK","PanAmSat (Intelsat S.A.)","PanAmSat (Intelsat S.A.)/Sky Perfect JSAT Corp.","Intelsat S.A.","PanAmSat (Intelsat S.A.)/DirecTV, Inc.","Gama","China National Academy of Sciences (CNSAS)","China’s Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","State Oceanic Administration (SOA)","China's Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","Ministry of Natural Resources/Ministry of Housing and Urban-Rural Development/National Bureau of Statistics","People's Liberation Army (C41)","GomSpace ApS","Air Force Research Laboratory/Globalstar","NearSpace Launch/Air Force Research Laboratory","Geespace","Bigelow Aerospace","Korea Meteorological Administration/National Meteorological Satellite Center","DigitalGlobe Corporation","Institute of Space and Astronautical Science (ISAS)/NASA/ESA","Marshall Intec","GHGSat, Inc.","Orbital Sidekick","Globalstar","OHB SE (Orbitale Hochtechnologie Bremen)","PlanetiQ","NOAA (National Oceanographic and Atmospheric Administration)","Turkish Ministry of National Defense","Gonets Satcom","JAXA, Ministry of Environment, National Institute of Environmental Studies","Lunasonde","LuxGovSat","National Aeronautics and Space Administration (NASA)/JAXA","NASA/German Research Centre for Geosciences (GFZ)","Axelspace","Indian Navy","Indian Air Force","Air Force Satellite Control Network","U.S. Army’s Space and Missile Defense Command","Department of Defense/Army Space and Missile Defense Command","Sanya Institute of Remote Sensing","Xiamen University/Aerospace Dongfanghong Satellite Co. Ltd. {CASC)","Department of Defense/Space Test Program","Hunan Hangsheng Satellite Technology Co., Ltd.","US Army Space and Missile Defense Command","HawkEye 360","HEAD Aerospace","Centre National d'Etudes Spatiales (CNES)/Délégation Générale de l'Armement (DGA)","Hellas-Sat Consortium Ltd.","Hellas-Sat Consortium Ltd./INMARSAT","Tokyo Institute of Technology","Hiber Global","Japan Meteorological Agency/Meteorological Satellite Center (MSC)","Institute of Space and Aeronautical Science/NASA","Spacety","Hispasat","National Remote Sensing Center (NRSCC)","China Centre for Resources Satellite Data and Application (CRESDA)","University of Tokyo and NESTRA","PIESAT","China Aerospace Science and Technology Corporation (CASC)","China Aerospace Science and Industry Corporation (CASIC)","Horizons 2 Satellite, LLC (Intelsat, Sky Perfect JSAT Corporation)","Egyptian Space Agency","Kyushu Institute of Technology (KIT)","Harris Corporation","Shanghai Lizheng Satellite Application Technology","Ministry of Ecology and Environment/Ministry of Emergency Management","European Space Agency (ESA)/NASA","Huaian Youth Comprehensive Development Base","National Space Science Center (NSSC)","Avanti Communications, PLC","ICEYE Ltd.","Cabinet Satellite Intelligence Center (CSIC)","TÜBİTAK Space Technologies Research Institute","INMARSAT, Ltd.","INMARSAT, Ltd./European Space Agency (ESA)","Astronautic Technology Sdn. Bhd (ATSB)","University of Colorado’s Laboratory for Atmospheric and Space Physics/Indian Institute of Space Science and Technology","NRS Lab. Atmospheres, Milieux, Observations Spatiales (Verrieres)","European Space Operations Centre (ESOC)/NASA/Russia","Intelsat S.A./Sky Perfect JSAT Corp.","National Aeronautics and Space Administration (NASA)/Multinational","National Aeronautics and Space Administration (NASA)/Goddard Space Flight Center","D-Orbit","Iridium Communications, Inc.","National Center for Atmospheric Research (NCKU)","NASA Small Satellite Technology Program","iSky Technology","NASA/CalPoly","TrustPoint, Inc.","ITU Space Systems Design & Test Laboratory","Radio Amateur Society of Thailand (RAST)","National Aeronautics and Space Administration (NASA)/Centre National d'Etudes Spatiales (CNES)/NOAA/EUMETSAT","Sky Perfect JSAT Corporation","Sky Perfect JSAT Corporation/Kacific","Space OK (Shanghai Ok Space Co Ltd.)","Chang Guang Satellite Technology Co. Ltd.","Beijing ZeroG Space Technology Co., Ltd./Hong Kong Aerospace Tech Group","Shanghai Jiao Tong University/APSCO","Gang Hang Ke","Hong Kong Aerospace Technology Group","Chinese University Corporation for Polar Research","Crown Prince Foundation","China Aerospace Science and Technology Corp. (CASC)","Scientific Production Corporation (joint stock creation of Russian Space Agency)","Roscosmos State Corporation","Kazcosmos","JSC KazSat","Ghalam LLP","National University of Singapore","Kepler Communications","US Army Space and Missile Defense Command/Army Forces Strategic Command","Revolutionary Guard Corps","GUMUSH AeroSpace/University of Turkish Aeronautical Association (UTAA)","Tsinghua University","Tokyo Metropolitan College of Industrial Technology","KLEO Connect","Chosun University","KT Corporation/Korean Agency for Defense Development (ADD)","KT Corporation","National Institute of Technology at Kochi College","Kleos Space","College of Engineering, King Saud University","Kuwait University","Lacuna Space","Lacuna Space/NanoAvionics","Beijing Commsat Technology Development Co. Ltd.","National Aeronautics and Space Administration (NASA)/US Geological Survey","Laos National Authority for Science and Technology","Indonesian National Aeronautics and Space Agency (Lembaga Penerbangan dan Antariksa Nasional - LAPAN)","Aprize Satellite Argentina","US Space Force","Sapienza","Spire Global Inc.","California Polytechnic State University","Harbin Institute/China Amateur Satellite - CAMSAT","General Atomics Electromagnetic Systems","Xinwei Telecom Technology Company","Beijing ZeroG Technology Co. Ltd.","Nanjing and Hong Kong Universities","Changchun Institute of Optics","Russian Federal Space Agency","Federal Security Service (FSB)","SkyFox Labs","Shanghai Academy of Space Technology","University of Vigo/Alén Space","Wuhan University","Lynk Global Inc.","University of Michigan/Montana University","Defence Research and Development Canada (DRDC)/Canadian Space Agency","CTRS Morocco/Institut für Luft-und Raumfahrttechnik (Berlin)","Max Valier school Bolzano, Italy, Oskar von Miller school, Merano, Italy","Cape Peninsula University of Technology (CPUT)","MEASAT Satellite Systems Sdn. Bhd.","Indian Space Research Organization (ISRO)/Centre National d'Etudes Spatiales (CNES)","Open Cosmos","PT Telkomunikasi","National Reconnaissance Office (NRO)/US Air Force","Military Space Forces (VKS)","Analytical Space","Russian Federal Service For Hydrometeorology and Environmental Monitoring (ROSHYDROMET)","EUMETSAT (European Organization for the Exploitation of Meteorological Satellites)","ESA/EUMETSAT (European Organization for the Exploitation of Meterological Satellites)","Space Logistics LLC","Mexico Telecomm","Masdar Institute of Science and Technology","Vietnam National Space Center","MIT/Lincoln Laboratory-University of Massachusetts Amherst","Kingdom of Morocco","Technical University of Munich","Mozhaisky Space Engineering Academy","Mobile Satellite Ventures","DoD/US Navy","ACMEO","Tyvak Nanosatellite Systems, Inc.","Instituto Nacional de Técnia Aeroespacial (INTA)","Brazilian National Institute of Space Research (INPE/MCTI), Federal University of Santa Maria (UFSM)","Shanghai Weixing Keji","Thai Air Force","National Authority for Remote Sensing and Space Sciences","DoD/US Air Force","Center of Excellence for Space Sciences and Technologies (SPACE-SI)","Origin Space","Deimos","Unknown","Würzburg Center for Telematics","Korea Advanced Institute of Science and Technology (KAIST)","Nihon University","NigComSat","National Space Research and Development Agency (NASRDA)","Egyptian Radio and TV Union","Echostar Corporation (entire payload leased from Telesat Canada Ltd.)","Ningxia Jingui Information Technology Co. Ltd.","Noorul Islam University","PT Pasifik Satelit Nntara","National Oceanographic and Atmospheric Administration (NOAA) (part of international program)","Novosibirsk State University","Norwegian Space Center","Norwegian Space Center/Statsat","UK Government/Surrey Satellite Technologies","National Oceanographic and Atmospheric Administration (NOAA)/NASA","F Space Test Office","NSLComm","National Aeronautics and Space Administration (NASA)-Ames Research Center/Stanford University","O3b Networks Ltd.","Michigan Technological University/Air Force Research Laboratory","Swedish National Space Board","U.S. Space Force","OneWeb Satellites","Spanish Space Agency","Italian Defense Ministry","Optus Communications (Parent: Singapore Telecom)","Optus Communications (Parent: Singapore Telecom)/Australian Ministry of Defense","ORBCOMM Inc.","DARPA/Tethers Unlimited","Portland State Aerospace Society","F /ORSO (Operationally Responsive Space Office)","General Atomics - Electromagnetic Systems Group","Mexico Secretariat of National Defense (SEDENA)","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)/PakSat International","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)","National Reconnaissance Office","National Aeronautics and Space Administration (NASA)/Cornell University","US Naval Academy","Helios Wire/Echostar Global Australia","Hisdesat","Tiger Innovations","Netherlands Organization for Applied Scientific Research (NTO) consortium","Dauria Aerospace","Peruvian Space Agency","Rocket Labs","Brigham Young University","German Aerospace Center (DLR-IKN)/TESAT Spacecom","University of Chile for Aerospace Investigation)","Ministry of Defense/Centre National d'Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Ministry of Defense//Centre National d/Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Airbus Defense and Space","US Coast Guard","University of Colorado, Boulder","National Technical University","St. Petersburg Polytechnic University","Italian Space Agency","Naval Post-Graduate School","Georgia Institute of Technology","Shanghai Academy of Spaceflight Technology","Astrodynamics and Control Laboratory of Yonsei University","Korean Aerospace Research Institute (KARI)","Von Karman Institute (VKI)","Qiansheng Exploration Technology Co. Ltd.","Shandong Institutes of Industrial Technology","Orbital Space","iQPS","University of Science and Technology","SES S.A. -- total capacity leased to subsidiary of EchoStar Corp.","University of New South Wales/Department of Defence","MDA Corporation","C3S","Vanderbilt University/AMSAT","Robertsville Middle School, Oak Ridge, Tennessee","Korea Advanced Institute of Science and Technology.","Space Technologies Research Institute","Regional African Satellite Communications Organization (RASCOM)","Applied Physics Laboratory/NASA","Reaktor Space Lab","Institute of Space and Astronautical Science (ISAS)/Japan Aerospace Exploration Agency (JAXA)","Skobeltsyn Institute of Nuclear Physics","Indian Space Research Organization (ISRO)/exactEarth","Tohoku University","Tohoku University/Hokkaido University","University of Montpellier","Orbital Solutions Monaco","European Space Agency (ESA) (and 250 international scientific investigators)","National Autonomous University of Mexico","AMSAT/Nepal","National Space Activities Commission (CONAE)","Stanford Student Space Initiative","Department of National Defense","EASF/Bundeswehr","Sateliot IoT","Department of Computer Science and the Faculty of Engineering, Ariel University","Riyadh Space Research Institute","Space Research Institute, King Abdulaziz City for Science and Technology","National Reconnaissance Office (NRO)/US Navy","Strategic Space Command/Space Surveillance Network","Instituto Nacional de Pesquisas Espaciais (INPE)","University of North Carolina - Wilmington","SES S.A./EchoStar Satellite Services, LLC","Telebras/Brazilian Air Force","Teen Satellite Project","NASA Langley Research Center","China Satellite Communication Corp. (China Satcom)","Chinese Academy of Launch Vehicle Technology (CALT)","Shanghai Institute of Satellite Engineering","Chinese Academy of Space Techology (CAST)","National Space Program","Italian Defense Ministry/Telespazio","Italian Defense Ministry/French Defense Ministry","ESA/Royal Meteorological Institute, Belgium","SRI International","Sirius XM Holdings","XM Satellite Radio","China Siwei Surveying and Mapping Technology Co. Ltd.","Sky Mexico","NBN Co (operated by Optus)","DirecTV Latin America/Intelsat","Skykraft","Intelsat/Paradigm Secure Communications (wholly owned by EADS Astrium)","Ministry of Defense/Paradigm Secure Communications (wholly owned by EADS Astrium)","LightSquared","Centre National d'Etudes Spatiales (CNES)/European Space Agency (ESA)","US Southern Command","Seoul National University","National Institute of Information and Communications Technology (JPN)","Astrotech SOHLA","Technical University Dresden","University of Würzburg","National Aeronautics and Space Administration (NASA) Earth Science Office/Laboratory for Atmospheric and Space Physics, Univ. of Colorado","COSMIAC (Configurable Space Microsystems Innovations & Applications Center)","South Asian Association for Regional Cooperation","Swarm Technologies","Hughes Space and Communications Co.","Hisdesat/Ministry of Defense","Air Force Research Laboratory/Swedish Defense Materiel Administration","Omnispace","Endurosat","Astro Space Center of Moscow/Russian Academy of Sciences","Ministry of Defense/General Delegation for Ordnance (DGA)","Spot Image","SRM University","Government of Sudan","Chilean Air Force","Beihang University/APSCO","Asia-Pacific Space Cooperation Organization (APSCO)","Singapore Telecommunications Ltd. (SingTel)/Chunghwa Telecom Co. Ltd.","Sony","Scanway/German Orbital Systems","SatRevolution/Steamjet","Sternula","SatRevolution","Atlas 5","Synspective","Missile Defense Agency (MDA)","University of Chile","Sky Perfect JSAT Corporation/DSN Corp.","Beijing Space View Technology","Goddard Space Flight Center/Penn State University","Ecole Polytechnique Fédérale de Lanne (EPFL)","Naval Research Laboratory","Operational Responsive Space (ORS) Office","Kenya Space Agency","Beijing MinoSpace Technology Co., Ltd.","Minospace","German Aerospace Center (DLR)/Astrium","Alibaba","Orbit Fab","Herzliya Science Center","National Aeronautics and Space Administration (NASA)/SES Americom (SES [Société Européenne des Satellites (SES)])","National Aeronautics and Space Administration (NASA)/Ames Research Center","Teikyo University","AgilSpace","Telesat Canada Ltd. (BCE, Inc.)/APT Satellite Holdings Ltd.","Colorado State University","German Aerospace Center (DLR)/Infoterra","TerraStar Corporation","U.S. Space Force/Other Transaction Authority","Thaicom Public Company Ltd.","National Aeronautics and Space Administration (NASA)/University of California, Berkeley (in partnership with Germany, France, Austria, Canada)","Geo-Informatics and Space Technology Development Agency (GISTDA)","Telenor Satellite Broadcasting","Thuraya Satellite Communications","China Manned Space Engineering Office (CMSEO)","Tianjin University","Chinese Academy of Launch Vehicle Technology (CASIC)","Xiyong Microelectrics Park","Guodian Gaoke","GasTianta and GZH-HNJ BDS AGR Co., Ltd.","Sun Yat-sen University","Insight Position Digital Intelligence Technology Service Co., Ltd.","China Telecom","National University of Defense Technology","China Electronic Technology Group","CASIOM","Spacety Aerospace Company/ThrustMe","Spacety Aerospace Company/Changsha Gaoxinqu Tianyi Research Institute","Chinese Academy of Sciences (CAS)/China Academy of Space Technology","Institute of Software Chinese Academy of Sciences","La Sapienza University of Rome","National Aeronautics and Space Administration (NASA)/Applied Physics Laboratory, Johns Hopkins","Bolivia Aerospace Bureau","Tomorrow.io","University of Malibor/SkyLabs","MyRadar","Tallinn University of Technology","Technical University of Graz","Turkmenistan Ministry of Communications/SSI-Monaco","Turksat","Elbit Systems","UK/DMC International Imaging (DMCII)","UMBRA","Alba Orbital","Wakayama University (UNIFORM consortium)","Polytechnic University of Madrid","University of Versailles Saint-Quentin-en-Yvelines (UVSQ)/National Centre for Scientific Research (CNRS)","NASA/Carnegie Mellon University","National Aeronautics and Space Administration (NASA)/Johns Hopkins University Applied Physics Laboratory","VISIONA Tecnologia Espacial S.A.","Care Weather Technologies","Nanyang Technological University","Centre National d'Etudes Spatiales (CNES)/Israel Space Agency","ViaSat, Inc.","Momentus, Inc.","Vietnamese Posts and Telecommunications Group (VNPT)","C3S LLC","Space Technology Institute-Vietnam Academy of Science and Technology (STI-VAST)","Bolivarian Agency for Space Activities","Shanghai Micro Satellite Engineering Center","CENTI (China-Europe Networks of technology and Innovation)","Military Satellite Communications - US Air Force","WildBlue Communications","La Sapienza University of Rome/Machakos University/University of Nairobi, Kenya","Weathernews, Inc.","Maxar Technologies Inc.","Centre for Research in Satellite Technology (CREST)","MinoSpace Technology","Chengdu Guoxing Aerospace Technology Co. Ltd.","Xingyun Satellite Co.","DFH Satellite Co. Ltd.","European Space Operations Centre (ESOC)","Ministry of Defense/XTAR","DFH Satellite Co. Ltd./AMSAT-China","Chinese Amateur Satellite (CAMSAT)","Loft Orbital/Fugro","Loft Orbital","Gazprom Space Systems","Bauman Moscow State Technical University","GalaxySpace","Galaxy Space","Hubei University of Technology (HUT)","Mitsubishi Heavy Industries","French South African Institute of Technology/Cape Peninsula University of Technology","QOSMOSYS","China Earthquake Administration/Italian Institute for Nuclear Physics","Zhejiang University","Zhuhai Orbita Control Engineering Co. Ltd.","Zhuhai Orbita Aerospace Science and Technology Co."],"operatorCountry":["","USA","United Kingdom","Finland","Denmark","Multinational","Israel","Austria","ESA","Lithuania","Norway","Spain","United Arab Emirates","Kazakhstan","Algeria","Japan","Brazil","Russia","South Korea","Angola","Canada","Argentina","USA/Argentina","China","Belgium","Turkey","Luxembourg","Switzerland","India","France/Italy","Singapore","Azerbaijan","Bangladesh","Czech Republic","Germany","Belarus","Netherlands","Indonesia","France","Australia","Bulgaria","China/Brazil","China/France","Tunisia","Taiwan","Italy","Mexico","Ecuador","Egypt","USA/Canada/Japan","USA/Japan/Brazil","Ethiopia","Colombia","USA/Japan","USA/Germany","France/Italy/Belgium/Spain/Greece","Greece","Greece/United Kingdom","United Kingdom/ESA","Malaysia","USA/India/Singapore/Taiwan","ESA/Russia","Thailand","USA/France","Japan/Singapore","Jordan","Iran","Saudi Arabia","Kuwait","United Kingdom/Netherlands","Laos","Morocco/Germany","South Africa","India/France","Vietnam","Morocco","USA/Canada","Slovenia","Sinapore","Nigeria","Sweden","Pakistan","Peru","New Zealand","Chile","Ukraine","Hungary","India/Canada","Monaco","Nepal","France/Belgium/Sweden","Sudan","Singapore/Taiwan","USA/Poland","Poland/UK","Poland","USA/United Kingdom/Italy","Kenya","Iraq","Bolivia","Estonia","Turkmenistan/Monaco","France/Israel","Venezuela","China/Italy"],"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"],"detailedPurpose":["","Infrared Imaging","Automatic Identification System (AIS)","Electronic Intelligence","Earth Science","Optical Imaging","Meteorology","Radar Imaging","Amateur Radio","Radar Surveillance","Signals Intelligence","Maritime Surveillance","Radar Imaging (SAR)","Multispectral Imaging","Optical Imaging/Meterology","Optical Imaging/Meteorology","Internet of Things (IoT)/Amateur Radio","Radar Imaging/Earth Science","AI Remote Sensing","Earth Science/Meterology","Early Warning","Hyperspectral Imaging","Optical Stereo Imaging","Subsurface Imaging","Imaging","Maritime Observation","Remote Sensing","Laser Imaging","Thermal Imaging","ADS-B Receiver","Internet of Things (IoT)","Optical Imaging (video)","Video Imaging","Optical Imaging/Automatic Identification System (AIS)","Radio Frequency Monitoring","Meteorology, Automatic Identification System (AIS)","X-ray Astronomy","Technology Development","Synthetic Aperture Radar (SAR)","Optical/Infrared Imaging","Optical/Video Imaging","Radio Spectrum Monitoring","Surveillance","Optical/Hyperspectral Imaging","Situational Awareness","Radar Imaging/Electronic Intelligence","Microwave Radiometer","Meteorology/Earth Science","Data Relay","Optical, Near-Infrared","Synthetic Aperture Imaging","Meteorology/Radar","Navigation","Optical Imaging/Infrared Imaging"],"users":["","Commercial","Civil","Government","Military","Military/Commercial","Government/Military","Military/Government","Government/Civil","Military/Civil","Commercial/Civil","Civil/Commercial","Government/Commercial","Commercial/Government","Government/Commercial/Military","Civil/Government","Civil/Military","Commercial/Military"],"orbitClass":["","LEO","GEO","ELLIPTICAL","MEO"]},"records":[[7530,30,1,3,0,2,1],[19548,141,1,3,0,3,2],[20253,213,1,3,0,4,2],[20580,286,8,5,0,3,1],[20776,525,2,3,0,4,2],[21639,27,1,3,0,12,2],[22049,237,5,5,0,3,3],[22314,575,1,3,0,12,2],[22491,503,16,1,47,3,1],[22787,213,1,3,0,4,2],[22988,9,1,3,0,4,2],[23223,376,1,1,3,4,2],[23467,213,1,3,0,4,2],[23553,390,76,3,0,1,2],[23613,141,1,3,0,3,2],[23628,9,1,3,0,4,2],[23855,376,1,1,3,4,2],[23893,8,1,3,0,4,3],[24674,293,2,3,0,1,2],[24753,162,1,1,19,4,1],[24812,221,1,3,0,1,2],[24876,399,1,11,0,5,4],[24916,221,1,3,0,1,2],[24920,215,1,1,7,3,1],[24936,171,1,3,0,1,2],[25019,9,1,3,0,4,2],[25034,376,1,1,3,4,3],[25071,27,26,3,0,1,2],[25112,432,1,3,0,1,1],[25113,432,1,3,0,1,1],[25114,432,1,3,0,1,1],[25115,432,1,3,0,1,1],[25116,432,1,3,0,1,1],[25117,432,1,3,0,1,1],[25118,432,1,3,0,1,1],[25119,432,1,3,0,1,1],[25153,293,2,3,0,1,2],[25159,432,1,3,0,1,1],[25258,213,1,3,0,4,2],[25336,8,1,1,3,4,2],[25338,414,1,1,19,3,1],[25414,432,1,3,0,1,1],[25415,432,1,3,0,1,1],[25416,432,1,3,0,1,1],[25418,432,1,3,0,1,1],[25419,432,1,3,0,1,1],[25420,432,1,3,0,1,1],[25462,27,26,3,0,1,2],[25475,432,1,3,0,1,1],[25477,432,1,3,0,1,1],[25481,432,1,3,0,1,1],[25504,503,16,1,47,3,1],[25544,300,5,5,0,3,1],[25639,61,2,3,0,4,2],[25652,241,1,3,0,1,1],[25682,346,1,1,4,3,1],[25757,160,34,4,0,3,1],[25867,110,1,5,0,3,3],[25924,5,5,3,0,1,2],[25933,399,1,11,0,5,4],[25967,213,1,3,0,4,2],[25980,432,1,3,0,1,1],[25981,432,1,3,0,1,1],[25982,432,1,3,0,1,1],[25984,432,1,3,0,1,1],[25985,432,1,3,0,1,1],[25989,646,8,5,0,3,3],[25991,162,1,1,19,4,1],[25994,187,49,1,4,3,1],[26038,219,1,3,0,1,2],[26052,9,1,3,0,4,2],[26107,48,1,3,0,1,2],[26356,9,1,1,1,4,2],[26360,399,1,11,0,5,4],[26369,13,5,3,0,1,2],[26388,141,1,3,0,3,2],[26390,518,1,3,0,1,3],[26407,399,1,11,0,5,4],[26410,490,8,5,0,3,3],[26411,490,8,5,0,3,3],[26451,219,1,3,0,1,2],[26463,490,8,5,0,3,3],[26464,490,8,5,0,3,3],[26469,78,16,3,0,1,2],[26473,8,1,1,7,4,1],[26483,518,1,3,0,1,3],[26554,27,36,3,0,1,2],[26575,9,1,3,0,4,2],[26580,27,1,3,0,1,2],[26590,221,1,3,0,1,2],[26605,399,1,11,0,5,4],[26608,219,1,3,0,1,2],[26624,35,20,3,0,1,2],[26626,518,1,3,0,1,3],[26638,27,26,3,0,1,2],[26690,399,1,11,0,5,4],[26694,429,45,3,0,4,2],[26702,425,80,1,4,3,1],[26715,9,1,3,0,4,2],[26719,13,5,3,0,1,2],[26766,219,1,3,0,1,2],[26824,221,1,3,0,1,2],[26853,27,26,3,0,1,2],[26880,9,1,1,1,4,2],[26900,221,1,3,0,1,2],[26958,11,8,9,0,3,1],[26959,70,34,12,5,8,1],[26998,606,1,5,0,8,1],[27004,369,71,2,0,3,1],[27056,61,17,3,0,4,1],[27057,61,17,3,0,4,1],[27168,9,1,3,0,4,2],[27378,173,1,3,0,1,2],[27380,221,1,3,0,1,2],[27389,141,1,3,0,3,2],[27399,312,15,3,0,1,2],[27400,27,26,3,0,1,2],[27414,27,36,3,0,1,2],[27424,189,50,1,4,3,1],[27426,54,1,3,0,1,2],[27434,61,6,1,5,4,1],[27438,221,1,3,0,1,2],[27445,222,1,3,0,1,2],[27460,13,5,3,0,1,2],[27464,61,17,3,0,4,1],[27465,61,17,3,0,4,1],[27509,380,5,1,19,8,2],[27513,221,1,3,0,1,2],[27528,273,11,3,0,14,2],[27540,298,61,5,0,3,3],[27566,141,1,3,0,3,2],[27603,27,36,3,0,1,2],[27606,349,21,3,0,1,1],[27607,500,67,3,8,3,1],[27612,349,21,3,0,1,1],[27640,130,1,1,0,4,1],[27651,535,1,5,0,8,1],[27663,399,1,11,0,5,4],[27691,9,1,3,0,4,2],[27704,399,1,11,0,5,4],[27711,9,1,3,0,4,2],[27715,219,1,3,0,1,2],[27811,266,56,3,0,1,2],[27825,588,12,3,0,1,2],[27831,431,39,3,0,5,2],[27843,97,20,5,0,3,1],[27848,140,15,2,0,2,1],[27854,175,1,3,0,1,2],[27858,97,20,5,0,3,1],[27868,61,17,3,0,4,1],[27869,61,17,3,0,4,1],[27875,9,1,3,0,4,2],[27937,8,1,1,3,4,2],[27939,389,17,2,0,8,1],[27954,220,53,3,0,1,2],[28051,95,28,1,5,3,1],[28054,162,1,1,19,4,1],[28058,117,23,3,0,3,1],[28089,652,17,3,0,1,2],[28117,213,1,3,0,4,2],[28158,9,1,1,1,4,2],[28184,5,5,3,0,1,2],[28187,13,5,3,0,1,2],[28190,399,1,11,0,5,4],[28220,88,23,1,0,3,1],[28252,27,1,3,0,1,2],[28358,221,1,3,0,1,2],[28366,36,22,7,0,1,1],[28369,499,67,2,0,1,1],[28370,499,67,2,0,1,1],[28371,499,67,1,5,3,1],[28372,36,21,7,0,1,1],[28376,188,1,1,4,3,1],[28378,35,20,3,0,1,2],[28384,376,1,3,48,4,3],[28413,88,23,5,0,3,1],[28414,88,23,5,0,3,1],[28419,61,17,3,0,4,1],[28420,61,17,3,0,4,1],[28446,27,1,3,0,1,2],[28470,88,23,1,5,4,1],[28474,399,1,11,0,5,4],[28485,564,96,5,0,8,1],[28492,265,55,1,5,4,1],[28493,394,11,3,0,3,1],[28526,27,36,3,0,1,2],[28537,501,1,1,3,4,1],[28541,501,1,1,3,4,1],[28542,647,11,3,0,7,2],[28626,519,1,3,0,1,2],[28628,293,2,3,0,1,2],[28646,8,1,1,7,4,1],[28649,95,28,1,5,3,1],[28654,414,1,1,6,3,1],[28659,54,1,3,0,1,2],[28702,221,1,3,0,1,2],[28707,202,17,3,0,1,2],[28737,88,23,5,0,3,1],[28786,584,62,3,0,1,2],[28790,219,1,3,0,1,2],[28810,483,15,2,0,3,1],[28868,35,20,3,0,1,2],[28874,399,1,11,0,5,4],[28884,219,1,3,0,1,2],[28885,190,38,3,0,4,2],[28888,8,1,1,5,4,1],[28890,67,23,1,5,1,1],[28899,293,2,3,0,1,2],[28903,54,1,3,0,1,2],[28908,61,17,3,0,4,1],[28912,380,5,1,19,8,2],[28924,13,5,3,0,1,2],[28935,173,1,3,0,1,2],[28945,13,5,3,0,1,2],[28946,540,11,3,0,4,2],[29045,312,15,3,0,1,2],[29047,133,44,1,6,8,1],[29048,133,44,1,6,8,1],[29050,133,44,1,6,8,1],[29051,133,44,1,6,8,1],[29052,133,44,1,6,8,1],[29055,27,26,3,0,1,2],[29079,191,6,1,5,5,1],[29107,125,1,1,7,8,1],[29108,92,38,4,0,3,1],[29155,9,1,1,19,4,2],[29162,197,5,3,0,1,2],[29236,219,1,3,0,1,2],[29249,8,1,1,3,4,3],[29252,234,1,2,0,1,1],[29268,96,18,12,5,12,1],[29270,13,5,3,0,1,2],[29272,312,15,3,0,1,2],[29273,190,38,3,0,4,2],[29349,337,18,3,0,5,2],[29479,271,5,5,0,3,1],[29486,399,1,11,0,5,4],[29494,54,1,3,0,1,2],[29495,430,39,3,0,1,2],[29505,88,23,5,0,3,1],[29506,88,23,5,0,3,1],[29520,519,1,3,0,1,2],[29522,162,1,1,19,4,1],[29526,38,5,3,0,3,2],[29601,399,1,11,0,5,4],[29643,637,1,3,0,1,2],[29644,27,1,3,0,1,2],[29658,128,34,1,7,4,1],[29670,61,17,11,0,5,4],[29671,61,17,11,0,5,4],[29709,348,37,12,5,3,1],[29710,95,28,1,5,3,1],[30580,585,5,5,0,8,3],[30776,206,1,2,0,9,1],[30794,526,2,3,0,4,2],[30797,585,5,5,0,8,3],[30798,585,5,5,0,8,3],[31102,35,20,3,0,1,2],[31118,499,67,1,5,3,1],[31119,499,67,3,0,1,1],[31121,499,67,3,0,1,1],[31124,499,67,3,0,1,1],[31125,499,67,3,0,1,1],[31127,499,67,3,0,1,1],[31304,15,1,1,4,3,1],[31306,27,26,3,0,1,2],[31307,221,1,3,0,1,2],[31571,241,1,3,0,1,1],[31573,241,1,3,0,1,1],[31574,241,1,3,0,1,1],[31576,241,1,3,0,1,1],[31598,134,45,1,7,9,1],[31601,61,6,1,0,4,1],[31698,581,34,1,7,12,1],[31701,501,1,1,3,4,1],[31708,501,1,1,3,4,1],[31789,234,1,2,0,1,1],[31792,61,17,1,3,4,1],[31797,128,34,1,7,4,1],[31800,509,23,3,0,12,2],[31862,54,1,3,0,1,2],[32018,539,1,3,0,1,2],[32019,85,15,3,0,1,2],[32060,640,1,1,5,1,1],[32252,430,39,3,0,1,2],[32253,219,1,3,0,1,2],[32258,636,1,3,0,4,2],[32260,399,1,11,0,5,4],[32263,241,1,3,0,1,1],[32264,241,1,3,0,1,1],[32265,241,1,3,0,1,1],[32275,61,17,11,0,5,4],[32276,61,17,11,0,5,4],[32283,128,34,1,7,4,1],[32293,78,16,3,0,1,2],[32294,526,2,3,0,4,2],[32299,27,80,3,0,1,2],[32376,134,45,1,7,7,1],[32378,376,1,3,48,4,3],[32382,474,20,1,7,1,1],[32384,399,1,11,0,5,4],[32388,280,53,3,0,1,2],[32393,61,17,11,0,5,4],[32395,61,17,11,0,5,4],[32404,588,12,3,0,1,2],[32476,190,6,1,7,4,1],[32478,202,17,3,0,1,2],[32487,587,10,3,0,1,2],[32706,8,1,1,3,4,3],[32711,399,1,11,0,5,4],[32729,54,1,3,0,1,2],[32750,128,34,1,7,4,1],[32763,173,1,3,0,1,2],[32767,630,74,3,0,3,2],[32768,78,16,3,0,1,2],[32779,322,23,3,37,3,2],[32783,95,28,1,5,4,1],[32789,151,36,2,0,2,1],[32790,81,20,2,0,8,1],[32791,406,15,2,0,2,1],[32794,29,6,3,0,5,2],[32951,221,1,3,0,1,2],[32954,61,17,3,0,4,1],[32955,61,17,3,0,4,1],[32956,61,17,3,0,4,1],[33051,509,23,3,0,3,2],[33053,211,1,5,0,3,1],[33055,526,2,3,0,4,2],[33056,614,25,3,0,1,2],[33153,221,1,3,0,1,2],[33154,38,5,3,0,3,2],[33207,173,1,3,0,1,2],[33244,128,34,1,7,4,1],[33274,312,15,3,0,1,2],[33275,27,1,3,0,1,2],[33278,293,2,3,0,1,2],[33320,274,23,1,5,3,1],[33321,274,23,1,5,3,1],[33331,236,1,1,5,12,1],[33373,35,20,3,0,1,2],[33376,221,1,3,0,1,2],[33396,586,62,1,5,3,1],[33401,301,1,5,0,3,3],[33408,88,23,5,0,3,1],[33409,88,23,5,0,3,1],[33412,134,45,1,7,7,1],[33433,88,23,1,0,3,1],[33434,117,23,1,14,3,1],[33436,27,26,3,0,1,2],[33446,65,23,1,5,3,1],[33453,120,20,3,0,1,2],[33459,13,5,3,0,1,2],[33460,13,5,3,0,1,2],[33490,8,1,1,3,4,2],[33492,146,15,1,4,3,1],[33496,532,15,2,0,2,1],[33499,334,15,2,0,2,1],[33591,414,1,1,6,3,1],[33595,202,17,3,0,1,2],[33749,27,36,3,0,1,2],[33750,13,5,3,0,1,2],[33751,545,38,2,1,4,3],[33752,545,38,2,1,4,3],[34111,35,20,3,0,1,2],[34710,13,5,3,0,1,2],[34713,636,1,3,0,4,2],[34810,514,45,3,0,5,2],[34839,65,23,1,5,3,1],[34903,560,1,2,0,4,1],[34941,27,26,3,0,1,2],[35362,372,59,3,0,1,2],[35491,244,1,1,19,3,2],[35493,518,1,3,0,1,2],[35496,582,1,3,0,1,2],[35498,61,17,3,0,4,1],[35500,61,17,3,0,4,1],[35681,150,11,1,5,3,1],[35683,616,2,1,5,3,1],[35684,36,22,8,2,1,1],[35685,394,11,7,0,3,1],[35686,36,22,8,2,1,1],[35696,47,23,3,0,1,2],[35752,399,1,11,0,5,4],[35755,312,15,3,0,1,2],[35756,430,39,3,0,1,2],[35815,440,1,1,3,4,2],[35873,410,20,3,0,1,2],[35932,565,27,2,0,2,1],[35935,309,25,2,0,2,1],[35942,25,11,3,5,1,2],[35943,128,34,3,0,4,2],[35946,640,1,1,5,1,1],[35951,162,1,1,19,4,1],[36032,27,36,3,0,1,2],[36033,587,10,3,0,1,2],[36036,528,8,1,4,3,1],[36037,11,8,9,0,3,1],[36088,88,23,5,0,3,1],[36095,61,17,1,3,4,1],[36097,221,1,3,0,1,2],[36101,13,5,3,0,1,2],[36106,299,1,3,0,1,2],[36108,636,1,3,0,4,2],[36110,65,23,1,5,3,1],[36111,61,17,11,0,5,4],[36112,61,17,11,0,5,4],[36121,65,23,1,5,3,1],[36122,648,23,3,0,2,1],[36124,265,55,1,5,4,1],[36131,54,1,3,0,1,2],[36358,61,17,3,0,4,2],[36395,141,1,5,0,3,2],[36397,221,1,3,0,1,2],[36400,61,17,11,0,5,4],[36401,61,17,11,0,5,4],[36402,61,17,11,0,5,4],[36411,244,1,1,19,3,2],[36413,65,23,1,3,3,1],[36414,65,23,1,3,3,1],[36415,65,23,1,3,3,1],[36499,173,1,3,0,1,2],[36508,11,8,1,7,3,1],[36516,27,26,3,0,1,2],[36581,27,26,3,0,1,2],[36582,128,34,3,0,4,2],[36585,399,1,11,0,5,4],[36592,38,5,3,0,3,2],[36596,88,23,2,0,3,1],[36605,571,34,1,7,3,1],[36608,61,6,1,5,4,1],[36744,96,18,13,15,3,2],[36745,38,5,3,0,3,2],[36792,173,1,3,0,1,2],[36795,95,28,1,5,3,1],[36797,16,10,3,2,3,1],[36798,20,14,1,5,3,1],[36828,65,23,11,0,7,2],[36830,409,48,3,0,3,2],[36831,480,5,3,0,1,2],[36834,65,23,1,7,4,1],[36985,322,23,1,5,3,1],[37139,61,17,11,0,5,4],[37150,509,23,3,0,1,2],[37152,61,17,3,0,4,1],[37154,61,17,3,0,4,1],[37158,146,15,23,0,3,2],[37162,8,1,1,7,4,1],[37165,65,23,1,0,4,1],[37166,661,23,5,0,2,1],[37167,661,23,5,0,2,1],[37168,502,1,21,0,4,1],[37179,88,23,1,0,3,1],[37180,88,23,1,0,3,1],[37185,518,1,3,0,1,2],[37188,241,1,3,0,1,1],[37189,241,1,3,0,1,1],[37190,241,1,3,0,1,1],[37191,241,1,3,0,1,1],[37192,241,1,3,0,1,1],[37193,241,1,3,0,1,1],[37207,85,15,3,0,1,2],[37210,65,23,11,0,7,2],[37212,377,17,3,0,4,3],[37214,210,23,1,6,3,1],[37216,134,45,1,7,7,1],[37218,527,1,3,0,1,2],[37224,422,1,5,0,8,1],[37227,208,1,2,0,9,1],[37232,8,1,1,3,4,2],[37234,229,23,3,0,4,2],[37237,289,2,3,0,1,2],[37238,221,1,3,0,1,2],[37256,65,23,11,0,7,2],[37258,13,5,3,0,1,2],[37264,273,11,3,0,1,2],[37265,338,18,3,0,1,2],[37344,180,17,1,6,3,2],[37348,8,1,1,5,4,1],[37364,8,1,2,0,4,1],[37372,61,17,11,0,5,4],[37377,376,1,3,48,4,2],[37384,65,23,11,0,7,2],[37386,501,1,1,3,4,1],[37387,485,87,1,5,3,1],[37388,95,28,5,0,3,1],[37389,641,30,1,5,3,1],[37391,501,1,1,3,4,1],[37392,221,1,3,0,1,2],[37393,18,12,3,0,5,2],[37398,377,17,3,0,4,3],[37481,9,1,1,1,4,2],[37602,35,20,3,0,1,2],[37605,95,28,20,0,3,2],[37606,552,92,3,0,1,2],[37677,509,23,3,0,3,2],[37730,88,23,2,0,3,1],[37737,322,23,3,37,3,2],[37739,241,1,3,0,1,1],[37740,241,1,3,0,1,1],[37741,241,1,3,0,1,1],[37742,241,1,3,0,1,1],[37743,241,1,3,0,1,1],[37744,241,1,3,0,1,1],[37746,95,28,3,0,3,2],[37748,27,26,3,0,1,2],[37749,326,13,3,0,1,2],[37753,399,1,11,0,5,4],[37755,544,5,5,0,3,3],[37763,65,23,11,0,7,2],[37765,88,23,2,0,3,1],[37775,27,26,3,0,1,2],[37776,86,15,3,0,1,2],[37779,438,81,3,0,12,2],[37781,226,23,1,6,3,1],[37789,408,79,1,5,3,1],[37790,408,79,12,5,3,1],[37791,479,25,1,5,3,1],[37792,36,20,3,2,1,1],[37793,199,20,3,2,1,1],[37804,229,23,3,0,4,2],[37809,27,26,3,0,1,2],[37810,38,5,3,0,3,2],[37813,291,15,1,5,3,1],[37816,198,5,3,0,1,2],[37818,566,1,2,0,4,3],[37826,472,1,3,0,1,2],[37834,221,1,3,0,1,2],[37836,13,5,3,0,1,2],[37838,373,73,1,4,3,1],[37841,547,28,2,0,2,1],[37843,628,1,3,0,1,2],[37846,11,8,11,0,1,4],[37847,11,8,11,0,1,4],[37849,419,1,1,6,3,1],[37855,367,1,2,0,2,1],[37867,61,17,11,0,5,4],[37868,61,17,11,0,5,4],[37869,61,17,11,0,5,4],[37875,65,23,1,0,4,1],[37930,117,23,1,14,3,1],[37931,88,23,1,0,3,1],[37933,47,23,3,0,1,2],[37941,65,23,1,3,4,1],[37948,65,23,11,0,7,2],[37951,360,17,3,0,3,2],[37954,291,15,1,7,3,1],[38007,182,38,1,3,4,1],[38008,182,38,1,3,4,1],[38009,182,38,1,3,4,1],[38010,182,38,1,3,4,1],[38011,549,84,1,5,6,1],[38012,453,29,1,5,3,1],[38014,407,79,3,0,1,2],[38038,275,23,1,5,3,1],[38040,241,1,3,0,1,1],[38041,241,1,3,0,1,1],[38042,241,1,3,0,1,1],[38043,241,1,3,0,1,1],[38044,241,1,3,0,1,1],[38045,241,1,3,0,1,1],[38046,275,23,1,5,3,1],[38049,210,23,1,4,3,2],[38070,636,1,3,0,4,2],[38087,27,26,3,0,1,2],[38091,65,23,11,0,7,2],[38093,391,1,3,0,4,2],[38098,221,1,3,0,1,2],[38107,37,23,3,0,1,2],[38109,8,1,1,7,4,1],[38245,18,12,3,0,5,2],[38248,61,28,1,7,4,1],[38250,65,23,11,0,7,4],[38251,65,23,11,0,7,4],[38254,9,1,3,0,4,2],[38256,322,23,1,5,3,1],[38257,65,23,1,7,4,1],[38331,312,15,3,0,1,2],[38332,630,74,3,0,3,2],[38337,146,53,1,4,3,1],[38338,96,18,1,5,12,1],[38339,146,15,2,0,3,1],[38340,282,15,2,0,2,1],[38342,35,20,3,0,1,2],[38352,229,23,3,0,7,2],[38354,65,23,1,5,4,1],[38356,221,1,3,0,1,2],[38358,141,1,5,0,3,1],[38466,376,1,3,48,4,2],[38528,8,1,1,3,4,2],[38551,173,1,3,0,1,2],[38552,380,5,1,19,8,2],[38652,27,26,3,0,1,2],[38707,323,17,1,5,3,1],[38708,74,35,1,5,3,1],[38710,72,34,2,0,1,1],[38730,322,23,3,0,3,2],[38733,61,17,3,0,4,1],[38740,221,1,3,0,1,2],[38741,289,2,3,0,1,2],[38749,221,1,3,0,1,2],[38752,623,1,1,4,3,3],[38753,623,1,1,4,3,3],[38755,546,90,1,5,1,1],[38758,501,1,1,3,4,1],[38759,262,1,2,0,4,1],[38760,10,1,2,0,3,1],[38764,121,1,5,0,2,1],[38766,262,1,2,0,4,1],[38771,381,5,1,19,8,1],[38773,501,1,1,3,4,1],[38775,65,23,11,0,7,4],[38778,27,26,3,0,1,2],[38779,95,28,3,0,3,2],[38782,633,103,1,5,3,1],[38833,399,1,11,0,5,4],[38857,11,8,11,0,1,4],[38858,11,8,11,0,1,4],[38860,463,23,2,0,3,1],[38861,463,23,2,0,3,1],[38867,221,1,3,0,1,2],[38908,200,1,2,0,4,1],[38953,65,23,11,0,7,2],[38977,360,17,3,0,3,2],[38978,652,17,3,0,1,2],[38991,78,16,3,0,1,2],[38992,13,5,3,0,1,2],[38995,377,17,3,0,4,3],[38998,88,23,2,0,3,1],[38999,645,23,2,0,3,1],[39008,173,1,3,0,1,2],[39011,65,23,1,3,4,1],[39012,65,23,1,3,4,1],[39013,65,23,1,3,4,1],[39017,509,23,3,0,3,2],[39019,454,38,1,5,1,1],[39020,13,5,3,0,1,2],[39022,652,17,3,0,1,2],[39030,245,25,1,5,4,1],[39034,526,2,3,0,4,2],[39035,383,46,3,0,6,2],[39057,61,17,3,0,4,1],[39058,61,17,3,0,4,1],[39059,61,17,3,0,4,1],[39061,291,15,1,7,3,1],[39062,291,15,1,5,3,1],[39068,96,18,2,0,3,1],[39070,301,1,3,0,3,2],[39072,241,1,3,0,1,1],[39073,241,1,3,0,1,1],[39074,241,1,3,0,1,1],[39075,241,1,3,0,1,1],[39076,241,1,3,0,1,1],[39077,241,1,3,0,1,1],[39078,25,11,3,0,1,2],[39079,58,31,3,0,3,2],[39084,346,1,1,4,3,1],[39086,373,73,1,4,3,1],[39088,495,20,21,0,4,1],[39089,97,20,21,0,3,1],[39091,612,7,2,0,2,1],[39120,9,1,1,1,4,2],[39122,197,5,3,0,1,2],[39127,35,20,3,0,1,2],[39150,224,23,1,5,3,1],[39155,61,17,11,0,5,4],[39157,509,23,3,0,1,2],[39159,11,8,1,4,3,1],[39160,632,74,1,5,3,1],[39163,13,5,3,0,1,2],[39166,399,1,11,0,5,4],[39168,636,1,3,0,4,2],[39172,27,26,3,0,1,2],[39177,61,17,1,5,4,1],[39186,360,17,1,5,12,1],[39188,423,2,3,0,1,4],[39189,423,2,3,0,1,4],[39190,423,2,3,0,1,4],[39191,423,2,3,0,1,4],[39197,141,1,5,0,3,1],[39199,95,28,23,0,3,2],[39202,88,23,2,0,3,1],[39206,391,1,3,0,4,2],[39208,88,23,2,0,4,1],[39209,117,23,2,0,3,1],[39210,88,23,2,0,3,1],[39215,294,58,7,0,12,2],[39216,95,28,1,6,3,2],[39222,636,1,3,0,4,2],[39227,96,18,1,7,12,1],[39232,8,1,1,5,4,1],[39233,192,5,3,0,1,2],[39234,253,28,3,0,4,2],[39237,29,6,3,0,5,2],[39239,65,23,1,3,4,1],[39240,65,23,1,3,4,1],[39241,65,23,1,3,4,1],[39249,246,17,3,0,1,1],[39250,246,17,3,0,1,1],[39251,246,17,3,0,1,1],[39253,146,15,5,0,3,3],[39256,9,1,3,0,4,2],[39260,210,23,1,6,3,1],[39265,97,20,4,0,3,1],[39285,27,26,3,0,1,2],[39358,88,23,2,0,3,1],[39360,518,1,3,0,1,2],[39375,61,17,3,0,4,2],[39410,65,23,1,5,4,1],[39416,36,20,3,2,1,1],[39417,658,72,29,0,2,1],[39418,164,1,1,5,1,1],[39419,168,12,1,5,3,1],[39420,428,11,2,0,3,1],[39423,639,15,1,5,1,1],[39425,36,22,8,2,1,1],[39430,230,4,2,0,1,1],[39440,138,21,2,0,2,1],[39444,218,36,3,0,2,1],[39451,11,8,1,4,3,1],[39452,11,8,1,4,3,1],[39453,11,8,1,4,3,1],[39455,88,23,1,0,3,1],[39460,27,26,3,0,1,2],[39462,8,1,1,7,4,1],[39467,23,1,2,0,4,1],[39468,529,1,3,0,4,1],[39472,262,1,2,0,4,1],[39473,567,1,2,0,4,1],[39474,262,1,2,0,4,1],[39476,293,2,3,0,1,2],[39481,607,99,3,0,3,2],[39483,61,17,3,0,4,1],[39484,61,17,3,0,4,1],[39485,61,17,3,0,4,1],[39487,202,17,3,0,1,2],[39498,95,28,3,0,3,2],[39500,584,62,3,0,1,2],[39504,141,1,3,0,3,2],[39508,5,5,3,0,1,2],[39509,56,29,3,0,6,2],[39522,614,25,3,0,1,2],[39533,399,1,11,0,5,4],[39574,250,53,1,4,3,1],[39612,202,17,3,0,1,2],[39613,202,17,3,0,1,2],[39616,25,11,3,0,1,2],[39617,27,26,3,0,1,2],[39624,88,23,2,0,3,1],[39634,380,8,1,4,3,1],[39635,95,28,23,0,3,2],[39650,61,6,1,7,4,1],[39652,376,1,1,3,4,2],[39727,360,17,3,0,3,2],[39728,326,13,3,0,1,2],[39731,325,13,1,5,3,1],[39741,399,1,11,0,5,4],[39751,376,1,3,48,4,2],[39761,61,17,3,0,4,1],[39762,61,17,3,0,4,1],[39763,61,17,3,0,4,1],[39766,146,15,1,7,3,1],[39767,619,15,1,5,2,1],[39768,531,15,2,0,1,1],[39769,487,15,1,5,2,1],[39770,406,15,2,0,2,1],[39773,13,5,3,0,1,2],[40001,61,17,11,0,5,4],[40010,325,13,1,5,3,1],[40011,276,15,2,0,3,1],[40013,150,11,1,5,3,1],[40014,89,21,2,0,1,1],[40015,276,15,2,0,3,1],[40018,36,1,8,2,1,1],[40019,36,22,8,2,1,1],[40020,81,20,5,0,2,1],[40037,447,17,3,2,1,1],[40039,447,17,3,2,1,1],[40042,458,85,2,0,2,1],[40043,605,98,1,4,2,1],[40044,352,1,2,0,1,1],[40045,12,1,2,0,1,1],[40046,12,1,2,0,1,1],[40053,546,90,1,5,1,1],[40055,81,20,2,0,8,1],[40056,81,20,2,0,8,1],[40059,141,1,1,4,3,1],[40061,246,17,3,0,1,1],[40062,246,17,3,0,1,1],[40063,246,17,3,0,1,1],[40069,379,17,1,6,3,1],[40070,484,17,1,4,3,1],[40072,164,1,1,5,1,1],[40075,16,10,3,2,3,1],[40079,423,2,3,0,1,4],[40080,423,2,3,0,1,4],[40081,423,2,3,0,1,4],[40082,423,2,3,0,1,4],[40086,432,1,3,0,1,1],[40087,432,1,3,0,1,1],[40091,432,1,3,0,1,1],[40100,255,1,21,24,4,2],[40105,399,1,11,0,5,4],[40107,47,23,3,0,1,2],[40109,65,23,1,5,4,1],[40110,65,23,1,5,4,1],[40111,65,23,1,5,4,1],[40115,640,1,1,53,1,1],[40118,224,23,1,5,3,1],[40119,82,5,5,0,3,1],[40128,11,8,11,0,1,3],[40129,11,8,11,0,1,3],[40136,356,23,2,0,1,1],[40137,117,23,1,14,3,1],[40141,47,23,3,0,1,2],[40143,65,23,1,7,4,1],[40144,598,23,13,2,3,1],[40146,430,39,3,0,1,2],[40147,372,59,3,0,1,2],[40208,124,1,3,0,4,2],[40258,61,17,3,0,4,2],[40261,88,23,2,0,3,1],[40267,270,15,1,6,3,2],[40269,95,28,23,0,3,2],[40271,221,1,3,0,1,2],[40272,44,21,3,0,1,2],[40275,65,23,1,5,4,1],[40277,202,17,3,0,1,2],[40286,88,23,2,0,3,1],[40294,399,1,11,0,5,4],[40296,377,17,3,0,4,3],[40298,49,15,1,5,3,1],[40301,282,15,1,4,2,1],[40305,65,23,1,7,4,1],[40310,65,23,1,5,4,1],[40315,61,17,11,0,5,4],[40332,95,28,3,0,3,2],[40333,54,1,3,0,1,2],[40336,98,41,1,5,3,1],[40338,65,23,1,3,4,1],[40339,65,23,1,3,4,1],[40340,65,23,1,3,4,1],[40344,8,1,1,3,4,3],[40345,652,17,3,0,1,2],[40348,423,2,3,0,1,4],[40349,423,2,3,0,1,4],[40350,423,2,3,0,1,4],[40351,423,2,3,0,1,4],[40358,61,17,1,3,4,1],[40360,360,17,27,33,12,1],[40362,65,23,1,3,4,1],[40364,27,26,3,0,1,2],[40367,210,23,1,4,3,2],[40374,391,1,3,0,4,2],[40376,141,1,1,4,3,1],[40380,135,1,1,4,8,1],[40381,291,15,1,7,3,1],[40384,293,2,3,0,1,2],[40420,61,17,1,3,4,1],[40424,5,5,3,0,1,2],[40425,13,5,3,0,1,2],[40482,141,1,5,0,3,3],[40483,141,1,5,0,3,3],[40484,141,1,5,0,3,3],[40485,141,1,5,0,3,3],[40505,202,17,3,0,1,2],[40534,399,1,11,0,5,4],[40536,96,18,1,5,12,1],[40538,291,15,1,5,3,1],[40544,11,8,11,0,1,4],[40545,11,8,11,0,1,4],[40547,95,28,23,0,3,2],[40549,65,23,11,0,7,2],[40552,246,17,3,0,1,1],[40553,246,17,3,0,1,1],[40554,246,17,3,0,1,1],[40555,61,17,2,0,4,1],[40613,587,10,3,0,1,2],[40614,515,29,3,0,4,2],[40617,613,101,3,0,12,2],[40657,232,1,2,0,3,1],[40663,54,1,3,0,1,2],[40664,521,46,3,0,1,2],[40697,380,8,1,4,3,1],[40699,61,17,1,5,4,1],[40701,229,23,1,5,4,1],[40715,99,2,1,5,1,1],[40716,99,2,1,5,1,1],[40717,99,2,1,5,1,1],[40718,99,2,2,0,1,1],[40730,399,1,11,0,5,4],[40732,380,5,1,19,8,2],[40733,78,16,3,0,1,2],[40746,636,1,3,0,4,2],[40748,65,23,11,0,7,4],[40749,65,23,11,0,7,4],[40874,221,1,3,0,1,2],[40875,198,5,3,0,1,2],[40878,65,23,1,3,4,1],[40880,95,28,3,0,12,2],[40882,293,2,3,0,1,2],[40887,391,1,3,0,4,2],[40889,11,8,11,0,1,4],[40890,11,8,11,0,1,4],[40892,88,23,2,3,3,2],[40894,224,23,1,5,3,1],[40895,202,17,3,0,1,2],[40899,598,23,2,0,3,1],[40901,661,23,2,0,2,1],[40902,661,23,2,0,2,1],[40903,649,23,3,0,2,1],[40904,322,23,2,0,3,1],[40905,333,23,2,0,2,1],[40906,649,23,3,0,2,1],[40907,649,23,3,0,2,1],[40908,354,23,2,0,2,1],[40910,649,23,3,0,2,1],[40911,649,23,3,0,2,1],[40912,149,23,2,0,2,1],[40914,333,23,2,0,2,1],[40920,61,17,3,0,4,1],[40921,61,17,3,0,4,1],[40922,61,17,3,0,4,1],[40925,463,23,2,0,3,1],[40930,53,28,5,0,3,1],[40931,348,37,1,4,3,1],[40932,352,1,1,35,1,1],[40933,352,1,1,35,1,1],[40934,352,1,1,35,1,1],[40935,352,1,1,35,1,1],[40936,199,20,3,2,1,1],[40938,65,23,11,0,7,2],[40940,522,39,3,0,1,2],[40941,44,21,3,0,1,2],[40946,383,46,3,0,3,2],[40958,359,23,1,37,1,1],[40959,315,23,1,31,1,1],[40960,315,23,1,31,1,1],[40961,315,23,1,5,1,1],[40964,501,1,1,3,4,1],[40965,12,1,2,0,1,1],[40966,12,1,2,0,1,1],[40968,73,1,12,5,2,1],[40970,262,1,2,0,4,1],[40972,262,1,2,0,4,1],[40973,461,1,5,0,4,1],[40974,517,1,9,0,1,1],[40975,262,1,2,0,4,1],[40976,461,1,5,0,4,1],[40977,517,1,9,0,1,1],[40981,501,1,1,3,4,1],[40982,37,23,3,0,1,2],[40984,614,25,3,0,1,2],[40988,322,23,1,5,3,1],[41019,399,1,11,0,5,4],[41021,509,23,3,0,12,2],[41026,65,23,1,3,4,1],[41028,95,28,3,0,3,2],[41029,38,5,3,0,3,2],[41032,61,17,1,20,4,3],[41034,347,70,3,0,3,2],[41036,35,20,3,0,1,2],[41038,65,23,1,3,4,1],[41103,509,23,3,0,1,2],[41105,180,17,1,6,3,2],[41121,61,17,3,0,4,2],[41166,626,30,2,0,2,1],[41167,328,30,1,5,2,1],[41168,57,30,2,0,1,1],[41169,578,30,1,5,1,1],[41171,626,30,2,0,2,1],[41173,117,23,5,0,3,1],[41174,11,8,11,0,1,4],[41175,11,8,11,0,1,4],[41179,432,1,3,0,1,1],[41182,432,1,3,0,1,1],[41183,432,1,3,0,1,1],[41184,432,1,3,0,1,1],[41185,432,1,3,0,1,1],[41186,432,1,3,0,1,1],[41187,432,1,3,0,1,1],[41188,432,1,3,0,1,1],[41189,432,1,3,0,1,1],[41191,202,17,3,4,1,2],[41194,224,23,1,5,3,2],[41238,69,35,3,0,3,2],[41240,311,63,1,4,3,1],[41241,95,28,23,0,3,2],[41310,13,5,3,0,1,2],[41328,399,1,11,0,5,4],[41330,61,17,11,0,5,4],[41334,8,1,1,7,4,1],[41335,380,8,1,4,3,1],[41380,27,26,3,0,1,2],[41382,13,5,3,0,1,2],[41384,95,28,23,0,3,2],[41386,360,17,1,0,12,1],[41394,61,17,1,3,4,1],[41434,65,23,11,0,7,2],[41456,380,8,1,4,3,1],[41460,4,4,1,2,2,1],[41464,28,17,5,0,15,1],[41469,95,28,23,0,3,2],[41471,312,15,3,0,1,2],[41473,65,23,1,3,4,1],[41549,11,8,11,0,1,4],[41550,11,8,11,0,1,4],[41552,584,62,3,0,1,2],[41554,61,17,11,0,5,4],[41556,275,23,1,5,3,1],[41579,61,17,4,0,4,1],[41581,221,1,3,0,1,2],[41584,8,1,1,3,4,2],[41586,65,23,11,0,7,2],[41588,5,5,3,0,1,2],[41589,197,5,3,0,1,2],[41591,80,37,3,0,1,2],[41592,173,1,3,0,1,2],[41599,95,28,1,5,3,1],[41601,164,1,1,5,1,1],[41602,239,20,4,0,1,1],[41603,348,37,1,2,3,1],[41604,72,34,1,5,3,1],[41605,368,20,1,2,3,1],[41622,391,1,3,0,4,2],[41634,88,23,2,0,3,1],[41724,376,1,3,48,4,2],[41725,597,23,3,0,3,2],[41727,226,23,1,7,3,1],[41729,312,15,3,0,1,2],[41731,471,23,5,0,3,1],[41744,255,1,21,24,4,2],[41745,255,1,21,24,4,2],[41747,221,1,3,0,1,2],[41748,221,1,3,0,1,2],[41752,95,28,1,6,3,2],[41759,61,6,1,5,4,1],[41770,448,82,1,5,3,1],[41771,164,1,1,5,1,1],[41772,164,1,1,5,1,1],[41773,164,1,1,5,1,1],[41774,164,1,1,5,1,1],[41784,95,28,2,5,3,1],[41785,20,14,1,5,3,1],[41786,20,14,1,5,3,1],[41787,75,1,1,5,1,1],[41788,81,20,2,0,8,1],[41789,24,14,2,0,3,1],[41790,95,28,1,47,3,1],[41793,95,28,3,0,3,2],[41794,522,39,3,0,1,2],[41836,270,15,1,6,3,2],[41838,509,23,2,0,3,2],[41841,322,23,2,0,3,1],[41849,481,1,2,0,3,1],[41850,102,1,2,0,4,1],[41852,12,1,2,0,1,1],[41853,12,1,2,0,1,1],[41854,215,1,2,0,4,1],[41855,215,1,2,0,4,1],[41857,463,23,1,0,3,1],[41859,11,8,11,0,1,4],[41860,11,8,11,0,1,4],[41861,11,8,11,0,1,4],[41862,11,8,11,0,1,4],[41866,244,1,1,19,3,2],[41869,322,23,3,2,3,2],[41871,352,1,1,35,1,1],[41872,352,1,1,35,1,1],[41873,352,1,1,35,1,1],[41874,352,1,1,35,1,1],[41875,245,25,1,5,4,1],[41877,95,28,1,5,3,1],[41879,636,1,3,0,4,2],[41882,210,23,1,6,3,2],[41884,143,1,1,17,3,1],[41885,143,1,1,17,3,1],[41886,143,1,1,17,3,1],[41887,143,1,1,17,3,1],[41888,143,1,1,17,3,1],[41889,143,1,1,17,3,1],[41890,143,1,1,17,3,1],[41891,143,1,1,17,3,1],[41893,174,1,3,0,1,2],[41896,146,15,5,0,3,3],[41898,210,23,1,4,3,1],[41899,112,23,1,13,3,1],[41900,112,23,1,21,3,1],[41901,112,23,1,21,3,1],[41903,312,15,3,0,1,2],[41904,78,16,3,0,1,2],[41907,563,23,1,5,1,1],[41908,563,23,1,5,1,1],[41911,463,23,2,0,3,2],[41913,322,23,2,0,3,1],[41914,315,23,1,5,1,1],[41917,303,1,3,0,12,1],[41918,303,1,3,0,12,1],[41919,303,1,3,0,12,1],[41920,303,1,3,0,12,1],[41921,303,1,3,0,12,1],[41922,303,1,3,0,12,1],[41923,303,1,3,0,12,1],[41924,303,1,3,0,12,1],[41925,303,1,3,0,12,1],[41926,303,1,3,0,12,1],[41937,9,1,1,1,4,2],[41940,61,15,3,0,4,2],[41942,273,11,3,0,1,2],[41944,375,37,3,0,1,2],[41945,523,16,3,0,1,2],[41948,95,28,1,5,3,1],[41949,95,28,1,5,3,1],[41954,95,28,1,5,3,1],[41965,164,1,1,5,1,1],[41966,164,1,1,5,1,1],[41968,164,1,1,5,1,1],[41972,164,1,1,5,1,1],[41975,164,1,1,5,1,1],[41977,164,1,1,5,1,1],[41978,164,1,1,5,1,1],[41988,164,1,1,5,1,1],[41990,164,1,1,5,1,1],[41991,352,1,1,35,1,1],[41992,352,1,1,35,1,1],[41993,352,1,1,35,1,1],[41994,352,1,1,35,1,1],[41995,352,1,1,35,1,1],[41996,352,1,1,35,1,1],[41997,352,1,1,35,1,1],[41998,352,1,1,35,1,1],[42000,157,27,2,0,1,1],[42001,164,1,1,5,1,1],[42009,164,1,1,5,1,1],[42014,164,1,1,5,1,1],[42015,446,36,2,0,3,1],[42018,164,1,1,5,1,1],[42019,164,1,1,5,1,1],[42023,164,1,1,5,1,1],[42026,164,1,1,5,1,1],[42027,164,1,1,5,1,1],[42028,164,1,1,5,1,1],[42029,164,1,1,5,1,1],[42031,164,1,1,5,1,1],[42034,164,1,1,5,1,1],[42041,164,1,1,5,1,1],[42048,164,1,1,5,1,1],[42058,501,1,1,3,4,1],[42061,591,23,12,0,3,1],[42063,380,8,1,4,3,1],[42065,501,1,1,3,4,1],[42070,173,1,3,0,1,2],[42072,291,15,1,7,3,1],[42075,636,1,3,0,4,2],[42432,27,26,3,0,1,2],[42662,88,23,2,0,3,2],[42689,8,1,2,0,4,1],[42691,338,18,3,0,1,2],[42692,506,16,3,0,17,2],[42695,537,28,3,0,3,2],[42698,293,2,3,0,1,2],[42709,27,26,3,0,1,2],[42719,61,17,1,20,4,3],[42738,146,15,23,0,3,2],[42740,628,1,3,0,1,2],[42741,13,5,3,0,1,2],[42747,95,28,3,0,3,2],[42749,173,1,3,0,1,2],[42752,352,1,1,35,1,1],[42753,352,1,1,35,1,1],[42754,352,1,1,35,1,1],[42755,352,1,1,35,1,1],[42758,288,23,5,0,3,1],[42759,662,23,1,32,1,1],[42760,89,21,1,5,1,1],[42761,662,23,1,32,1,1],[42766,412,28,2,0,2,1],[42767,95,28,1,5,3,1],[42769,101,15,2,5,1,1],[42771,352,1,1,35,1,1],[42772,352,1,1,35,1,1],[42773,352,1,1,35,1,1],[42774,352,1,1,35,1,1],[42775,3,3,2,0,2,1],[42777,127,34,2,0,8,1],[42778,370,45,24,0,2,1],[42779,352,1,1,35,1,1],[42780,352,1,1,35,1,1],[42781,352,1,1,35,1,1],[42782,352,1,1,35,1,1],[42783,156,2,2,0,1,1],[42785,156,2,2,0,1,1],[42786,156,2,2,0,1,1],[42792,488,38,4,0,2,1],[42793,118,1,1,6,1,1],[42795,393,1,9,0,1,1],[42801,90,40,3,0,1,2],[42803,303,1,3,0,12,1],[42804,303,1,3,0,12,1],[42805,303,1,3,0,12,1],[42806,303,1,3,0,12,1],[42807,303,1,3,0,12,1],[42808,303,1,3,0,12,1],[42809,303,1,3,0,12,1],[42810,303,1,3,0,12,1],[42811,303,1,3,0,12,1],[42812,303,1,3,0,12,1],[42814,267,57,3,0,1,2],[42815,95,28,3,0,3,2],[42818,221,1,3,0,1,2],[42825,324,17,1,5,3,1],[42826,416,10,26,2,3,1],[42828,416,10,1,2,3,1],[42829,64,34,9,0,2,1],[42831,212,34,18,2,2,1],[42835,639,15,1,5,1,1],[42837,352,1,1,35,1,1],[42838,352,1,1,35,1,1],[42839,352,1,1,35,1,1],[42840,352,1,1,35,1,1],[42841,352,1,1,35,1,1],[42842,352,1,1,35,1,1],[42844,393,1,2,0,1,1],[42845,352,1,1,35,1,1],[42846,131,1,1,0,1,1],[42847,131,1,1,0,1,1],[42848,118,1,1,6,1,1],[42849,118,1,1,6,1,1],[42900,429,45,1,5,4,1],[42901,627,102,1,5,3,1],[42907,61,17,3,0,5,2],[42915,141,1,3,0,3,2],[42917,146,15,23,0,3,2],[42920,214,44,1,5,3,1],[42921,435,1,9,0,4,1],[42922,155,1,2,0,4,1],[42934,25,11,3,0,1,2],[42939,61,17,11,0,5,4],[42941,8,1,1,3,4,3],[42942,47,23,3,0,1,2],[42945,65,23,1,3,4,1],[42946,65,23,1,3,4,1],[42947,65,23,1,3,4,1],[42949,376,1,3,48,4,2],[42950,221,1,3,0,1,2],[42951,85,15,3,0,1,2],[42954,633,103,1,5,3,1],[42955,303,1,3,0,12,1],[42956,303,1,3,0,12,1],[42957,303,1,3,0,12,1],[42958,303,1,3,0,12,1],[42959,303,1,3,0,12,1],[42960,303,1,3,0,12,1],[42961,303,1,3,0,12,1],[42962,303,1,3,0,12,1],[42963,303,1,3,0,12,1],[42964,303,1,3,0,12,1],[42965,146,15,23,0,3,2],[42967,505,26,3,0,1,2],[42969,380,8,1,4,3,1],[42982,330,1,12,5,4,1],[42984,338,18,3,0,1,2],[42987,164,1,1,5,1,1],[42988,164,1,1,5,1,1],[42989,164,1,1,5,1,1],[42990,164,1,1,5,1,1],[42991,164,1,1,5,1,1],[42992,164,1,1,5,1,1],[42997,164,1,1,5,1,1],[43001,65,23,11,0,7,4],[43002,65,23,11,0,7,4],[43005,387,75,1,5,3,1],[43010,210,23,1,6,3,1],[43011,264,23,1,2,1,1],[43013,414,1,1,6,3,1],[43014,87,39,2,0,9,1],[43017,476,1,2,8,2,1],[43022,315,23,1,5,1,1],[43023,315,23,1,5,1,1],[43024,315,23,1,5,1,1],[43028,65,23,1,3,4,1],[43029,65,23,1,3,4,1],[43030,65,23,1,3,4,1],[43032,61,17,1,3,4,1],[43034,229,23,1,5,4,1],[43039,20,14,3,0,3,2],[43041,352,1,1,35,1,1],[43045,352,1,1,35,1,1],[43047,352,1,1,35,1,1],[43048,352,1,1,35,1,1],[43050,305,1,2,0,3,1],[43051,352,1,1,35,1,1],[43053,352,1,1,35,1,1],[43054,352,1,1,35,1,1],[43055,11,8,11,0,1,4],[43056,11,8,11,0,1,4],[43057,11,8,11,0,1,4],[43058,11,8,11,0,1,4],[43065,146,15,1,0,3,1],[43070,303,1,3,0,12,1],[43071,303,1,3,0,12,1],[43072,303,1,3,0,12,1],[43073,303,1,3,0,12,1],[43074,303,1,3,0,12,1],[43075,303,1,3,0,12,1],[43076,303,1,3,0,12,1],[43077,303,1,3,0,12,1],[43078,303,1,3,0,12,1],[43079,303,1,3,0,12,1],[43080,229,23,1,5,4,1],[43081,65,23,1,3,4,1],[43082,65,23,1,3,4,1],[43083,65,23,1,3,4,1],[43099,563,23,1,5,1,1],[43100,563,23,1,5,1,1],[43107,65,23,11,0,7,4],[43108,65,23,11,0,7,4],[43111,95,28,1,5,3,1],[43113,35,20,3,35,1,1],[43114,290,3,1,7,1,1],[43115,100,2,2,0,1,1],[43116,95,28,2,0,3,1],[43118,131,1,1,0,1,1],[43123,352,1,1,35,1,1],[43124,352,1,1,35,1,1],[43125,352,1,1,35,1,1],[43126,352,1,1,35,1,1],[43127,51,1,2,0,1,1],[43130,43,1,2,0,1,1],[43131,386,1,1,4,15,1],[43143,118,1,1,6,1,1],[43144,393,1,9,0,1,1],[43145,8,1,1,7,4,1],[43146,229,23,1,5,4,1],[43152,49,15,1,7,3,1],[43155,602,23,2,0,1,1],[43156,287,23,2,0,2,1],[43157,329,2,3,30,1,1],[43158,602,23,2,0,1,1],[43159,315,23,1,5,1,1],[43160,315,23,1,5,1,1],[43162,9,1,1,1,4,2],[43165,352,1,1,35,1,1],[43167,352,1,1,35,1,1],[43169,634,23,2,0,3,1],[43170,65,23,1,3,4,1],[43171,65,23,1,3,4,1],[43172,65,23,1,3,4,1],[43174,18,12,3,0,1,2],[43175,27,26,3,0,1,2],[43178,249,26,3,0,6,2],[43180,324,17,1,5,3,1],[43181,324,17,1,5,3,1],[43182,352,1,1,35,1,1],[43183,352,1,1,35,1,1],[43184,352,1,1,35,1,1],[43185,352,1,1,35,1,1],[43186,64,34,2,0,2,1],[43187,64,34,2,0,2,1],[43188,64,34,2,0,2,1],[43189,64,34,2,0,2,1],[43192,209,23,2,0,1,1],[43194,660,104,4,0,3,1],[43195,89,21,1,21,1,1],[43196,230,4,2,0,3,1],[43197,230,4,1,0,4,1],[43199,507,23,2,0,2,1],[43204,89,21,1,21,1,1],[43207,65,23,11,0,7,4],[43208,65,23,11,0,7,4],[43215,444,11,1,7,5,1],[43223,291,15,1,5,3,1],[43226,244,1,1,19,3,2],[43228,273,11,3,0,1,2],[43231,423,2,3,0,1,4],[43232,423,2,3,0,1,4],[43233,423,2,3,0,1,4],[43234,423,2,3,0,1,4],[43236,229,23,1,5,4,1],[43245,65,23,11,0,7,4],[43246,65,23,11,0,7,4],[43249,303,1,3,0,12,1],[43250,303,1,3,0,12,1],[43251,303,1,3,0,12,1],[43252,303,1,3,0,12,1],[43253,303,1,3,0,12,1],[43254,303,1,3,0,12,1],[43255,303,1,3,0,12,1],[43256,303,1,3,0,12,1],[43257,303,1,3,0,12,1],[43258,303,1,3,0,12,1],[43259,225,23,1,5,3,1],[43260,225,23,1,5,3,1],[43262,225,23,1,5,3,1],[43271,562,15,3,0,17,2],[43272,289,2,3,0,1,2],[43275,65,23,1,3,4,1],[43276,65,23,1,3,4,1],[43277,65,23,1,3,4,1],[43279,634,23,2,0,3,1],[43286,95,28,23,0,3,2],[43339,9,1,3,0,4,2],[43432,61,17,3,0,5,2],[43435,141,1,5,0,3,3],[43437,380,8,1,4,3,1],[43439,663,23,1,21,1,1],[43440,662,23,1,32,1,1],[43441,663,23,1,21,1,1],[43442,663,23,1,21,1,1],[43443,663,23,1,21,1,1],[43446,102,1,2,0,4,2],[43450,37,23,3,0,1,2],[43461,225,23,1,21,3,1],[43463,59,32,3,0,3,2],[43476,251,54,1,4,3,1],[43477,251,54,1,4,3,1],[43478,303,1,3,0,12,1],[43479,303,1,3,0,12,1],[43480,303,1,3,0,12,1],[43481,303,1,3,0,12,1],[43482,303,1,3,0,12,1],[43484,225,23,1,5,3,1],[43485,365,23,1,5,2,1],[43488,27,26,3,0,1,2],[43491,210,23,1,6,3,2],[43495,291,15,1,7,3,1],[43508,61,17,11,0,5,4],[43518,403,23,2,0,3,1],[43519,403,23,2,0,3,1],[43529,439,81,1,5,3,1],[43530,439,81,1,5,3,1],[43539,65,23,11,0,7,2],[43547,580,1,5,0,2,1],[43556,12,1,2,0,1,1],[43557,12,1,2,0,1,1],[43558,352,1,1,35,1,1],[43559,352,1,1,35,1,1],[43560,352,1,1,35,1,1],[43561,352,1,1,35,1,1],[43562,35,20,3,0,1,2],[43564,11,8,11,0,1,4],[43565,11,8,11,0,1,4],[43566,11,8,11,0,1,4],[43567,11,8,11,0,1,4],[43569,303,1,3,0,12,1],[43570,303,1,3,0,12,1],[43571,303,1,3,0,12,1],[43572,303,1,3,0,12,1],[43573,303,1,3,0,12,1],[43574,303,1,3,0,12,1],[43575,303,1,3,0,12,1],[43576,303,1,3,0,12,1],[43577,303,1,3,0,12,1],[43578,303,1,3,0,12,1],[43581,65,23,11,0,7,4],[43582,65,23,11,0,7,4],[43585,224,23,1,5,3,3],[43587,375,37,3,0,1,2],[43600,11,8,1,4,3,1],[43602,65,23,11,0,7,4],[43603,65,23,11,0,7,4],[43609,226,23,1,6,3,1],[43611,579,20,3,0,1,2],[43613,141,1,4,27,3,1],[43616,181,1,5,0,10,1],[43618,99,2,1,5,1,1],[43619,418,2,1,7,12,1],[43622,65,23,11,0,7,4],[43623,65,23,11,0,7,4],[43632,58,31,3,0,3,2],[43633,280,53,3,0,1,2],[43636,104,23,2,0,1,1],[43641,493,21,1,7,3,1],[43642,65,23,1,3,4,1],[43647,65,23,11,0,7,4],[43648,65,23,11,0,7,4],[43651,9,1,3,0,4,2],[43655,226,23,1,4,3,1],[43656,572,23,3,0,1,1],[43657,61,17,1,3,4,1],[43662,108,42,1,6,3,1],[43668,593,23,3,0,1,1],[43669,111,23,2,0,1,1],[43672,247,15,1,4,3,1],[43676,168,12,1,5,3,1],[43677,282,15,5,0,2,1],[43678,159,15,1,5,2,1],[43683,65,23,11,0,7,2],[43687,61,17,11,0,5,4],[43689,381,5,1,19,8,1],[43690,118,1,1,6,1,1],[43694,103,39,2,0,1,1],[43695,352,1,1,35,1,1],[43696,103,39,2,0,1,1],[43697,352,1,1,35,1,1],[43698,95,28,3,0,3,2],[43700,193,38,3,0,5,2],[43706,65,23,11,0,7,4],[43707,65,23,11,0,7,4],[43710,88,23,5,0,3,1],[43712,603,23,2,0,3,1],[43713,314,23,3,30,1,1],[43717,387,75,1,5,3,1],[43719,95,28,1,21,3,1],[43720,283,1,3,0,1,1],[43721,205,52,1,5,4,1],[43722,103,39,2,0,1,1],[43723,164,1,1,5,1,1],[43724,164,1,1,5,1,1],[43725,164,1,1,5,1,1],[43729,329,20,3,0,1,1],[43730,75,1,1,5,1,1],[43731,352,1,1,35,1,1],[43732,352,1,1,35,1,1],[43733,164,1,1,5,1,1],[43737,119,1,1,6,1,1],[43738,295,59,2,0,3,1],[43741,164,1,1,5,1,1],[43742,164,1,1,5,1,1],[43743,482,3,1,21,1,1],[43744,269,36,2,0,1,1],[43745,352,1,1,35,1,1],[43746,352,1,1,35,1,1],[43747,164,1,1,5,1,1],[43751,61,17,3,0,4,1],[43752,61,17,3,0,4,1],[43753,61,17,3,0,4,1],[43759,443,1,3,30,1,1],[43764,456,1,3,0,4,1],[43765,263,1,22,3,1,1],[43767,131,1,1,0,1,1],[43768,17,11,1,2,1,1],[43769,164,1,1,5,1,1],[43770,30,1,3,0,2,1],[43772,462,1,2,0,2,1],[43773,462,1,2,0,2,1],[43774,269,36,2,0,1,1],[43775,203,28,3,8,1,1],[43780,388,34,2,0,8,1],[43783,327,13,1,5,1,1],[43785,433,1,2,0,4,1],[43790,172,45,1,2,1,1],[43792,195,8,16,0,2,1],[43793,136,1,5,0,2,1],[43794,263,1,22,3,1,1],[43795,433,1,2,0,4,1],[43796,79,1,2,0,1,1],[43797,164,1,1,5,1,1],[43799,263,1,22,3,1,1],[43800,290,3,1,12,1,1],[43802,164,1,1,5,1,1],[43803,321,65,3,8,2,1],[43804,3,3,2,0,2,1],[43805,19,13,2,0,2,1],[43807,72,34,2,0,3,1],[43808,456,1,3,0,4,1],[43809,103,39,3,0,1,1],[43811,405,18,5,0,3,1],[43812,75,1,1,5,1,1],[43813,79,1,2,0,1,1],[43815,206,1,2,0,9,1],[43816,538,1,3,0,1,1],[43817,538,1,3,0,1,1],[43818,538,1,3,0,1,1],[43819,200,1,2,0,4,1],[43820,504,1,1,13,2,1],[43822,200,1,1,5,4,1],[43823,235,18,1,6,3,2],[43824,95,28,3,0,3,2],[43831,500,67,1,5,3,1],[43833,500,67,1,5,3,1],[43836,345,23,2,30,1,1],[43837,345,23,2,30,1,1],[43838,345,23,2,30,1,1],[43839,345,23,2,30,1,1],[43840,345,23,2,30,1,1],[43841,345,23,2,30,1,1],[43842,345,23,2,30,1,1],[43849,12,1,2,0,1,1],[43850,508,1,2,0,3,1],[43853,105,1,5,0,3,1],[43855,116,1,2,0,8,1],[43856,307,1,5,0,8,1],[43857,147,1,15,0,2,1],[43858,139,1,2,0,2,1],[43860,200,1,5,0,4,1],[43861,12,1,2,0,1,1],[43864,254,28,3,0,4,2],[43866,137,38,1,13,4,1],[43867,61,17,3,0,5,2],[43871,279,23,2,0,1,1],[43873,399,1,11,0,5,4],[43874,88,23,2,0,3,2],[43876,324,17,1,5,3,1],[43877,324,17,1,5,3,1],[43879,306,33,3,29,1,1],[43880,534,34,29,0,2,1],[43881,144,34,2,0,1,1],[43882,352,1,1,35,1,1],[43883,352,1,1,35,1,1],[43884,352,1,1,35,1,1],[43885,352,1,1,35,1,1],[43886,352,1,1,35,1,1],[43887,352,1,1,35,1,1],[43888,352,1,1,35,1,1],[43889,352,1,1,35,1,1],[43890,252,15,1,13,1,1],[43893,164,1,1,5,1,1],[43899,164,1,1,5,1,1],[43902,164,1,1,5,1,1],[43905,164,1,1,5,1,1],[43906,164,1,1,5,1,1],[43907,658,72,1,33,2,1],[43908,364,11,3,0,10,1],[43909,463,23,1,4,3,1],[43910,463,23,1,4,3,1],[43911,463,23,1,4,3,1],[43912,463,23,1,4,3,1],[43913,463,23,1,4,3,1],[43914,278,23,3,0,3,1],[43915,463,23,1,4,3,1],[43917,88,23,2,0,3,2],[43920,509,23,3,0,12,2],[43922,303,1,3,0,12,1],[43923,303,1,3,0,12,1],[43924,303,1,3,0,12,1],[43925,303,1,3,0,12,1],[43926,303,1,3,0,12,1],[43927,303,1,3,0,12,1],[43928,303,1,3,0,12,1],[43929,303,1,3,0,12,1],[43930,303,1,3,0,12,1],[43931,303,1,3,0,12,1],[43932,146,15,2,0,3,1],[43934,486,15,1,5,2,1],[43935,385,74,1,0,3,1],[43937,406,15,3,8,2,1],[43941,8,1,1,5,4,1],[43942,357,23,1,0,1,1],[43943,315,23,1,13,1,1],[43944,111,23,2,0,1,1],[43946,315,23,1,13,1,1],[44034,266,56,3,0,13,2],[44035,95,28,3,0,3,2],[44047,178,48,1,13,3,1],[44048,413,37,3,0,1,2],[44057,427,2,3,0,1,1],[44058,427,2,3,0,1,1],[44059,427,2,3,0,1,1],[44060,427,2,3,0,1,1],[44061,427,2,3,0,1,1],[44062,427,2,3,0,1,1],[44067,509,23,3,0,12,2],[44071,636,1,3,0,4,2],[44072,460,45,1,21,3,1],[44076,322,23,3,0,3,2],[44078,185,28,1,3,4,1],[44080,164,1,1,5,1,1],[44084,352,1,1,35,1,1],[44085,352,1,1,35,1,1],[44086,352,1,1,35,1,1],[44087,352,1,1,35,1,1],[44088,164,1,1,5,1,1],[44090,164,1,1,5,1,1],[44095,164,1,1,5,1,1],[44098,164,1,1,5,1,1],[44103,17,11,1,2,1,1],[44105,76,9,2,0,1,1],[44109,76,9,2,0,1,1],[44112,423,2,3,0,1,4],[44113,423,2,3,0,1,4],[44114,423,2,3,0,1,4],[44115,423,2,3,0,1,4],[44186,38,5,3,0,1,2],[44204,65,23,11,0,7,2],[44207,322,23,1,5,3,1],[44209,322,23,1,5,3,1],[44226,541,80,2,0,4,1],[44229,262,1,2,7,4,1],[44231,65,23,11,0,7,2],[44233,61,28,1,7,4,1],[44299,61,17,11,0,5,4],[44307,652,17,3,0,1,2],[44310,315,23,1,5,1,1],[44311,593,23,3,0,1,1],[44312,88,23,1,6,3,1],[44313,111,23,2,0,1,1],[44314,88,23,1,6,3,1],[44315,599,23,2,0,1,1],[44316,599,23,2,0,1,1],[44322,97,20,1,7,3,1],[44323,97,20,1,7,3,1],[44324,97,20,1,7,3,1],[44333,54,1,3,0,1,2],[44334,13,5,3,0,1,2],[44337,65,23,11,0,7,2],[44339,462,1,2,0,2,1],[44340,420,1,2,0,16,1],[44341,436,1,2,0,1,1],[44343,132,44,1,6,3,1],[44344,102,1,2,0,4,4],[44347,206,1,2,0,9,3],[44349,132,44,1,6,3,1],[44350,132,44,1,6,3,1],[44351,132,44,1,6,3,1],[44353,132,44,1,6,3,1],[44354,442,1,2,0,4,3],[44356,424,1,2,0,4,3],[44358,132,44,1,6,3,1],[44360,353,1,2,35,2,3],[44365,437,46,1,5,4,1],[44367,75,1,1,5,1,1],[44387,379,17,1,6,3,1],[44388,176,47,2,0,2,1],[44389,290,3,1,7,1,1],[44390,290,3,1,7,1,1],[44391,421,6,2,0,1,1],[44392,28,17,5,0,2,1],[44394,28,17,5,0,2,1],[44396,352,1,1,35,1,1],[44399,99,2,2,0,1,1],[44400,534,34,2,0,2,1],[44402,352,1,1,35,1,1],[44403,352,1,1,35,1,1],[44404,28,17,5,0,2,1],[44405,352,1,1,35,1,1],[44406,362,33,2,0,1,1],[44407,352,1,1,35,1,1],[44408,179,1,2,0,1,1],[44409,352,1,1,35,1,1],[44411,352,1,1,35,1,1],[44412,64,34,2,0,2,1],[44414,352,1,1,35,1,1],[44415,398,48,2,0,3,1],[44419,310,62,3,0,2,1],[44421,61,17,2,0,4,1],[44422,61,17,2,0,4,1],[44423,61,17,2,0,4,1],[44424,61,17,2,0,4,1],[44449,65,23,1,3,4,1],[44450,65,23,1,3,4,1],[44451,65,23,1,3,4,1],[44453,377,17,3,0,4,3],[44457,61,17,3,0,5,2],[44475,177,8,3,0,1,2],[44476,221,1,3,0,1,2],[44479,29,6,3,0,5,2],[44481,9,1,3,0,4,2],[44482,9,1,2,0,4,3],[44486,467,23,1,5,1,1],[44487,593,23,3,0,1,1],[44488,643,23,1,5,1,1],[44495,83,38,1,11,1,1],[44497,445,1,2,0,1,1],[44498,445,1,2,0,1,1],[44499,75,1,1,5,1,1],[44506,399,1,11,0,5,4],[44517,61,17,4,0,4,1],[44519,111,23,2,5,1,1],[44520,117,23,5,0,3,1],[44528,275,23,1,21,3,1],[44529,320,23,1,33,2,1],[44534,662,23,1,32,1,1],[44536,662,23,1,21,1,1],[44537,662,23,1,21,1,1],[44538,662,23,1,21,1,1],[44539,662,23,1,21,1,1],[44542,65,23,11,0,7,4],[44543,65,23,11,0,7,4],[44552,61,17,1,20,4,3],[44622,224,23,1,5,3,1],[44624,13,5,3,0,1,2],[44625,382,1,25,0,1,2],[44628,141,1,5,7,3,1],[44634,131,1,9,0,1,1],[44637,88,23,2,0,3,2],[44703,228,23,1,5,3,1],[44704,548,91,1,5,3,1],[44705,284,23,3,0,1,1],[44706,601,42,2,0,1,1],[44709,65,23,11,0,7,2],[44777,315,23,1,13,1,1],[44779,411,23,1,0,1,1],[44780,411,23,1,0,1,1],[44781,411,23,1,0,1,1],[44782,411,23,1,0,1,1],[44783,411,23,1,0,1,1],[44785,335,34,2,0,1,1],[44786,335,34,2,0,1,1],[44793,65,23,11,0,7,4],[44794,65,23,11,0,7,4],[44797,61,17,14,0,4,1],[44800,61,48,3,0,5,2],[44801,293,2,3,0,1,2],[44804,95,28,1,5,3,1],[44806,378,1,2,0,3,1],[44808,164,1,1,13,1,1],[44809,164,1,1,13,1,1],[44811,164,1,1,13,1,1],[44812,164,1,1,13,1,1],[44814,164,1,1,13,1,1],[44816,164,1,1,13,1,1],[44818,164,1,1,13,1,1],[44819,224,23,1,7,3,1],[44824,21,15,4,0,1,1],[44835,61,17,14,0,4,1],[44836,315,23,1,5,1,1],[44838,264,23,1,2,1,1],[44839,264,23,1,2,1,1],[44840,593,23,3,0,1,1],[44841,593,23,3,0,1,1],[44842,111,23,2,0,1,1],[44843,111,23,2,0,1,1],[44850,61,17,11,0,5,4],[44852,615,6,2,0,1,1],[44853,615,6,2,0,1,1],[44855,352,1,1,35,1,1],[44856,470,15,1,7,1,1],[44857,61,28,1,7,4,1],[44859,1,1,1,1,1,1],[44860,352,1,1,35,1,1],[44861,352,1,1,35,1,1],[44863,352,1,1,35,1,1],[44864,65,23,11,0,7,4],[44865,65,23,11,0,7,4],[44868,313,64,3,0,1,2],[44873,134,45,1,7,7,1],[44874,11,8,5,0,3,1],[44876,33,8,3,0,3,1],[44877,204,8,5,0,3,1],[44879,594,23,1,0,1,1],[44880,196,51,1,0,3,1],[44881,595,23,2,0,2,1],[44883,98,41,1,5,3,1],[44903,180,17,1,6,3,2],[44905,246,17,3,0,1,1],[44906,246,17,3,0,1,1],[44907,246,17,3,0,1,1],[44910,278,23,2,0,3,2],[44978,463,23,3,0,3,2],[45016,315,23,1,5,1,1],[45017,89,21,1,0,1,1],[45018,89,21,1,0,1,1],[45019,593,23,3,0,1,1],[45024,654,23,3,0,1,1],[45026,95,28,3,0,3,2],[45027,13,5,3,0,1,2],[45110,8,1,2,0,4,1],[45114,12,1,2,0,4,1],[45116,12,1,2,0,4,1],[45117,12,1,2,0,4,1],[45121,123,1,2,0,10,1],[45125,200,1,2,0,4,1],[45131,427,2,3,0,1,1],[45132,427,2,3,0,1,1],[45133,427,2,3,0,1,1],[45134,427,2,3,0,1,1],[45135,427,2,3,0,1,1],[45136,427,2,3,0,1,1],[45137,427,2,3,0,1,1],[45138,427,2,3,0,1,1],[45139,427,2,3,0,1,1],[45140,427,2,3,0,1,1],[45141,427,2,3,0,1,1],[45142,427,2,3,0,1,1],[45143,427,2,3,0,1,1],[45144,427,2,3,0,1,1],[45145,427,2,3,0,1,1],[45146,427,2,3,0,1,1],[45147,427,2,3,0,1,1],[45148,427,2,3,0,1,1],[45149,427,2,3,0,1,1],[45150,427,2,3,0,1,1],[45151,427,2,3,0,1,1],[45152,427,2,3,0,1,1],[45153,427,2,3,0,1,1],[45154,427,2,3,0,1,1],[45155,427,2,3,0,1,1],[45156,427,2,3,0,1,1],[45157,427,2,3,0,1,1],[45158,427,2,3,0,1,1],[45159,427,2,3,0,1,1],[45160,427,2,3,0,1,1],[45161,427,2,3,0,1,1],[45162,427,2,3,0,1,1],[45163,427,2,3,0,1,1],[45164,427,2,3,0,1,1],[45165,291,15,1,5,3,1],[45245,312,15,3,0,1,2],[45246,235,18,1,6,3,2],[45249,403,23,2,0,3,1],[45250,403,23,2,0,3,1],[45251,403,23,2,0,3,1],[45253,403,23,2,0,3,1],[45254,377,17,3,0,4,3],[45263,466,24,5,0,2,1],[45264,536,1,5,0,3,1],[45344,65,23,11,0,7,2],[45358,61,17,11,0,5,4],[45424,427,2,3,0,1,1],[45425,427,2,3,0,1,1],[45426,427,2,3,0,1,1],[45427,427,2,3,0,1,1],[45428,427,2,3,0,1,1],[45429,427,2,3,0,1,1],[45430,427,2,3,0,1,1],[45431,427,2,3,0,1,1],[45432,427,2,3,0,1,1],[45433,427,2,3,0,1,1],[45434,427,2,3,0,1,1],[45435,427,2,3,0,1,1],[45436,427,2,3,0,1,1],[45437,427,2,3,0,1,1],[45438,427,2,3,0,1,1],[45439,427,2,3,0,1,1],[45440,427,2,3,0,1,1],[45441,427,2,3,0,1,1],[45442,427,2,3,0,1,1],[45443,427,2,3,0,1,1],[45444,427,2,3,0,1,1],[45445,427,2,3,0,1,1],[45446,427,2,3,0,1,1],[45447,427,2,3,0,1,1],[45448,427,2,3,0,1,1],[45449,427,2,3,0,1,1],[45450,427,2,3,0,1,1],[45451,427,2,3,0,1,1],[45452,427,2,3,0,1,1],[45453,427,2,3,0,1,1],[45454,427,2,3,0,1,1],[45455,427,2,3,0,1,1],[45456,427,2,3,0,1,1],[45457,427,2,3,0,1,1],[45460,65,23,1,3,4,1],[45461,65,23,1,3,4,1],[45462,65,23,1,3,4,1],[45465,9,1,3,0,4,2],[45602,644,23,3,0,3,1],[45603,644,23,3,0,3,1],[45605,366,1,3,0,1,1],[45606,350,1,2,0,4,1],[45608,61,17,1,20,4,3],[45610,206,1,2,0,9,1],[45611,403,23,2,0,3,1],[45612,403,23,2,0,3,1],[45624,264,23,1,2,1,1],[45625,224,23,1,5,3,1],[45721,226,23,1,6,3,1],[45723,8,1,10,0,4,1],[45724,8,1,10,0,4,1],[45725,8,1,10,0,4,1],[45726,32,1,6,0,2,1],[45727,473,39,2,0,16,1],[45788,164,1,1,5,1,1],[45789,164,1,1,5,1,1],[45790,164,1,1,5,1,1],[45794,224,23,1,5,3,1],[45795,661,23,2,0,2,1],[45796,264,23,1,2,1,1],[45807,65,23,11,0,7,2],[45854,399,1,11,0,5,4],[45856,227,23,1,13,3,1],[45859,88,23,5,0,3,1],[45860,61,6,1,5,4,1],[45863,37,23,3,0,1,2],[45873,8,1,1,5,4,1],[45874,8,1,1,5,4,1],[45875,8,1,1,5,4,1],[45876,8,1,1,5,4,1],[45916,152,1,2,0,2,1],[45920,31,18,3,0,4,2],[45939,275,23,1,5,3,1],[45941,593,23,2,0,1,1],[45985,202,17,3,0,1,2],[45986,202,17,3,0,1,2],[46025,224,23,1,5,3,1],[46026,333,23,4,0,2,1],[46088,75,1,1,5,1,1],[46089,75,1,1,5,1,1],[46112,85,15,3,0,1,2],[46113,382,1,25,0,1,2],[46114,221,1,3,0,1,2],[46179,164,1,1,5,1,1],[46180,164,1,1,5,1,1],[46200,263,1,22,3,1,1],[46232,224,23,1,5,3,1],[46233,598,23,2,0,4,1],[46234,169,23,2,0,4,1],[46235,164,1,1,5,1,1],[46265,493,21,1,7,3,1],[46266,243,1,4,0,1,1],[46267,393,1,9,0,1,1],[46272,89,21,1,5,1,1],[46273,194,8,1,2,3,1],[46274,302,45,9,0,1,1],[46275,55,1,3,0,1,1],[46276,620,11,2,0,2,1],[46277,400,77,1,40,2,1],[46278,239,20,4,0,1,1],[46279,516,8,4,0,3,1],[46280,609,77,2,0,2,1],[46281,11,8,2,0,3,1],[46284,157,27,5,0,1,1],[46285,164,1,1,5,1,1],[46286,164,1,1,5,1,1],[46288,164,1,1,5,1,1],[46289,164,1,1,5,1,1],[46290,164,1,1,5,1,1],[46291,164,1,1,5,1,1],[46292,217,8,1,0,3,1],[46293,217,8,1,0,3,1],[46294,393,1,2,0,1,1],[46295,393,1,2,0,1,1],[46296,164,1,1,5,1,1],[46297,164,1,1,5,1,1],[46298,352,1,1,35,1,1],[46299,352,1,1,35,1,1],[46312,611,100,1,1,2,1],[46313,164,1,1,5,1,1],[46314,164,1,1,5,1,1],[46315,352,1,1,35,1,1],[46316,352,1,1,35,1,1],[46317,352,1,1,35,1,1],[46318,352,1,1,35,1,1],[46319,329,20,3,0,1,1],[46320,397,62,1,5,4,1],[46396,224,23,1,5,3,3],[46454,315,23,1,5,1,1],[46455,315,23,1,5,1,1],[46456,315,23,1,5,1,1],[46457,315,23,1,5,1,1],[46458,315,23,1,5,1,1],[46459,315,23,1,5,1,1],[46460,315,23,1,32,1,1],[46461,315,23,1,32,1,1],[46462,315,23,1,32,1,1],[46469,226,23,1,4,3,1],[46478,275,23,1,0,3,1],[46479,275,23,1,0,3,1],[46486,246,17,3,0,1,1],[46487,246,17,3,0,1,1],[46488,246,17,3,0,1,1],[46489,384,12,1,4,2,1],[46490,653,17,5,0,2,1],[46491,653,17,5,0,2,1],[46492,343,2,3,30,1,1],[46493,28,17,5,0,2,1],[46494,415,17,5,0,2,1],[46495,64,34,5,0,2,1],[46496,290,3,1,7,1,1],[46497,290,3,1,7,1,1],[46498,329,20,3,0,1,1],[46499,329,20,3,0,1,1],[46500,352,1,1,35,1,1],[46501,352,1,1,35,1,1],[46502,352,1,1,35,1,1],[46503,352,1,1,35,1,1],[46504,404,34,9,0,1,1],[46505,404,34,9,0,1,1],[46506,404,34,9,0,1,1],[46507,404,34,9,0,1,1],[46527,164,1,1,5,1,1],[46528,164,1,1,5,1,1],[46529,164,1,1,5,1,1],[46597,164,1,1,5,1,1],[46609,164,1,1,5,1,1],[46610,224,23,1,5,3,2],[46621,164,1,1,5,1,1],[46735,164,1,1,5,1,1],[46737,164,1,1,5,1,1],[46738,164,1,1,5,1,1],[46805,61,17,11,0,5,4],[46807,65,23,1,3,4,1],[46808,65,23,1,3,4,1],[46809,65,23,1,3,4,1],[46810,593,23,2,0,1,1],[46812,164,1,1,5,1,1],[46813,164,1,1,5,1,1],[46814,164,1,1,5,1,1],[46815,164,1,1,5,1,1],[46816,164,1,1,5,1,1],[46817,164,1,1,5,1,1],[46818,101,15,9,5,1,1],[46819,164,1,1,5,1,1],[46820,164,1,1,5,1,1],[46821,164,1,1,5,1,1],[46822,164,1,1,5,1,1],[46825,164,1,1,5,1,1],[46826,399,1,11,0,5,4],[46827,89,21,1,0,1,1],[46828,89,21,1,0,1,1],[46829,89,21,1,0,1,1],[46830,89,21,1,0,1,1],[46831,89,21,1,0,1,1],[46832,89,21,1,0,1,1],[46833,89,21,1,0,1,1],[46835,89,21,1,0,1,1],[46836,89,21,1,0,1,1],[46840,89,21,1,0,1,1],[46904,593,23,2,0,1,1],[46905,61,28,1,7,4,1],[46906,340,26,1,34,1,1],[46907,340,26,1,34,1,1],[46908,352,1,1,35,1,1],[46909,352,1,1,35,1,1],[46910,352,1,1,35,1,1],[46911,352,1,1,35,1,1],[46912,340,26,1,34,1,1],[46914,340,26,1,34,1,1],[46916,597,23,3,0,3,2],[46918,376,1,1,0,4,4],[46926,352,1,1,35,1,1],[46931,131,1,1,0,1,1],[46941,83,38,1,11,1,1],[46943,83,38,1,11,1,1],[46984,380,8,1,4,3,1],[47202,146,15,3,0,3,2],[47226,190,12,1,5,4,1],[47227,246,17,3,0,1,1],[47228,246,17,3,0,1,1],[47229,246,17,3,0,1,1],[47230,190,17,2,0,4,1],[47231,224,23,1,22,3,1],[47234,117,23,5,0,3,1],[47235,117,23,5,0,3,1],[47237,8,1,1,3,4,2],[47253,559,15,1,7,1,1],[47256,95,28,3,0,3,2],[47258,427,2,3,0,1,1],[47259,427,2,3,0,1,1],[47260,427,2,3,0,1,1],[47261,427,2,3,0,1,1],[47262,427,2,3,0,1,1],[47263,427,2,3,0,1,1],[47264,427,2,3,0,1,1],[47265,427,2,3,0,1,1],[47266,427,2,3,0,1,1],[47267,427,2,3,0,1,1],[47268,427,2,3,0,1,1],[47269,427,2,3,0,1,1],[47270,427,2,3,0,1,1],[47271,427,2,3,0,1,1],[47272,427,2,3,0,1,1],[47273,427,2,3,0,1,1],[47274,427,2,3,0,1,1],[47275,427,2,3,0,1,1],[47276,427,2,3,0,1,1],[47277,427,2,3,0,1,1],[47278,427,2,3,0,1,1],[47279,427,2,3,0,1,1],[47280,427,2,3,0,1,1],[47281,427,2,3,0,1,1],[47282,427,2,3,0,1,1],[47283,427,2,3,0,1,1],[47284,427,2,3,0,1,1],[47285,427,2,3,0,1,1],[47286,427,2,3,0,1,1],[47287,427,2,3,0,1,1],[47288,427,2,3,0,1,1],[47289,427,2,3,0,1,1],[47290,427,2,3,0,1,1],[47291,427,2,3,0,1,1],[47292,427,2,3,0,1,1],[47293,427,2,3,0,1,1],[47294,376,1,10,0,4,1],[47295,376,1,10,0,4,1],[47296,593,23,2,0,1,1],[47297,272,23,1,7,1,1],[47298,403,23,9,0,3,1],[47299,656,23,2,0,2,1],[47300,196,51,1,5,3,1],[47302,65,23,1,7,4,1],[47305,137,38,1,13,4,1],[47306,614,25,3,0,1,2],[47309,93,1,2,0,2,1],[47310,457,1,1,46,8,1],[47317,450,1,2,0,8,1],[47318,450,1,2,0,8,1],[47319,201,1,5,0,8,1],[47320,91,1,2,0,8,1],[47321,597,23,3,0,3,2],[47346,242,34,3,0,1,1],[47429,538,1,3,0,1,1],[47430,52,27,3,0,1,1],[47438,621,38,9,0,2,1],[47440,14,9,3,0,1,1],[47441,46,25,1,5,2,1],[47445,533,34,5,0,2,1],[47446,329,20,3,0,1,1],[47448,451,34,2,0,3,1],[47450,352,1,1,35,1,1],[47452,164,1,1,5,1,1],[47453,352,1,1,35,1,1],[47454,329,20,3,0,1,1],[47456,164,1,1,5,1,1],[47457,352,1,1,35,1,1],[47459,538,1,3,0,1,1],[47461,329,20,3,0,1,1],[47462,164,1,1,5,1,1],[47463,622,1,9,0,8,1],[47465,538,1,3,0,1,1],[47467,215,1,2,0,4,1],[47469,164,1,1,5,1,1],[47471,164,1,1,5,1,1],[47472,164,1,1,5,1,1],[47473,164,1,1,5,1,1],[47474,164,1,1,5,1,1],[47475,164,1,1,5,1,1],[47476,329,20,3,0,1,1],[47477,164,1,1,5,1,1],[47478,164,1,1,5,1,1],[47483,622,1,9,0,8,1],[47484,39,1,2,0,2,1],[47485,470,15,1,7,1,1],[47486,329,20,3,0,1,1],[47487,52,27,3,0,1,1],[47488,52,27,3,0,1,1],[47490,164,1,1,5,1,1],[47491,164,1,1,5,1,1],[47492,164,1,1,5,1,1],[47493,352,1,1,35,1,1],[47494,164,1,1,5,1,1],[47495,164,1,1,5,1,1],[47496,164,1,1,5,1,1],[47498,164,1,1,5,1,1],[47500,164,1,1,5,1,1],[47501,329,20,3,0,1,1],[47502,263,1,22,3,1,1],[47503,329,20,3,0,1,1],[47505,263,1,22,3,1,1],[47506,290,3,1,7,1,1],[47507,290,3,1,7,1,1],[47508,263,1,22,3,1,1],[47509,239,20,4,0,1,1],[47510,290,3,1,7,1,1],[47511,352,1,1,35,1,1],[47514,164,1,1,5,1,1],[47515,164,1,1,5,1,1],[47516,164,1,1,5,1,1],[47517,52,27,3,0,1,1],[47518,164,1,1,5,1,1],[47519,52,27,3,0,1,1],[47521,164,1,1,5,1,1],[47522,164,1,1,5,1,1],[47523,164,1,1,5,1,1],[47524,622,1,9,0,8,1],[47525,352,1,1,35,1,1],[47526,141,1,2,0,3,1],[47527,164,1,1,5,1,1],[47528,164,1,1,5,1,1],[47529,352,1,1,35,1,1],[47530,164,1,1,5,1,1],[47531,329,20,3,0,1,1],[47532,65,23,1,3,4,1],[47533,65,23,1,3,4,1],[47536,65,23,1,3,4,1],[47538,352,1,1,35,1,1],[47539,164,1,1,5,1,1],[47540,164,1,1,5,1,1],[47541,269,36,2,0,1,1],[47542,164,1,1,5,1,1],[47543,164,1,1,5,1,1],[47544,164,1,1,5,1,1],[47545,164,1,1,5,1,1],[47546,61,17,1,3,4,1],[47612,164,1,1,5,1,1],[47613,463,23,3,0,3,2],[47615,39,1,2,0,2,1],[47616,39,1,2,0,2,1],[47617,164,1,1,5,1,1],[47684,164,1,1,5,1,1],[47685,164,1,1,5,1,1],[47686,164,1,1,5,1,1],[47687,164,1,1,5,1,1],[47688,164,1,1,5,1,1],[47691,65,23,1,3,4,1],[47693,65,23,1,3,4,1],[47695,65,23,1,3,4,1],[47698,164,1,1,5,1,1],[47699,26,16,1,5,3,1],[47701,491,46,2,0,2,1],[47719,42,17,1,6,3,3],[47851,88,23,2,0,3,2],[47854,65,23,1,3,4,1],[47856,65,23,1,3,4,1],[47857,65,23,1,3,4,1],[47932,96,18,1,13,3,1],[47933,252,15,1,5,1,1],[47934,252,15,1,5,1,1],[47935,252,15,1,5,1,1],[47936,252,15,1,5,1,1],[47937,6,6,2,0,3,1],[47938,464,18,5,0,2,1],[47939,6,6,2,0,3,1],[47940,161,12,1,0,3,1],[47941,638,45,3,0,2,1],[47942,269,36,2,0,1,1],[47943,6,6,2,0,3,1],[47944,184,30,2,0,1,1],[47948,343,69,3,30,1,1],[47949,329,20,3,0,1,1],[47950,336,18,5,0,2,1],[47953,395,16,1,4,8,1],[47954,341,67,2,0,2,1],[47955,329,20,3,0,1,1],[47956,109,43,2,0,1,1],[47957,64,34,9,0,2,1],[47961,497,11,3,30,1,1],[47965,625,1,1,6,2,1],[47966,393,1,9,0,1,1],[47967,473,39,2,0,16,1],[47968,393,1,9,0,1,1],[47969,256,1,1,0,4,1],[47971,75,1,1,5,1,1],[47972,449,83,2,0,1,1],[48042,427,2,3,0,1,1],[48043,427,2,3,0,1,1],[48044,427,2,3,0,1,1],[48045,427,2,3,0,1,1],[48046,427,2,3,0,1,1],[48047,427,2,3,0,1,1],[48048,427,2,3,0,1,1],[48049,427,2,3,0,1,1],[48050,427,2,3,0,1,1],[48051,427,2,3,0,1,1],[48052,427,2,3,0,1,1],[48053,427,2,3,0,1,1],[48054,427,2,3,0,1,1],[48055,427,2,3,0,1,1],[48056,427,2,3,0,1,1],[48057,427,2,3,0,1,1],[48058,427,2,3,0,1,1],[48059,427,2,3,0,1,1],[48060,427,2,3,0,1,1],[48061,427,2,3,0,1,1],[48062,427,2,3,0,1,1],[48063,427,2,3,0,1,1],[48064,427,2,3,0,1,1],[48065,427,2,3,0,1,1],[48066,427,2,3,0,1,1],[48067,427,2,3,0,1,1],[48068,427,2,3,0,1,1],[48069,427,2,3,0,1,1],[48070,427,2,3,0,1,1],[48071,427,2,3,0,1,1],[48072,427,2,3,0,1,1],[48073,427,2,3,0,1,1],[48074,427,2,3,0,1,1],[48075,427,2,3,0,1,1],[48076,427,2,3,0,1,1],[48077,427,2,3,0,1,1],[48079,224,23,1,5,3,1],[48083,64,34,9,0,2,1],[48084,64,34,9,0,2,1],[48085,64,34,9,0,2,1],[48157,88,23,2,0,3,1],[48210,427,2,3,0,1,1],[48211,427,2,3,0,1,1],[48212,427,2,3,0,1,1],[48213,427,2,3,0,1,1],[48214,427,2,3,0,1,1],[48215,427,2,3,0,1,1],[48216,427,2,3,0,1,1],[48217,427,2,3,0,1,1],[48218,427,2,3,0,1,1],[48219,427,2,3,0,1,1],[48220,427,2,3,0,1,1],[48221,427,2,3,0,1,1],[48222,427,2,3,0,1,1],[48223,427,2,3,0,1,1],[48224,427,2,3,0,1,1],[48225,427,2,3,0,1,1],[48226,427,2,3,0,1,1],[48227,427,2,3,0,1,1],[48228,427,2,3,0,1,1],[48229,427,2,3,0,1,1],[48230,427,2,3,0,1,1],[48231,427,2,3,0,1,1],[48232,427,2,3,0,1,1],[48233,427,2,3,0,1,1],[48234,427,2,3,0,1,1],[48235,427,2,3,0,1,1],[48236,427,2,3,0,1,1],[48237,427,2,3,0,1,1],[48238,427,2,3,0,1,1],[48239,427,2,3,0,1,1],[48240,427,2,3,0,1,1],[48241,427,2,3,0,1,1],[48242,427,2,3,0,1,1],[48243,427,2,3,0,1,1],[48244,427,2,3,0,1,1],[48245,427,2,3,0,1,1],[48247,8,1,1,5,4,1],[48248,468,23,1,7,3,1],[48249,468,23,1,5,3,1],[48250,216,23,2,0,3,1],[48251,261,23,2,0,1,1],[48252,593,23,2,0,1,1],[48253,401,23,2,0,1,1],[48254,316,23,1,0,1,1],[48255,316,23,1,0,1,1],[48256,569,23,2,0,1,1],[48261,226,23,1,4,3,1],[48268,455,38,1,5,1,1],[48269,352,1,1,35,1,1],[48270,14,9,3,0,1,1],[48271,393,1,9,0,1,1],[48272,416,10,1,2,3,1],[48273,352,1,1,35,1,1],[48275,589,23,5,0,3,1],[48340,65,23,1,5,4,1],[48423,65,23,1,3,4,1],[48424,65,23,1,3,4,1],[48425,65,23,1,3,4,1],[48426,593,23,2,0,1,1],[48605,94,1,1,12,1,1],[48606,393,1,2,0,1,1],[48618,9,1,1,1,4,2],[48767,427,2,3,0,1,1],[48768,427,2,3,0,1,1],[48769,427,2,3,0,1,1],[48770,427,2,3,0,1,1],[48771,427,2,3,0,1,1],[48772,427,2,3,0,1,1],[48773,427,2,3,0,1,1],[48774,427,2,3,0,1,1],[48775,427,2,3,0,1,1],[48776,427,2,3,0,1,1],[48777,427,2,3,0,1,1],[48778,427,2,3,0,1,1],[48779,427,2,3,0,1,1],[48780,427,2,3,0,1,1],[48781,427,2,3,0,1,1],[48782,427,2,3,0,1,1],[48783,427,2,3,0,1,1],[48784,427,2,3,0,1,1],[48785,427,2,3,0,1,1],[48786,427,2,3,0,1,1],[48787,427,2,3,0,1,1],[48788,427,2,3,0,1,1],[48789,427,2,3,0,1,1],[48790,427,2,3,0,1,1],[48791,427,2,3,0,1,1],[48792,427,2,3,0,1,1],[48793,427,2,3,0,1,1],[48794,427,2,3,0,1,1],[48795,427,2,3,0,1,1],[48796,427,2,3,0,1,1],[48797,427,2,3,0,1,1],[48798,427,2,3,0,1,1],[48799,427,2,3,0,1,1],[48800,427,2,3,0,1,1],[48801,427,2,3,0,1,1],[48802,427,2,3,0,1,1],[48808,210,23,1,6,3,2],[48838,518,1,3,0,1,2],[48840,66,23,1,5,1,1],[48841,401,23,5,0,1,1],[48842,259,23,1,4,8,1],[48844,426,1,21,44,4,1],[48846,8,1,2,0,4,1],[48847,8,1,2,0,4,1],[48848,8,1,2,0,4,1],[48850,477,1,1,13,2,1],[48859,399,1,11,0,5,4],[48860,65,23,1,3,4,1],[48861,65,23,1,3,4,1],[48863,65,23,1,3,4,1],[48864,593,23,2,0,1,1],[48865,61,17,1,45,4,1],[48872,557,95,1,0,1,1],[48873,557,95,1,0,1,1],[48874,126,1,3,0,4,1],[48875,126,1,3,0,4,1],[48876,61,36,1,10,3,1],[48877,260,1,3,5,4,1],[48878,257,1,1,0,4,1],[48882,538,1,3,0,1,1],[48883,538,1,3,0,1,1],[48884,538,1,3,0,1,1],[48885,352,1,1,35,1,1],[48886,538,1,3,0,1,1],[48887,538,1,3,0,1,1],[48888,538,1,3,0,1,1],[48889,538,1,3,0,1,1],[48890,538,1,3,0,1,1],[48891,538,1,3,0,1,1],[48892,393,1,9,0,1,1],[48893,538,1,3,0,1,1],[48894,538,1,3,0,1,1],[48895,538,1,3,0,1,1],[48896,538,1,3,0,1,1],[48897,538,1,3,0,1,1],[48898,393,1,9,0,1,1],[48899,538,1,3,0,1,1],[48900,64,34,9,0,2,1],[48901,141,1,1,6,3,1],[48902,355,1,3,5,1,1],[48903,355,1,3,5,1,1],[48904,538,1,3,0,1,1],[48905,89,21,1,43,1,1],[48906,617,1,1,50,1,1],[48907,200,1,2,0,4,1],[48909,141,1,9,0,3,1],[48910,243,1,4,0,1,1],[48911,650,1,30,0,1,1],[48914,290,3,1,12,1,1],[48915,651,1,3,0,1,1],[48916,290,3,1,12,1,1],[48917,290,3,1,12,1,1],[48918,290,3,1,12,1,1],[48919,89,21,1,0,1,1],[48920,89,21,1,0,1,1],[48921,89,21,1,0,1,1],[48922,145,46,1,5,3,1],[48923,352,1,1,35,1,1],[48924,207,2,17,0,1,1],[48925,352,1,1,35,1,1],[48926,340,26,1,3,1,1],[48927,352,1,1,35,1,1],[48928,437,46,1,5,4,1],[48929,352,1,1,35,1,1],[48930,340,26,1,3,1,1],[48931,263,1,22,3,1,1],[48938,366,1,3,0,1,1],[48942,340,26,1,3,1,1],[48943,469,68,3,0,2,1],[48948,263,1,22,3,1,1],[48950,340,26,1,3,1,1],[48951,573,1,2,0,1,1],[48952,52,27,3,0,1,1],[48953,45,24,1,5,1,1],[48954,52,27,3,0,1,1],[48955,52,27,3,0,1,1],[48957,263,1,22,3,1,1],[48959,352,1,1,35,1,1],[48960,52,27,3,0,1,1],[48961,52,27,3,0,1,1],[48962,238,12,1,0,3,1],[48963,397,62,1,5,4,1],[48964,543,40,2,0,1,1],[48965,11,8,2,0,3,1],[48966,402,11,1,0,3,1],[48967,427,2,3,0,1,1],[48968,427,2,3,0,1,1],[48970,427,2,3,0,1,1],[48971,427,2,3,0,1,1],[48972,427,2,3,0,1,1],[48973,427,2,3,0,1,1],[48974,427,2,3,0,1,1],[48975,427,2,3,0,1,1],[48976,427,2,3,0,1,1],[48977,427,2,3,0,1,1],[48978,427,2,3,0,1,1],[48979,427,2,3,0,1,1],[48980,427,2,3,0,1,1],[48981,427,2,3,0,1,1],[48982,427,2,3,0,1,1],[48984,427,2,3,0,1,1],[48985,427,2,3,0,1,1],[48986,427,2,3,0,1,1],[48987,427,2,3,0,1,1],[48988,427,2,3,0,1,1],[48989,427,2,3,0,1,1],[48990,427,2,3,0,1,1],[48991,427,2,3,0,1,1],[48992,427,2,3,0,1,1],[48993,427,2,3,0,1,1],[48994,427,2,3,0,1,1],[48995,427,2,3,0,1,1],[48996,427,2,3,0,1,1],[48997,427,2,3,0,1,1],[48998,427,2,3,0,1,1],[48999,427,2,3,0,1,1],[49000,427,2,3,0,1,1],[49001,427,2,3,0,1,1],[49002,427,2,3,0,1,1],[49003,315,23,1,5,1,1],[49004,315,23,1,5,1,1],[49005,315,23,1,5,1,1],[49006,315,23,1,5,1,1],[49007,643,23,1,5,1,1],[49008,210,23,1,6,3,1],[49011,322,23,3,0,3,2],[49013,200,1,2,0,4,1],[49014,256,1,1,0,4,1],[49015,200,1,2,0,4,1],[49016,154,12,2,0,2,1],[49018,411,23,1,41,1,1],[49019,411,23,1,41,1,1],[49020,411,23,1,41,1,1],[49021,411,23,1,41,1,1],[49022,411,23,1,41,1,1],[49026,65,23,1,3,4,1],[49027,593,23,2,0,1,1],[49028,65,23,1,3,4,1],[49029,65,23,1,3,4,1],[49049,322,23,1,5,3,1],[49052,102,1,2,0,4,1],[49055,78,16,3,0,1,2],[49056,13,5,3,0,1,2],[49059,335,34,2,0,1,1],[49060,335,34,2,0,1,1],[49062,509,23,3,0,12,2],[49066,83,38,1,11,1,1],[49067,475,86,9,0,1,1],[49068,11,8,5,0,3,1],[49069,351,45,2,0,2,1],[49070,455,38,1,5,1,1],[49071,322,23,1,5,3,1],[49072,322,23,1,5,3,1],[49075,427,2,3,0,1,1],[49076,427,2,3,0,1,1],[49077,427,2,3,0,1,1],[49078,427,2,3,0,1,1],[49079,427,2,3,0,1,1],[49080,427,2,3,0,1,1],[49081,427,2,3,0,1,1],[49082,427,2,3,0,1,1],[49083,427,2,3,0,1,1],[49084,427,2,3,0,1,1],[49085,427,2,3,0,1,1],[49086,427,2,3,0,1,1],[49087,427,2,3,0,1,1],[49088,427,2,3,0,1,1],[49089,427,2,3,0,1,1],[49090,427,2,3,0,1,1],[49091,427,2,3,0,1,1],[49092,427,2,3,0,1,1],[49093,427,2,3,0,1,1],[49094,427,2,3,0,1,1],[49095,427,2,3,0,1,1],[49096,427,2,3,0,1,1],[49097,427,2,3,0,1,1],[49098,427,2,3,0,1,1],[49099,427,2,3,0,1,1],[49100,427,2,3,0,1,1],[49101,427,2,3,0,1,1],[49102,427,2,3,0,1,1],[49103,427,2,3,0,1,1],[49104,427,2,3,0,1,1],[49105,427,2,3,0,1,1],[49106,427,2,3,0,1,1],[49107,427,2,3,0,1,1],[49108,427,2,3,0,1,1],[49112,403,23,3,0,1,1],[49114,403,23,3,0,1,1],[49115,463,23,3,0,3,2],[49122,224,23,1,21,3,1],[49125,509,23,3,0,3,2],[49186,427,2,3,0,1,1],[49187,427,2,3,0,1,1],[49188,427,2,3,0,1,1],[49189,427,2,3,0,1,1],[49190,427,2,3,0,1,1],[49191,427,2,3,0,1,1],[49192,427,2,3,0,1,1],[49193,427,2,3,0,1,1],[49194,427,2,3,0,1,1],[49195,427,2,3,0,1,1],[49196,427,2,3,0,1,1],[49197,427,2,3,0,1,1],[49198,427,2,3,0,1,1],[49199,427,2,3,0,1,1],[49200,427,2,3,0,1,1],[49201,427,2,3,0,1,1],[49202,427,2,3,0,1,1],[49203,427,2,3,0,1,1],[49204,427,2,3,0,1,1],[49205,427,2,3,0,1,1],[49206,427,2,3,0,1,1],[49207,427,2,3,0,1,1],[49208,427,2,3,0,1,1],[49209,427,2,3,0,1,1],[49210,427,2,3,0,1,1],[49211,427,2,3,0,1,1],[49212,427,2,3,0,1,1],[49213,427,2,3,0,1,1],[49214,427,2,3,0,1,1],[49215,427,2,3,0,1,1],[49216,427,2,3,0,1,1],[49217,427,2,3,0,1,1],[49218,427,2,3,0,1,1],[49219,427,2,3,0,1,1],[49256,315,23,1,5,1,1],[49260,346,1,1,4,3,1],[49261,107,1,2,0,4,1],[49262,107,1,2,0,4,1],[49263,142,1,5,0,3,1],[49264,141,1,4,0,3,1],[49279,427,2,3,0,1,1],[49280,427,2,3,0,1,1],[49281,427,2,3,0,1,1],[49282,427,2,3,0,1,1],[49283,427,2,3,0,1,1],[49284,427,2,3,0,1,1],[49285,427,2,3,0,1,1],[49286,427,2,3,0,1,1],[49287,427,2,3,0,1,1],[49288,427,2,3,0,1,1],[49289,427,2,3,0,1,1],[49290,427,2,3,0,1,1],[49291,427,2,3,0,1,1],[49292,427,2,3,0,1,1],[49293,427,2,3,0,1,1],[49294,427,2,3,0,1,1],[49295,427,2,3,0,1,1],[49296,427,2,3,0,1,1],[49297,427,2,3,0,1,1],[49298,427,2,3,0,1,1],[49299,427,2,3,0,1,1],[49300,427,2,3,0,1,1],[49301,427,2,3,0,1,1],[49302,427,2,3,0,1,1],[49303,427,2,3,0,1,1],[49304,427,2,3,0,1,1],[49305,427,2,3,0,1,1],[49306,427,2,3,0,1,1],[49307,427,2,3,0,1,1],[49308,427,2,3,0,1,1],[49309,427,2,3,0,1,1],[49310,427,2,3,0,1,1],[49311,427,2,3,0,1,1],[49312,427,2,3,0,1,1],[49313,427,2,3,0,1,1],[49314,427,2,3,0,1,1],[49315,114,23,5,0,3,1],[49317,596,23,2,0,1,1],[49319,264,23,1,2,1,1],[49321,550,23,2,0,2,1],[49322,264,23,1,2,1,1],[49323,114,23,9,0,2,1],[49324,316,23,1,5,1,1],[49325,317,23,2,0,2,1],[49330,278,23,2,0,3,2],[49332,27,26,3,0,1,2],[49333,190,38,3,0,4,2],[49336,146,15,23,0,3,2],[49338,315,23,1,5,1,1],[49383,65,23,1,3,4,1],[49384,65,23,1,3,4,1],[49387,117,23,1,21,3,1],[49390,65,23,1,5,4,1],[49391,65,23,1,5,4,1],[49392,65,23,1,5,4,1],[49395,146,15,9,0,3,1],[49396,577,15,2,0,2,1],[49397,50,15,2,0,2,1],[49398,166,15,9,0,1,1],[49399,657,15,2,0,1,1],[49400,268,15,9,0,2,1],[49402,339,15,2,0,2,1],[49403,41,15,2,0,2,1],[49464,106,38,1,3,4,1],[49465,106,38,1,3,4,1],[49466,106,38,1,3,4,1],[49469,75,1,1,5,1,1],[49470,75,1,1,5,1,1],[49492,224,23,1,5,3,3],[49495,227,23,1,7,3,1],[49501,88,23,2,0,3,1],[49503,61,17,1,20,4,3],[49505,509,23,3,0,1,2],[49809,11,8,11,0,1,4],[49810,11,8,11,0,1,4],[49812,590,23,1,1,2,1],[49813,60,23,2,0,2,1],[49814,60,23,10,0,2,1],[49815,319,23,1,5,1,1],[49816,319,23,1,5,1,1],[49817,558,1,9,0,4,2],[49818,350,1,2,0,4,2],[49949,75,1,1,5,1,1],[49950,75,1,1,5,1,1],[49954,141,1,5,0,3,1],[49961,88,23,1,3,3,1],[49962,88,23,1,3,3,1],[50001,202,17,3,0,1,2],[50002,202,17,3,0,1,2],[50005,322,23,3,0,3,2],[50212,614,25,3,0,1,2],[50319,293,2,3,0,1,2],[50321,88,23,2,0,3,2],[50322,88,23,2,0,3,2],[50469,427,2,3,0,1,1],[50470,427,2,3,0,1,1],[50471,427,2,3,0,1,1],[50472,427,2,3,0,1,1],[50473,427,2,3,0,1,1],[50474,427,2,3,0,1,1],[50475,427,2,3,0,1,1],[50476,427,2,3,0,1,1],[50477,427,2,3,0,1,1],[50478,427,2,3,0,1,1],[50479,427,2,3,0,1,1],[50480,427,2,3,0,1,1],[50481,427,2,3,0,1,1],[50482,427,2,3,0,1,1],[50483,427,2,3,0,1,1],[50484,427,2,3,0,1,1],[50485,427,2,3,0,1,1],[50486,427,2,3,0,1,1],[50487,427,2,3,0,1,1],[50488,427,2,3,0,1,1],[50489,427,2,3,0,1,1],[50490,427,2,3,0,1,1],[50491,427,2,3,0,1,1],[50492,427,2,3,0,1,1],[50493,427,2,3,0,1,1],[50494,427,2,3,0,1,1],[50495,427,2,3,0,1,1],[50496,427,2,3,0,1,1],[50497,427,2,3,0,1,1],[50498,427,2,3,0,1,1],[50499,427,2,3,0,1,1],[50500,427,2,3,0,1,1],[50501,427,2,3,0,1,1],[50502,427,2,3,0,1,1],[50503,427,2,3,0,1,1],[50504,427,2,3,0,1,1],[50986,617,1,1,7,1,1],[50992,164,1,1,5,1,1],[50993,164,1,1,5,1,1],[50995,371,72,1,2,3,1],[50996,164,1,1,5,1,1],[50997,164,1,1,5,1,1],[50998,574,6,3,8,1,1],[50999,248,1,1,23,1,1],[51000,164,1,1,5,1,1],[51002,329,20,3,0,1,1],[51003,164,1,1,5,1,1],[51004,164,1,1,5,1,1],[51005,164,1,1,5,1,1],[51006,164,1,1,5,1,1],[51007,164,1,1,5,1,1],[51008,290,3,1,12,1,1],[51009,164,1,1,5,1,1],[51010,164,1,1,5,1,1],[51011,164,1,1,5,1,1],[51013,618,2,2,0,1,1],[51014,498,6,2,0,2,1],[51015,164,1,1,5,1,1],[51016,164,1,1,5,1,1],[51017,164,1,1,5,1,1],[51018,164,1,1,5,1,1],[51019,574,6,3,8,1,1],[51020,164,1,1,5,1,1],[51021,352,1,1,35,1,1],[51022,352,1,1,35,1,1],[51023,164,1,1,5,1,1],[51024,164,1,1,5,1,1],[51026,164,1,1,5,1,1],[51027,164,1,1,5,1,1],[51028,164,1,1,5,1,1],[51029,164,1,1,5,1,1],[51030,513,85,1,49,3,1],[51031,492,89,3,8,2,1],[51032,618,2,2,0,1,1],[51033,574,6,3,8,1,1],[51035,164,1,1,5,1,1],[51036,352,1,1,35,1,1],[51037,164,1,1,5,1,1],[51038,371,72,1,2,3,1],[51039,164,1,1,5,1,1],[51040,164,1,1,5,1,1],[51041,164,1,1,5,1,1],[51042,164,1,1,5,1,1],[51043,164,1,1,5,1,1],[51045,164,1,1,5,1,1],[51046,164,1,1,5,1,1],[51047,164,1,1,5,1,1],[51048,164,1,1,5,1,1],[51049,164,1,1,5,1,1],[51050,371,72,1,2,3,1],[51051,329,20,3,0,1,1],[51052,164,1,1,5,1,1],[51054,352,1,1,35,1,1],[51056,164,1,1,5,1,1],[51057,329,20,3,0,1,1],[51058,352,1,1,35,1,1],[51059,329,20,3,0,1,1],[51062,574,6,3,8,1,1],[51063,574,6,3,8,1,1],[51064,164,1,1,5,1,1],[51065,164,1,1,5,1,1],[51066,164,1,1,5,1,1],[51068,574,6,3,8,1,1],[51069,618,2,2,0,1,1],[51070,290,3,1,12,1,1],[51071,94,1,1,12,1,1],[51072,94,1,1,12,1,1],[51073,574,6,3,8,1,1],[51075,8,1,2,0,4,1],[51077,8,1,2,0,4,1],[51082,618,2,2,0,1,1],[51083,574,6,3,8,1,1],[51084,163,1,2,0,11,1],[51087,557,95,1,0,1,1],[51088,557,95,1,0,1,1],[51094,441,1,2,0,3,1],[51095,576,1,2,0,3,1],[51096,441,1,2,0,3,1],[51097,231,1,3,0,5,1],[51098,557,95,1,0,1,1],[51099,555,94,2,0,1,1],[51102,88,23,2,0,3,1],[51280,255,1,21,24,4,2],[51281,255,1,21,24,4,2],[51284,363,23,1,38,3,1],[51444,134,45,1,7,7,1],[51445,8,1,1,5,4,1],[51511,61,17,1,7,4,4],[51622,427,2,3,0,1,1],[51623,427,2,3,0,1,1],[51624,427,2,3,0,1,1],[51625,427,2,3,0,1,1],[51626,427,2,3,0,1,1],[51627,427,2,3,0,1,1],[51628,427,2,3,0,1,1],[51629,427,2,3,0,1,1],[51630,427,2,3,0,1,1],[51631,427,2,3,0,1,1],[51632,427,2,3,0,1,1],[51633,427,2,3,0,1,1],[51634,427,2,3,0,1,1],[51635,427,2,3,0,1,1],[51636,427,2,3,0,1,1],[51637,427,2,3,0,1,1],[51638,427,2,3,0,1,1],[51639,427,2,3,0,1,1],[51640,427,2,3,0,1,1],[51641,427,2,3,0,1,1],[51642,427,2,3,0,1,1],[51643,427,2,3,0,1,1],[51644,427,2,3,0,1,1],[51645,427,2,3,0,1,1],[51646,427,2,3,0,1,1],[51647,427,2,3,0,1,1],[51648,427,2,3,0,1,1],[51649,427,2,3,0,1,1],[51650,427,2,3,0,1,1],[51651,427,2,3,0,1,1],[51652,427,2,3,0,1,1],[51653,427,2,3,0,1,1],[51654,427,2,3,0,1,1],[51655,427,2,3,0,1,1],[51656,95,28,1,7,3,1],[51657,296,60,4,0,15,1],[51658,95,28,1,28,3,1],[51822,363,23,1,38,3,1],[51824,593,23,2,0,1,1],[51825,258,23,1,25,3,1],[51826,258,23,1,25,3,1],[51827,258,23,1,13,3,1],[51828,258,23,1,13,3,1],[51829,570,23,1,5,1,1],[51830,570,23,1,50,1,1],[51831,642,23,1,0,1,1],[51832,315,23,1,5,1,1],[51833,315,23,1,5,1,1],[51834,315,23,1,5,1,1],[51835,315,23,1,5,1,1],[51836,315,23,1,5,1,1],[51837,315,23,1,5,1,1],[51838,315,23,1,5,1,1],[51839,315,23,1,5,1,1],[51840,315,23,1,5,1,1],[51841,315,23,1,5,1,1],[51843,113,23,1,7,1,1],[51845,148,23,1,18,1,1],[51846,365,23,1,5,2,1],[51847,559,15,1,7,1,1],[51850,244,1,1,19,3,2],[51946,655,23,3,0,1,1],[51947,655,23,3,0,1,1],[51948,655,23,3,0,1,1],[51949,655,23,3,0,1,1],[51950,655,23,3,0,1,1],[51951,655,23,3,0,1,1],[51954,331,66,1,42,4,1],[52009,538,1,3,0,1,1],[52010,538,1,3,0,1,1],[52011,538,1,3,0,1,1],[52012,538,1,3,0,1,1],[52013,538,1,3,0,1,1],[52014,538,1,3,0,1,1],[52015,538,1,3,0,1,1],[52016,538,1,3,0,1,1],[52017,434,1,2,0,2,1],[52018,538,1,3,0,1,1],[52019,538,1,3,0,1,1],[52020,538,1,3,0,1,1],[52021,538,1,3,0,1,1],[52022,538,1,3,0,1,1],[52023,538,1,3,0,1,1],[52024,538,1,3,0,1,1],[52025,538,1,3,0,1,1],[52026,538,1,3,0,1,1],[52027,538,1,3,0,1,1],[52028,538,1,3,0,1,1],[52029,538,1,3,0,1,1],[52084,65,23,1,5,4,1],[52146,377,17,3,0,4,3],[52150,279,23,2,0,3,1],[52151,463,23,1,0,3,1],[52157,542,1,3,0,1,1],[52158,243,1,4,0,1,1],[52159,72,34,1,21,3,1],[52160,22,16,2,0,2,1],[52161,40,10,9,0,4,1],[52162,366,1,3,0,1,1],[52164,538,1,3,0,1,1],[52165,263,1,22,3,1,1],[52166,538,1,3,0,1,1],[52167,263,1,22,3,1,1],[52168,89,21,1,0,1,1],[52170,263,1,22,3,1,1],[52171,89,21,1,0,1,1],[52172,89,21,1,0,1,1],[52174,83,38,1,11,1,1],[52175,63,33,9,0,3,1],[52176,538,1,3,0,1,1],[52177,538,1,3,0,1,1],[52178,89,21,1,0,1,1],[52179,538,1,3,0,1,1],[52180,538,1,3,0,1,1],[52181,538,1,3,0,1,1],[52182,538,1,3,0,1,1],[52183,538,1,3,0,1,1],[52184,89,21,1,0,1,1],[52185,538,1,3,0,1,1],[52186,538,1,3,0,1,1],[52187,538,1,3,0,1,1],[52188,452,84,2,0,15,1],[52189,340,26,1,0,1,1],[52190,340,26,1,0,1,1],[52191,561,84,4,0,2,1],[52192,561,84,4,0,2,1],[52193,340,26,1,0,1,1],[52194,340,26,1,0,1,1],[52196,75,1,1,5,1,1],[52197,75,1,1,5,1,1],[52200,227,23,1,7,3,1],[52202,61,17,1,3,4,1],[52255,509,23,3,0,12,2],[52257,165,23,4,0,3,1],[52259,8,1,1,3,4,1],[52320,520,23,1,5,1,1],[52322,520,23,1,5,1,1],[52388,315,23,1,5,1,1],[52389,315,23,1,5,1,1],[52390,315,23,1,5,1,1],[52391,315,23,1,5,1,1],[52392,315,23,1,5,1,1],[52394,538,1,3,0,1,1],[52395,538,1,3,0,1,1],[52396,538,1,3,0,1,1],[52397,538,1,3,0,1,1],[52398,538,1,3,0,1,1],[52399,538,1,3,0,1,1],[52400,538,1,3,0,1,1],[52401,538,1,3,0,1,1],[52402,538,1,3,0,1,1],[52403,538,1,3,0,1,1],[52404,538,1,3,0,1,1],[52405,538,1,3,0,1,1],[52406,538,1,3,0,1,1],[52407,538,1,3,0,1,1],[52408,538,1,3,0,1,1],[52409,538,1,3,0,1,1],[52410,538,1,3,0,1,1],[52411,538,1,3,0,1,1],[52412,538,1,3,0,1,1],[52413,538,1,3,0,1,1],[52414,538,1,3,0,1,1],[52415,538,1,3,0,1,1],[52416,538,1,3,0,1,1],[52417,538,1,3,0,1,1],[52418,610,34,2,0,1,1],[52419,392,1,2,0,1,1],[52420,610,34,2,0,1,1],[52422,83,38,1,11,1,1],[52423,170,1,2,0,1,1],[52424,170,1,2,0,1,1],[52426,170,1,2,0,1,1],[52443,315,23,1,5,1,1],[52444,315,23,1,5,1,1],[52445,315,23,1,5,1,1],[52446,315,23,1,5,1,1],[52447,315,23,1,5,1,1],[52448,315,23,1,5,1,1],[52449,315,23,1,5,1,1],[52450,315,23,1,5,1,1],[52713,61,17,1,3,4,1],[52732,352,1,1,35,1,1],[52733,352,1,1,35,1,1],[52735,119,1,1,6,1,1],[52736,352,1,1,35,1,1],[52741,119,1,1,6,1,1],[52747,89,21,1,0,1,1],[52748,89,21,1,0,1,1],[52749,290,3,1,7,1,1],[52752,89,21,1,0,1,1],[52755,290,3,1,7,1,1],[52758,290,3,1,7,1,1],[52759,290,3,1,7,1,1],[52762,290,3,1,7,1,1],[52764,89,21,1,0,1,1],[52769,352,1,1,35,1,1],[52785,233,23,19,0,1,1],[52786,233,23,19,0,1,1],[52787,233,23,19,0,1,1],[52788,233,23,19,0,1,1],[52789,233,23,19,0,1,1],[52790,233,23,19,0,1,1],[52791,233,23,19,0,1,1],[52792,233,23,19,0,1,1],[52793,233,23,19,0,1,1],[52817,409,48,3,0,3,2],[52887,496,34,1,7,4,1],[52888,241,1,3,0,1,1],[52889,8,1,10,0,4,1],[52890,8,1,10,0,4,1],[52891,8,1,10,0,4,1],[52892,8,1,10,0,4,1],[52894,465,18,9,0,3,1],[52897,336,18,1,5,2,1],[52898,478,18,1,21,2,1],[52899,530,18,9,0,2,1],[52901,600,23,5,0,1,1],[52903,95,28,3,0,3,2],[52904,372,59,3,0,1,2],[52907,65,23,1,0,4,1],[52908,65,23,1,0,4,1],[52909,65,23,1,0,4,1],[52912,224,23,1,7,3,1],[52933,27,26,3,0,1,2],[52935,167,30,1,5,4,1],[52937,403,78,1,0,1,1],[52941,350,1,2,0,4,1],[52942,350,1,2,0,4,2],[52984,61,17,11,0,5,4],[53100,322,23,3,0,3,2],[53102,8,1,1,0,4,1],[53128,520,23,1,7,1,1],[53129,520,23,1,7,1,1],[53316,65,23,1,5,4,1],[53318,65,23,1,5,4,1],[53352,8,1,2,0,4,1],[53355,9,1,1,1,4,2],[53370,331,66,1,5,4,1],[53371,459,17,2,0,2,1],[53372,459,17,2,0,2,1],[53586,117,23,2,0,3,1],[53698,65,23,1,7,4,1],[53757,635,23,3,52,1,1],[53758,635,23,3,52,1,1],[53760,88,23,1,0,4,1],[53761,88,23,1,0,4,1],[53763,88,23,1,0,4,1],[53765,13,5,3,0,1,2],[53808,77,1,3,0,1,1],[53813,509,23,3,0,1,2],[53815,559,15,1,7,1,1],[53876,117,23,2,0,3,1],[53877,463,23,1,4,3,1],[53883,8,1,1,5,4,1],[53884,88,23,2,0,3,1],[53885,88,23,2,0,3,1],[53943,65,23,1,5,4,1],[53946,65,23,1,5,4,1],[53947,65,23,1,5,4,1],[53948,463,23,2,0,3,1],[53949,510,23,2,0,3,1],[53950,463,23,2,0,3,1],[53960,27,26,3,0,1,2],[53961,27,26,3,0,1,2],[54020,634,23,2,0,3,1],[54021,634,23,2,0,3,1],[54026,221,1,3,0,1,2],[54027,221,1,3,0,1,2],[54029,117,23,5,0,3,1],[54031,61,17,11,0,5,4],[54033,34,19,3,0,3,1],[54035,285,23,1,0,3,1],[54042,65,23,1,5,4,1],[54043,65,23,1,5,4,1],[54045,65,23,1,5,4,1],[54048,13,5,3,0,1,2],[54109,61,17,22,0,4,1],[54110,61,17,22,0,4,1],[54113,427,2,3,0,1,1],[54114,427,2,3,0,1,1],[54115,427,2,3,0,1,1],[54116,427,2,3,0,1,1],[54117,427,2,3,0,1,1],[54118,427,2,3,0,1,1],[54119,427,2,3,0,1,1],[54120,427,2,3,0,1,1],[54121,427,2,3,0,1,1],[54122,427,2,3,0,1,1],[54123,427,2,3,0,1,1],[54124,427,2,3,0,1,1],[54125,427,2,3,0,1,1],[54126,427,2,3,0,1,1],[54127,427,2,3,0,1,1],[54128,427,2,3,0,1,1],[54129,427,2,3,0,1,1],[54130,427,2,3,0,1,1],[54131,427,2,3,0,1,1],[54132,427,2,3,0,1,1],[54133,427,2,3,0,1,1],[54134,427,2,3,0,1,1],[54135,427,2,3,0,1,1],[54136,427,2,3,0,1,1],[54137,427,2,3,0,1,1],[54138,427,2,3,0,1,1],[54139,427,2,3,0,1,1],[54140,427,2,3,0,1,1],[54141,427,2,3,0,1,1],[54142,427,2,3,0,1,1],[54143,427,2,3,0,1,1],[54144,427,2,3,0,1,1],[54145,427,2,3,0,1,1],[54146,427,2,3,0,1,1],[54147,427,2,3,0,1,1],[54148,427,2,3,0,1,1],[54225,13,5,3,0,1,2],[54642,427,2,3,0,1,1],[54643,427,2,3,0,1,1],[54644,427,2,3,0,1,1],[54645,427,2,3,0,1,1],[54646,427,2,3,0,1,1],[54647,427,2,3,0,1,1],[54648,427,2,3,0,1,1],[54649,427,2,3,0,1,1],[54650,427,2,3,0,1,1],[54651,427,2,3,0,1,1],[54652,427,2,3,0,1,1],[54653,427,2,3,0,1,1],[54654,427,2,3,0,1,1],[54655,427,2,3,0,1,1],[54656,427,2,3,0,1,1],[54657,427,2,3,0,1,1],[54658,427,2,3,0,1,1],[54659,427,2,3,0,1,1],[54660,427,2,3,0,1,1],[54661,427,2,3,0,1,1],[54662,427,2,3,0,1,1],[54663,427,2,3,0,1,1],[54664,427,2,3,0,1,1],[54665,427,2,3,0,1,1],[54666,427,2,3,0,1,1],[54667,427,2,3,0,1,1],[54668,427,2,3,0,1,1],[54669,427,2,3,0,1,1],[54670,427,2,3,0,1,1],[54671,427,2,3,0,1,1],[54672,427,2,3,0,1,1],[54673,427,2,3,0,1,1],[54674,427,2,3,0,1,1],[54675,427,2,3,0,1,1],[54676,427,2,3,0,1,1],[54677,427,2,3,0,1,1],[54678,427,2,3,0,1,1],[54679,427,2,3,0,1,1],[54680,427,2,3,0,1,1],[54681,427,2,3,0,1,1],[54940,358,23,5,36,2,1],[55009,554,93,9,0,1,1],[55010,374,11,1,39,1,1],[55011,83,38,1,11,1,1],[55012,129,25,3,16,1,1],[55013,352,26,1,35,1,1],[55014,352,26,1,35,1,1],[55015,71,10,1,9,4,1],[55017,352,26,1,35,1,1],[55018,340,26,1,0,1,1],[55019,340,26,1,0,1,1],[55020,164,1,1,5,1,1],[55021,164,1,1,5,1,1],[55022,164,1,1,5,1,1],[55023,164,1,1,5,1,1],[55024,164,1,1,5,1,1],[55025,164,1,1,5,1,1],[55026,164,1,1,5,1,1],[55027,164,1,1,5,1,1],[55028,164,1,1,5,1,1],[55029,164,1,1,5,1,1],[55030,164,1,1,5,1,1],[55031,164,1,1,5,1,1],[55032,164,1,1,5,1,1],[55033,164,1,1,5,1,1],[55034,366,1,3,0,1,1],[55035,164,1,1,5,1,1],[55036,617,1,1,7,1,1],[55037,352,26,1,35,1,1],[55038,352,26,1,35,1,1],[55039,164,1,1,5,1,1],[55040,164,1,1,5,1,1],[55041,524,39,9,0,1,1],[55042,164,1,1,5,1,1],[55043,164,1,1,5,1,1],[55044,556,4,1,2,1,1],[55045,89,21,1,0,1,1],[55046,366,1,3,0,1,1],[55047,89,21,1,0,1,1],[55048,89,21,1,0,1,1],[55049,290,3,1,7,1,1],[55050,617,1,1,7,1,1],[55052,524,39,9,0,1,1],[55053,186,1,1,13,1,1],[55054,524,39,9,0,1,1],[55055,164,1,1,5,1,1],[55056,629,1,2,0,1,1],[55057,164,1,1,5,1,1],[55058,524,39,9,0,1,1],[55061,524,39,9,0,1,1],[55062,290,3,1,7,1,1],[55063,164,1,1,5,1,1],[55064,89,21,1,0,1,1],[55065,164,1,1,5,1,1],[55066,164,1,1,5,1,1],[55067,421,6,2,0,1,1],[55068,164,1,1,5,1,1],[55069,164,1,1,5,1,1],[55070,164,1,1,5,1,1],[55071,164,1,1,5,1,1],[55072,553,15,5,0,1,1],[55073,574,6,5,0,2,1],[55074,164,1,1,5,1,1],[55075,164,1,1,5,1,1],[55076,651,1,9,0,1,1],[55077,164,1,1,5,1,1],[55078,164,1,1,5,1,1],[55079,164,1,1,5,1,1],[55080,164,1,1,5,1,1],[55081,458,85,2,0,2,1],[55082,164,1,1,5,1,1],[55083,164,1,1,5,1,1],[55084,223,38,2,0,1,1],[55085,340,26,1,0,1,1],[55086,340,26,1,0,1,1],[55087,538,1,3,0,1,1],[55088,352,26,1,35,1,1],[55089,538,1,3,0,1,1],[55090,115,21,9,0,1,1],[55091,538,1,3,0,1,1],[55092,538,1,3,0,1,1],[55093,71,10,1,9,4,1],[55094,538,1,3,0,1,1],[55095,538,1,3,0,1,1],[55096,538,1,3,0,1,1],[55097,538,1,3,0,1,1],[55098,63,33,3,8,2,1],[55099,538,1,3,0,1,1],[55100,538,1,3,0,1,1],[55101,538,1,3,0,1,1],[55102,538,1,3,0,1,1],[55103,342,68,2,0,2,1],[55107,2,2,1,2,1,1],[55108,659,30,2,0,1,1],[55133,315,23,2,26,1,1],[55134,592,23,28,0,1,1],[55135,592,23,28,0,1,1],[55136,593,23,2,0,1,1],[55137,396,23,2,0,2,1],[55138,583,1,2,0,4,2],[55140,427,2,3,0,1,1],[55141,427,2,3,0,1,1],[55142,427,2,3,0,1,1],[55143,427,2,3,0,1,1],[55144,427,2,3,0,1,1],[55145,427,2,3,0,1,1],[55146,427,2,3,0,1,1],[55147,427,2,3,0,1,1],[55148,427,2,3,0,1,1],[55149,427,2,3,0,1,1],[55150,427,2,3,0,1,1],[55151,427,2,3,0,1,1],[55152,427,2,3,0,1,1],[55153,427,2,3,0,1,1],[55154,427,2,3,0,1,1],[55155,427,2,3,0,1,1],[55156,427,2,3,0,1,1],[55157,427,2,3,0,1,1],[55158,427,2,3,0,1,1],[55159,427,2,3,0,1,1],[55160,427,2,3,0,1,1],[55161,427,2,3,0,1,1],[55162,427,2,3,0,1,1],[55163,427,2,3,0,1,1],[55164,427,2,3,0,1,1],[55165,427,2,3,0,1,1],[55166,427,2,3,0,1,1],[55167,427,2,3,0,1,1],[55168,427,2,3,0,1,1],[55169,427,2,3,0,1,1],[55239,37,23,3,0,1,2],[55242,512,23,2,0,3,1],[55243,512,23,2,0,3,1],[55244,65,23,1,5,4,1],[55248,468,23,1,5,3,1],[55249,468,23,1,5,3,1],[55250,365,23,1,5,2,1],[55251,315,23,1,5,1,1],[55252,315,23,1,5,1,1],[55253,315,23,1,5,1,1],[55254,315,23,1,5,1,1],[55255,315,23,1,5,1,1],[55256,315,23,1,5,1,1],[55257,68,23,1,7,1,1],[55258,318,23,1,5,1,1],[55259,318,23,1,5,1,1],[55260,318,23,1,5,1,1],[55261,604,23,2,0,3,1],[55263,9,1,3,0,4,2],[55264,350,1,2,0,4,2],[55268,399,1,11,0,5,4],[55324,263,1,22,3,1,1],[55326,263,1,22,3,1,1],[55327,263,1,22,3,1,1],[55329,291,15,1,7,3,1],[55506,180,17,1,6,3,2],[55508,25,11,3,0,1,2],[55562,95,28,9,0,3,1],[55683,293,2,3,0,1,2],[55686,509,23,3,0,1,2],[55690,281,48,1,26,3,1],[55801,427,2,3,0,1,1],[55802,427,2,3,0,1,1],[55803,427,2,3,0,1,1],[55804,427,2,3,0,1,1],[55805,427,2,3,0,1,1],[55806,427,2,3,0,1,1],[55807,427,2,3,0,1,1],[55808,427,2,3,0,1,1],[55809,427,2,3,0,1,1],[55810,427,2,3,0,1,1],[55811,427,2,3,0,1,1],[55812,427,2,3,0,1,1],[55813,427,2,3,0,1,1],[55814,427,2,3,0,1,1],[55815,427,2,3,0,1,1],[55816,427,2,3,0,1,1],[55817,427,2,3,0,1,1],[55818,427,2,3,0,1,1],[55819,427,2,3,0,1,1],[55820,427,2,3,0,1,1],[55821,427,2,3,0,1,1],[55822,427,2,3,0,1,1],[55823,427,2,3,0,1,1],[55824,427,2,3,0,1,1],[55825,427,2,3,0,1,1],[55826,427,2,3,0,1,1],[55827,427,2,3,0,1,1],[55828,427,2,3,0,1,1],[55829,427,2,3,0,1,1],[55830,427,2,3,0,1,1],[55836,322,23,1,5,3,1],[55839,322,23,1,5,3,1],[55841,361,17,3,0,3,2],[55844,281,48,1,26,3,1],[55861,511,23,2,0,3,1],[55909,94,1,1,12,1,1],[55910,94,1,1,12,1,1],[55912,224,23,1,5,3,2],[55970,27,26,3,0,1,2],[55971,27,26,3,0,1,2],[55973,592,23,28,0,1,1],[55974,592,23,28,0,1,1],[55975,592,23,28,0,1,1],[55976,592,23,28,0,1,1],[55978,61,17,1,3,4,1],[55982,75,1,1,5,1,1],[55983,75,1,1,5,1,1],[56055,427,2,3,0,1,1],[56056,427,2,3,0,1,1],[56057,427,2,3,0,1,1],[56058,427,2,3,0,1,1],[56059,427,2,3,0,1,1],[56060,427,2,3,0,1,1],[56061,427,2,3,0,1,1],[56062,427,2,3,0,1,1],[56063,427,2,3,0,1,1],[56064,427,2,3,0,1,1],[56065,427,2,3,0,1,1],[56066,427,2,3,0,1,1],[56067,427,2,3,0,1,1],[56068,427,2,3,0,1,1],[56069,427,2,3,0,1,1],[56070,427,2,3,0,1,1],[56071,427,2,3,0,1,1],[56072,427,2,3,0,1,1],[56073,427,2,3,0,1,1],[56074,427,2,3,0,1,1],[56075,427,2,3,0,1,1],[56076,427,2,3,0,1,1],[56077,427,2,3,0,1,1],[56078,427,2,3,0,1,1],[56079,427,2,3,0,1,1],[56080,427,2,3,0,1,1],[56081,427,2,3,0,1,1],[56083,61,6,1,7,4,1],[56091,61,17,14,0,4,1],[56153,277,39,1,7,1,1],[56154,277,39,1,7,1,1],[56155,277,39,1,7,1,1],[56156,277,39,1,7,1,1],[56157,65,23,1,7,4,1],[56162,62,1,9,0,4,1],[56163,62,1,9,0,4,1],[56164,62,1,9,0,4,1],[56165,62,1,9,0,4,1],[56166,62,1,9,0,4,1],[56167,62,1,9,0,4,1],[56168,62,1,9,0,4,1],[56169,62,1,9,0,4,1],[56170,62,1,9,0,4,1],[56171,62,1,9,0,4,1],[56174,221,1,3,0,1,2],[56178,292,25,1,13,2,1],[56179,84,2,2,0,1,1],[56180,153,12,1,5,3,1],[56181,332,25,3,2,10,1],[56182,352,1,1,35,1,1],[56183,497,1,3,30,1,1],[56184,551,5,2,0,2,1],[56185,83,38,1,11,1,1],[56186,239,20,4,0,1,1],[56187,7,7,4,0,2,1],[56188,122,1,5,0,2,1],[56189,308,1,2,0,1,1],[56190,89,21,1,0,1,1],[56191,263,1,22,3,1,1],[56193,263,1,22,3,1,1],[56194,417,10,1,2,3,1],[56195,240,1,1,21,1,1],[56196,629,1,2,0,1,1],[56197,240,1,1,21,1,1],[56198,617,1,1,7,1,1],[56199,608,1,2,51,1,1],[56201,89,21,1,0,1,1],[56202,89,21,1,0,1,1],[56203,89,21,1,0,1,1],[56204,239,20,4,0,1,1],[56205,205,52,1,5,4,1],[56206,352,1,1,35,1,1],[56207,568,97,1,13,3,1],[56208,455,38,2,0,1,1],[56209,239,20,4,0,1,1],[56210,129,25,1,13,1,1],[56211,297,38,14,0,3,1],[56212,489,88,2,0,2,1],[56213,494,1,1,5,2,1],[56214,344,9,3,30,1,1],[56215,624,16,9,0,1,1],[56216,183,38,9,0,1,1],[56217,329,20,3,0,1,1],[56218,329,20,3,0,1,1],[56219,12,1,4,0,1,1],[56220,12,1,4,0,1,1],[56221,304,44,5,0,3,1],[56222,158,4,5,0,2,1],[56223,631,86,9,0,1,1]]} diff --git a/src/data/ucsSatellites.js b/src/data/ucsSatellites.js index e3c67a2e2..5a87a2bd4 100644 --- a/src/data/ucsSatellites.js +++ b/src/data/ucsSatellites.js @@ -1,6 +1,6 @@ /** - * @file The bundled UCS Satellite Database pack: the purpose of each active - * satellite, keyed by NORAD catalog number. + * @file The bundled UCS Satellite Database pack: operator, owner country and + * purpose for active satellites, keyed by NORAD catalog number. * * Data: `local_data/ucs_satellites/ucs_satellites.json`, built by * `scripts/build-ucs-satellites.py` from UCS-Satellite-Database 5-1-2023 @@ -20,8 +20,11 @@ const PACK_URL = new URL( ); /** - * One UCS row: what UCS says a satellite is for. - * @typedef {{noradId: number, purpose: string|null}} UcsSatellite + * One UCS row: what UCS says about a satellite's operator and purpose. + * @typedef {{noradId: number, operator: string|null, + * operatorCountry: string|null, purpose: string|null, + * detailedPurpose: string|null, users: string|null, + * orbitClass: string|null}} UcsSatellite */ /** diff --git a/src/data/ucsSatellites.test.mjs b/src/data/ucsSatellites.test.mjs index 3b52a4e92..86df68e81 100644 --- a/src/data/ucsSatellites.test.mjs +++ b/src/data/ucsSatellites.test.mjs @@ -22,7 +22,15 @@ test('the bundled UCS pack names its release, licence and curation', () => { pack.meta.curation.invalidNoradDropped + pack.meta.curation.duplicateNoradDropped, ); - assert.deepEqual(pack.fields, ['noradId', 'purpose']); + assert.deepEqual(pack.fields, [ + 'noradId', + 'operator', + 'operatorCountry', + 'purpose', + 'detailedPurpose', + 'users', + 'orbitClass', + ]); // Small enough to load with the layer. assert.ok(readFileSync(PACK_URL).length < 200_000); }); @@ -33,26 +41,36 @@ test('the pack loads into records keyed by NORAD number', async () => { const iss = records.get(25544); assert.ok(iss, 'the ISS is in UCS'); assert.equal(iss.noradId, 25544); + assert.ok(iss.operator); assert.ok(iss.purpose); + for (const record of records.values()) { + assert.doesNotMatch( + `${record.operator ?? ''} ${record.purpose ?? ''}`, + /starlink/i, + 'Starlink rows are dropped', + ); + if (record.orbitClass) assert.match(record.orbitClass, /^(LEO|MEO|GEO|ELLIPTICAL)$/); + } }); test('indexing reads the string tables and turns empty strings into null', () => { const records = indexUcsPack({ - fields: ['noradId', 'purpose'], - strings: { purpose: ['', 'Earth Observation'] }, + fields: ['noradId', 'operator', 'purpose'], + strings: { operator: ['', 'ESA'], purpose: ['', 'Earth Observation'] }, records: [ - [39634, 1], - [40000, 0], - ['bad', 1], - [-5, 1], + [39634, 1, 1], + [40000, 0, 1], + ['bad', 1, 1], + [-5, 1, 1], ], }); assert.deepEqual([...records.keys()], [39634, 40000]); assert.deepEqual(records.get(39634), { noradId: 39634, + operator: 'ESA', purpose: 'Earth Observation', }); - assert.equal(records.get(40000).purpose, null); + assert.equal(records.get(40000).operator, null); }); test('a pack of the wrong shape throws, so the loader tries again', () => { diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index a7fd060a5..bb20e646a 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -124,6 +124,8 @@ export function createCatalog({ state: layerState, services, parts, source }) { satrec, group: 'dense', launchYear: tleLineLaunchYear(entry.line1), + line1: entry.line1, + line2: entry.line2, // The dense feed is CelesTrak's Starlink group. satelliteClass: 'starlink', ucs: null, diff --git a/src/layers/satellites/classes.js b/src/layers/satellites/classes.js index dea3dcc38..f4d47a0ea 100644 --- a/src/layers/satellites/classes.js +++ b/src/layers/satellites/classes.js @@ -231,6 +231,13 @@ export function classifySatellite({ return 'probes'; } +/** Whether a class may be ridden in the Cockpit's orbital view. */ +export function satelliteClassEnterable(satelliteClass) { + return ( + SATELLITE_CLASS_IDS.includes(satelliteClass) && satelliteClass !== 'debris' + ); +} + /** * Merge a partial class-visibility request into the current choice. * Unknown keys and non-boolean values are ignored. @@ -250,6 +257,443 @@ export function mergeClassVisibility( return next; } +// ISO 3166-1 alpha-2 code → short English name, for every owner below. +const COUNTRY_NAMES = Object.freeze({ + AE: 'United Arab Emirates', + AM: 'Armenia', + AO: 'Angola', + AR: 'Argentina', + AT: 'Austria', + AU: 'Australia', + AZ: 'Azerbaijan', + BD: 'Bangladesh', + BE: 'Belgium', + BG: 'Bulgaria', + BH: 'Bahrain', + BM: 'Bermuda', + BO: 'Bolivia', + BR: 'Brazil', + BT: 'Bhutan', + BW: 'Botswana', + BY: 'Belarus', + CA: 'Canada', + CH: 'Switzerland', + CL: 'Chile', + CN: 'China', + CO: 'Colombia', + CR: 'Costa Rica', + CZ: 'Czechia', + DE: 'Germany', + DJ: 'Djibouti', + DK: 'Denmark', + DZ: 'Algeria', + EC: 'Ecuador', + EE: 'Estonia', + EG: 'Egypt', + ES: 'Spain', + ET: 'Ethiopia', + FI: 'Finland', + FR: 'France', + GB: 'United Kingdom', + GH: 'Ghana', + GR: 'Greece', + GT: 'Guatemala', + HR: 'Croatia', + HU: 'Hungary', + ID: 'Indonesia', + IE: 'Ireland', + IL: 'Israel', + IN: 'India', + IQ: 'Iraq', + IR: 'Iran', + IT: 'Italy', + JO: 'Jordan', + JP: 'Japan', + KE: 'Kenya', + KP: 'North Korea', + KR: 'South Korea', + KW: 'Kuwait', + KZ: 'Kazakhstan', + LA: 'Laos', + LK: 'Sri Lanka', + LT: 'Lithuania', + LU: 'Luxembourg', + MA: 'Morocco', + MC: 'Monaco', + MD: 'Moldova', + ME: 'Montenegro', + MM: 'Myanmar', + MN: 'Mongolia', + MU: 'Mauritius', + MX: 'Mexico', + MY: 'Malaysia', + NG: 'Nigeria', + NL: 'Netherlands', + NO: 'Norway', + NP: 'Nepal', + NZ: 'New Zealand', + PE: 'Peru', + PH: 'Philippines', + PK: 'Pakistan', + PL: 'Poland', + PT: 'Portugal', + PY: 'Paraguay', + QA: 'Qatar', + RO: 'Romania', + RU: 'Russia', + RW: 'Rwanda', + SA: 'Saudi Arabia', + SB: 'Solomon Islands', + SD: 'Sudan', + SE: 'Sweden', + SG: 'Singapore', + SI: 'Slovenia', + SN: 'Senegal', + TH: 'Thailand', + TM: 'Turkmenistan', + TN: 'Tunisia', + TR: 'Türkiye', + TW: 'Taiwan', + UA: 'Ukraine', + US: 'United States', + UY: 'Uruguay', + VA: 'Vatican City', + VE: 'Venezuela', + VN: 'Vietnam', + ZA: 'South Africa', + ZW: 'Zimbabwe', +}); + +// UCS "Country of Operator/Owner" → ISO code. Joint owners ("USA/Japan") and +// organisations are not countries and resolve through ORGANISATIONS or as a +// joint label. +const UCS_COUNTRIES = Object.freeze({ + Algeria: 'DZ', + Angola: 'AO', + Argentina: 'AR', + Australia: 'AU', + Austria: 'AT', + Azerbaijan: 'AZ', + Bangladesh: 'BD', + Belarus: 'BY', + Belgium: 'BE', + Bolivia: 'BO', + Brazil: 'BR', + Bulgaria: 'BG', + Canada: 'CA', + Chile: 'CL', + China: 'CN', + Colombia: 'CO', + 'Czech Republic': 'CZ', + Denmark: 'DK', + Ecuador: 'EC', + Egypt: 'EG', + Estonia: 'EE', + Ethiopia: 'ET', + Finland: 'FI', + France: 'FR', + Germany: 'DE', + Greece: 'GR', + Hungary: 'HU', + India: 'IN', + Indonesia: 'ID', + Iran: 'IR', + Iraq: 'IQ', + Israel: 'IL', + Italy: 'IT', + Japan: 'JP', + Jordan: 'JO', + Kazakhstan: 'KZ', + Kenya: 'KE', + Kuwait: 'KW', + Laos: 'LA', + Lithuania: 'LT', + Luxembourg: 'LU', + Malaysia: 'MY', + Mexico: 'MX', + Monaco: 'MC', + Morocco: 'MA', + Nepal: 'NP', + Netherlands: 'NL', + 'New Zealand': 'NZ', + Nigeria: 'NG', + Norway: 'NO', + Pakistan: 'PK', + Peru: 'PE', + Poland: 'PL', + Russia: 'RU', + 'Saudi Arabia': 'SA', + Sinapore: 'SG', + Singapore: 'SG', + Slovenia: 'SI', + 'South Africa': 'ZA', + 'South Korea': 'KR', + Spain: 'ES', + Sudan: 'SD', + Sweden: 'SE', + Switzerland: 'CH', + Taiwan: 'TW', + Thailand: 'TH', + Tunisia: 'TN', + Turkey: 'TR', + UK: 'GB', + USA: 'US', + Ukraine: 'UA', + 'United Arab Emirates': 'AE', + 'United Kingdom': 'GB', + Venezuela: 'VE', + Vietnam: 'VN', +}); + +// CelesTrak SATCAT OWNER code → ISO code (https://celestrak.org/satcat/sources.php). +// CIS (former USSR) is filed under Russia, its continuing state. +const SATCAT_COUNTRIES = Object.freeze({ + ALG: 'DZ', + ANG: 'AO', + ARGN: 'AR', + ARM: 'AM', + ASRA: 'AT', + AUS: 'AU', + AZER: 'AZ', + BEL: 'BE', + BELA: 'BY', + BERM: 'BM', + BGD: 'BD', + BHR: 'BH', + BHUT: 'BT', + BOL: 'BO', + BRAZ: 'BR', + BUL: 'BG', + BWA: 'BW', + CA: 'CA', + CHLE: 'CL', + CIS: 'RU', + COL: 'CO', + CRI: 'CR', + CZCH: 'CZ', + DEN: 'DK', + DJI: 'DJ', + ECU: 'EC', + EGYP: 'EG', + EST: 'EE', + ETH: 'ET', + FIN: 'FI', + FR: 'FR', + GER: 'DE', + GHA: 'GH', + GREC: 'GR', + GUAT: 'GT', + HRV: 'HR', + HUN: 'HU', + IND: 'IN', + INDO: 'ID', + IRAN: 'IR', + IRAQ: 'IQ', + IRL: 'IE', + ISRA: 'IL', + ISRO: 'IN', + IT: 'IT', + JPN: 'JP', + KAZ: 'KZ', + KEN: 'KE', + LAOS: 'LA', + LKA: 'LK', + LTU: 'LT', + LUXE: 'LU', + MA: 'MA', + MALA: 'MY', + MCO: 'MC', + MDA: 'MD', + MEX: 'MX', + MMR: 'MM', + MNE: 'ME', + MNG: 'MN', + MUS: 'MU', + NETH: 'NL', + NIG: 'NG', + NKOR: 'KP', + NOR: 'NO', + NPL: 'NP', + NZ: 'NZ', + PAKI: 'PK', + PERU: 'PE', + POL: 'PL', + POR: 'PT', + PRC: 'CN', + PRY: 'PY', + QAT: 'QA', + ROC: 'TW', + ROM: 'RO', + RP: 'PH', + RWA: 'RW', + SAFR: 'ZA', + SAUD: 'SA', + SDN: 'SD', + SEN: 'SN', + SING: 'SG', + SKOR: 'KR', + SLB: 'SB', + SPN: 'ES', + SVN: 'SI', + SWED: 'SE', + SWTZ: 'CH', + THAI: 'TH', + TUN: 'TN', + TURK: 'TR', + UAE: 'AE', + UK: 'GB', + UKR: 'UA', + URY: 'UY', + US: 'US', + VAT: 'VA', + VENZ: 'VE', + VTNM: 'VN', + ZWE: 'ZW', +}); + +// Organisations, by SATCAT code and by UCS name: no flag, a short label. +const ORGANISATIONS = Object.freeze({ + AB: 'Arabsat', + ABS: 'ABS', + AC: 'AsiaSat', + ESA: 'ESA', + ESRO: 'ESRO', + EUME: 'EUMETSAT', + EUTE: 'Eutelsat', + GLOB: 'Globalstar', + IM: 'Inmarsat', + IRID: 'Iridium', + ISS: 'ISS partners', + ITSO: 'Intelsat', + NATO: 'NATO', + NICO: 'New ICO', + O3B: 'O3b', + ORB: 'ORBCOMM', + RASC: 'RascomStar-QAF', + SEAL: 'Sea Launch', + SES: 'SES', + Multinational: 'Multinational', +}); + +// SATCAT codes for joint ownership, as their parts. +const SATCAT_JOINT = Object.freeze({ + CHBZ: ['CN', 'BR'], + CHTU: ['CN', 'TR'], + FGER: ['FR', 'DE'], + FRIT: ['FR', 'IT'], + GRSA: ['GR', 'SA'], + PRES: ['CN', 'ESA'], + SGJP: ['SG', 'JP'], + STCT: ['SG', 'TW'], + TMMC: ['TM', 'MC'], + USBZ: ['US', 'BR'], +}); + +function country(code) { + return COUNTRY_NAMES[code] + ? { countryCode: code, label: COUNTRY_NAMES[code] } + : null; +} + +function joint(parts) { + const labels = parts.map( + (part) => COUNTRY_NAMES[part] || ORGANISATIONS[part] || part, + ); + return { countryCode: null, label: labels.join(' / ') }; +} + +/** + * Normalise a UCS owner country ("USA", "China/France", "ESA", + * "Multinational") to `{ countryCode, label }`. + * @param {string|null|undefined} text + * @returns {{countryCode: string|null, label: string}|null} + */ +export function ownerFromUcs(text) { + const value = String(text || '').trim(); + if (!value) return null; + if (UCS_COUNTRIES[value]) return country(UCS_COUNTRIES[value]); + if (ORGANISATIONS[value]) + return { countryCode: null, label: ORGANISATIONS[value] }; + if (value.includes('/')) + return joint( + value + .split('/') + .map((part) => part.trim()) + .map((part) => UCS_COUNTRIES[part] || part), + ); + return { countryCode: null, label: value }; +} + +/** + * Normalise a CelesTrak SATCAT OWNER code ("US", "PRC", "CIS", "ITSO") to + * `{ countryCode, label }`; null for TBD, UNK and blanks. + * @param {string|null|undefined} code + * @returns {{countryCode: string|null, label: string}|null} + */ +export function ownerFromSatcat(code) { + const value = String(code || '') + .trim() + .toUpperCase(); + if (!value || value === 'TBD' || value === 'UNK') return null; + if (SATCAT_COUNTRIES[value]) return country(SATCAT_COUNTRIES[value]); + if (ORGANISATIONS[value]) + return { countryCode: null, label: ORGANISATIONS[value] }; + if (SATCAT_JOINT[value]) return joint(SATCAT_JOINT[value]); + return null; +} + +/** + * Who owns a satellite: UCS names the operator's country, SATCAT the + * registering owner; UCS wins where both exist. Starlink without either is + * SpaceX's, so United States. + * @returns {{countryCode: string|null, label: string}|null} + */ +export function satelliteOwner({ + ucs = null, + satcat = null, + satelliteClass, +} = {}) { + return ( + ownerFromUcs(ucs?.operatorCountry) || + ownerFromSatcat(satcat?.owner) || + (satelliteClass === 'starlink' ? country('US') : null) + ); +} + +/** Who operates a satellite: UCS's operator, or SpaceX for Starlink. */ +export function satelliteOperator({ ucs = null, satelliteClass } = {}) { + if (ucs?.operator) return ucs.operator; + return satelliteClass === 'starlink' ? 'SpaceX' : null; +} + +/** + * The international designator (COSPAR id) from TLE line 1, columns 10–17: + * `98067A` → `1998-067A`. Two-digit years below 57 are 20xx. + * @param {string|null|undefined} line1 + * @returns {string|null} + */ +export function cosparIdFromTleLine1(line1) { + const field = String(line1 || '') + .slice(9, 17) + .trim(); + const match = /^(\d{2})(\d{3})([A-Z]{1,3})$/.exec(field); + if (!match) return null; + const yy = Number(match[1]); + return `${yy < 57 ? 2000 + yy : 1900 + yy}-${match[2]}${match[3]}`; +} + +/** + * The TLE's epoch as epoch milliseconds, from satellite.js's Julian date. + * @param {{jdsatepoch?: number, jdsatepochF?: number}|null|undefined} satrec + * @returns {number|null} + */ +export function tleEpochMs(satrec) { + const jd = Number(satrec?.jdsatepoch) + (Number(satrec?.jdsatepochF) || 0); + return Number.isFinite(jd) && jd > 0 + ? Math.round((jd - 2440587.5) * 86_400_000) + : null; +} + /** Split one RFC 4180 CSV line (quoted fields may hold commas). */ function csvFields(line) { const fields = []; @@ -277,7 +721,7 @@ function csvFields(line) { * Parse CelesTrak SATCAT CSV into records keyed by NORAD number. * @param {string} text `records.php?…&FORMAT=CSV` body. * @returns {Map} + * owner: string, cosparId: string|null, launchDate: string|null}>} */ export function parseSatcatCsv(text) { const records = new Map(); @@ -291,6 +735,8 @@ export function parseSatcatCsv(text) { norad: at('NORAD_CAT_ID'), type: at('OBJECT_TYPE'), status: at('OPS_STATUS_CODE'), + owner: at('OWNER'), + id: at('OBJECT_ID'), launch: at('LAUNCH_DATE'), }; if (col.norad < 0 || col.type < 0) return records; @@ -302,6 +748,8 @@ export function parseSatcatCsv(text) { records.set(noradId, { objectType: text(col.type).toUpperCase(), opsStatus: text(col.status), + owner: text(col.owner), + cosparId: text(col.id) || null, launchDate: text(col.launch) || null, }); } diff --git a/src/layers/satellites/classes.test.mjs b/src/layers/satellites/classes.test.mjs index 40346b949..6243181f9 100644 --- a/src/layers/satellites/classes.test.mjs +++ b/src/layers/satellites/classes.test.mjs @@ -1,20 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { twoline2satrec } from 'satellite.js'; import { DEFAULT_SATELLITE_CLASSES, PRESUMED_DEAD_AFTER_YEARS, SATELLITE_CLASS_DISPLAY, SATELLITE_CLASS_IDS, classifySatellite, + cosparIdFromTleLine1, mergeClassVisibility, + ownerFromSatcat, + ownerFromUcs, parseSatcatCsv, + satelliteClassEnterable, + satelliteOperator, + satelliteOwner, + tleEpochMs, } from './classes.js'; import { CATALOG_GROUPS } from './policy.js'; const NOW = 2026; const classify = (subject) => classifySatellite({ nowYear: NOW, ...subject }); -test('six classes, every one but Starlink on by default', () => { +test('six classes, with stations and imaging on by default and the rest off', () => { assert.deepEqual(SATELLITE_CLASS_IDS, [ 'stations', 'imaging', @@ -37,28 +45,16 @@ test('six classes, every one but Starlink on by default', () => { test('SATCAT object type and status file debris first, overruling UCS', () => { const imaging = { purpose: 'Earth Observation' }; for (const objectType of ['R/B', 'DEB', 'UNK']) - assert.equal( - classify({ name: 'X', satcat: { objectType, opsStatus: '' } }), - 'debris', - objectType, - ); + assert.equal(classify({ name: 'X', satcat: { objectType, opsStatus: '' } }), 'debris', objectType); // A satellite UCS listed in 2023 that SATCAT now calls dead or decayed. for (const opsStatus of ['-', 'D']) assert.equal( - classify({ - name: 'OLDSAT', - ucs: imaging, - satcat: { objectType: 'PAY', opsStatus }, - }), + classify({ name: 'OLDSAT', ucs: imaging, satcat: { objectType: 'PAY', opsStatus } }), 'debris', opsStatus, ); assert.equal( - classify({ - name: 'OLDSAT', - ucs: imaging, - satcat: { objectType: 'PAY', opsStatus: '+' }, - }), + classify({ name: 'OLDSAT', ucs: imaging, satcat: { objectType: 'PAY', opsStatus: '+' } }), 'imaging', ); // SATCAT calls a new launch's objects UNK until it names them: only an @@ -86,33 +82,15 @@ test('SATCAT object type and status file debris first, overruling UCS', () => { test('the ISS is a station whatever else says', () => { assert.equal( - classify({ - noradId: 25544, - name: 'ISS (ZARYA)', - group: 'visual', - satcat: { objectType: 'DEB' }, - }), + classify({ noradId: 25544, name: 'ISS (ZARYA)', group: 'visual', satcat: { objectType: 'DEB' } }), 'stations', ); }); test('stations: crewed stations and visiting vehicles by name', () => { - assert.equal( - classify({ name: 'CSS (TIANHE)', group: 'stations' }), - 'stations', - ); - assert.equal( - classify({ name: 'ISS (NAUKA)', group: 'stations' }), - 'stations', - ); - for (const name of [ - 'SHENZHOU-21', - 'TIANZHOU-9', - 'SOYUZ-MS 28', - 'PROGRESS-MS 31', - 'CREW DRAGON 11', - 'CYGNUS NG-23', - ]) + assert.equal(classify({ name: 'CSS (TIANHE)', group: 'stations' }), 'stations'); + assert.equal(classify({ name: 'ISS (NAUKA)', group: 'stations' }), 'stations'); + for (const name of ['SHENZHOU-21', 'TIANZHOU-9', 'SOYUZ-MS 28', 'PROGRESS-MS 31', 'CREW DRAGON 11', 'CYGNUS NG-23']) assert.equal(classify({ name, group: 'visual' }), 'stations', name); }); @@ -133,11 +111,7 @@ test('UCS purpose decides imaging, telecom and probes', () => { ['Unknown', 'probes'], ]; for (const [purpose, expected] of table) - assert.equal( - classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }), - expected, - purpose, - ); + assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }), expected, purpose); }); test('satellites launched after the UCS snapshot are classed by name', () => { @@ -156,42 +130,20 @@ test('satellites launched after the UCS snapshot are classed by name', () => { ['STARLINK-34012', 'starlink'], ]; for (const [name, expected] of table) - assert.equal( - classify({ name, group: 'visual', launchYear: 2025 }), - expected, - name, - ); + assert.equal(classify({ name, group: 'visual', launchYear: 2025 }), expected, name); }); test('the group the TLE came in is the last resort', () => { - assert.equal( - classify({ name: 'UNNAMED-1', group: 'resource', launchYear: 2024 }), - 'imaging', - ); - assert.equal( - classify({ name: 'UNNAMED-1', group: 'geo', launchYear: 2024 }), - 'telecom', - ); - assert.equal( - classify({ name: 'UNNAMED-1', group: 'science', launchYear: 2024 }), - 'probes', - ); + assert.equal(classify({ name: 'UNNAMED-1', group: 'resource', launchYear: 2024 }), 'imaging'); + assert.equal(classify({ name: 'UNNAMED-1', group: 'geo', launchYear: 2024 }), 'telecom'); + assert.equal(classify({ name: 'UNNAMED-1', group: 'science', launchYear: 2024 }), 'probes'); assert.equal(classify({ name: 'STARLINK-1', group: 'dense' }), 'starlink'); - assert.equal( - classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 2024 }), - 'probes', - ); + assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 2024 }), 'probes'); }); test('CubeSats deployed from the ISS are not stations', () => { // They ride in CelesTrak's stations group under the ISS's 1998 designator. - for (const name of [ - 'DUPLEX', - 'KNACKSAT-2', - 'GXIBA-1', - 'LEOPARD', - 'HMU-SAT2', - ]) { + for (const name of ['DUPLEX', 'KNACKSAT-2', 'GXIBA-1', 'LEOPARD', 'HMU-SAT2']) { assert.equal( classify({ name, group: 'stations', launchYear: 1998 }), 'probes', @@ -209,11 +161,7 @@ test('CubeSats deployed from the ISS are not stations', () => { ); } assert.equal( - classify({ - name: 'UNNAMED-1', - group: 'stations', - ucs: { purpose: 'Earth Observation' }, - }), + classify({ name: 'UNNAMED-1', group: 'stations', ucs: { purpose: 'Earth Observation' } }), 'imaging', ); }); @@ -226,53 +174,89 @@ test('an unknown old payload is presumed dead; a recognisable one is not', () => 'without a SATCAT record (a failed read) nothing is presumed', ); assert.equal( - classify({ - name: 'ATLAS CENTAUR 2', - group: 'visual', - launchYear: 1963, - satcat: { objectType: 'PAY', opsStatus: '' }, - }), + classify({ name: 'ATLAS CENTAUR 2', group: 'visual', launchYear: 1963, satcat: { objectType: 'PAY', opsStatus: '' } }), 'debris', 'a blank SATCAT status is no evidence of life', ); + assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', launchYear: old + 1 }), 'probes'); + assert.equal(classify({ name: 'NOAA 15', group: 'weather', launchYear: 1998 }), 'imaging'); assert.equal( - classify({ name: 'UNNAMED-1', group: 'visual', launchYear: old + 1 }), - 'probes', - ); - assert.equal( - classify({ name: 'NOAA 15', group: 'weather', launchYear: 1998 }), - 'imaging', - ); - assert.equal( - classify({ - name: 'UNNAMED-1', - group: 'visual', - launchYear: 1990, - satcat: { objectType: 'PAY', opsStatus: '+' }, - }), + classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 1990, satcat: { objectType: 'PAY', opsStatus: '+' } }), 'probes', 'SATCAT says it works', ); }); +test('only debris is refused the Cockpit', () => { + for (const id of SATELLITE_CLASS_IDS) + assert.equal(satelliteClassEnterable(id), id !== 'debris', id); + assert.equal(satelliteClassEnterable('nonsense'), false); +}); + test('class visibility merges booleans and ignores everything else', () => { - const next = mergeClassVisibility({ - telecom: true, - imaging: false, - debris: 'yes', - bogus: true, - }); - assert.deepEqual(next, { - ...DEFAULT_SATELLITE_CLASSES, - telecom: true, - imaging: false, - }); - assert.deepEqual(mergeClassVisibility(null), { - ...DEFAULT_SATELLITE_CLASSES, - }); + const next = mergeClassVisibility({ telecom: true, imaging: false, debris: 'yes', bogus: true }); + assert.deepEqual(next, { ...DEFAULT_SATELLITE_CLASSES, telecom: true, imaging: false }); + assert.deepEqual(mergeClassVisibility(null), { ...DEFAULT_SATELLITE_CLASSES }); assert.equal(Object.hasOwn(next, 'bogus'), false); }); +test('UCS owner countries normalise to ISO codes, organisations and joint owners', () => { + assert.deepEqual(ownerFromUcs('USA'), { countryCode: 'US', label: 'United States' }); + assert.deepEqual(ownerFromUcs('United Kingdom'), { countryCode: 'GB', label: 'United Kingdom' }); + assert.deepEqual(ownerFromUcs('Sinapore'), { countryCode: 'SG', label: 'Singapore' }, 'UCS typo'); + assert.deepEqual(ownerFromUcs('Turkey'), { countryCode: 'TR', label: 'Türkiye' }); + assert.deepEqual(ownerFromUcs('ESA'), { countryCode: null, label: 'ESA' }); + assert.deepEqual(ownerFromUcs('Multinational'), { countryCode: null, label: 'Multinational' }); + assert.deepEqual(ownerFromUcs('China/France'), { countryCode: null, label: 'China / France' }); + assert.deepEqual(ownerFromUcs('United Kingdom/ESA'), { countryCode: null, label: 'United Kingdom / ESA' }); + assert.equal(ownerFromUcs(''), null); + assert.equal(ownerFromUcs(null), null); +}); + +test('SATCAT owner codes normalise the same way', () => { + assert.deepEqual(ownerFromSatcat('US'), { countryCode: 'US', label: 'United States' }); + assert.deepEqual(ownerFromSatcat('PRC'), { countryCode: 'CN', label: 'China' }); + assert.deepEqual(ownerFromSatcat('CIS'), { countryCode: 'RU', label: 'Russia' }, 'former USSR'); + assert.deepEqual(ownerFromSatcat('ISRO'), { countryCode: 'IN', label: 'India' }); + assert.deepEqual(ownerFromSatcat('itso'), { countryCode: null, label: 'Intelsat' }); + assert.deepEqual(ownerFromSatcat('EUME'), { countryCode: null, label: 'EUMETSAT' }); + assert.deepEqual(ownerFromSatcat('PRES'), { countryCode: null, label: 'China / ESA' }); + assert.deepEqual(ownerFromSatcat('TMMC'), { countryCode: null, label: 'Turkmenistan / Monaco' }); + for (const code of ['TBD', 'UNK', '', null, 'ZZZZ']) assert.equal(ownerFromSatcat(code), null, String(code)); +}); + +test('owner prefers UCS, falls back to SATCAT, and Starlink is American', () => { + assert.deepEqual( + satelliteOwner({ ucs: { operatorCountry: 'ESA' }, satcat: { owner: 'FR' } }), + { countryCode: null, label: 'ESA' }, + ); + assert.deepEqual(satelliteOwner({ satcat: { owner: 'FR' } }), { countryCode: 'FR', label: 'France' }); + assert.deepEqual(satelliteOwner({ satelliteClass: 'starlink' }), { countryCode: 'US', label: 'United States' }); + assert.equal(satelliteOwner({}), null); + assert.equal(satelliteOperator({ ucs: { operator: 'Planet Labs, Inc.' } }), 'Planet Labs, Inc.'); + assert.equal(satelliteOperator({ satelliteClass: 'starlink' }), 'SpaceX'); + assert.equal(satelliteOperator({ satelliteClass: 'imaging' }), null); +}); + +const ISS_LINE1 = '1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993'; +const ISS_LINE2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323'; + +test('COSPAR ids come from TLE line 1, across the 1957 century pivot', () => { + assert.equal(cosparIdFromTleLine1(ISS_LINE1), '1998-067A'); + assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', '24123BC ')), '2024-123BC'); + assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', '57001A ')), '1957-001A'); + assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', ' ')), null); + assert.equal(cosparIdFromTleLine1(null), null); +}); + +test('the TLE epoch is read as epoch milliseconds', () => { + const satrec = twoline2satrec(ISS_LINE1, ISS_LINE2); + // Day 001.5 of 2024: 1 January 2024, 12:00 UTC. + assert.ok(Math.abs(tleEpochMs(satrec) - Date.UTC(2024, 0, 1, 12)) < 1000); + assert.equal(tleEpochMs(null), null); + assert.equal(tleEpochMs({}), null); +}); + test('SATCAT CSV parses, quoted commas included, keyed by NORAD id', () => { const csv = [ 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER,LAUNCH_DATE', @@ -286,6 +270,8 @@ test('SATCAT CSV parses, quoted commas included, keyed by NORAD id', () => { assert.deepEqual(records.get(25544), { objectType: 'PAY', opsStatus: '+', + owner: 'ISS', + cosparId: '1998-067A', launchDate: '1998-11-20', }); assert.equal(records.get(733).objectType, 'R/B'); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index ee9477fc6..5d1624de0 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -322,6 +322,8 @@ export function createIngestion({ groups, // satellite.js does not keep the designator the year match reads. launchYear, + line1: entry.line1, + line2: entry.line2, satelliteClass: classifySatellite({ noradId, name: entry.name, diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 42823ee00..783cdcb4c 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -14,7 +14,7 @@ export function createState({ services }) { state._overlayHost = state.DEFAULT_OVERLAY_HOST; - // Satellite catalog: { noradId → { name, satrec, group, launchYear, + // Satellite catalog: { noradId → { name, satrec, group, line1, line2, // satelliteClass, ucs, satcat } } — class, UCS and SATCAT records from // src/layers/satellites/classes.js and the sources the update read. From 048293fc2a5923772660322bc1cf89571042558b Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 20:06:33 +0100 Subject: [PATCH 048/113] feat(cockpit): ride a tracked satellite in an orbital view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a satellite tracked and no aircraft tracked, Cockpit can ride the satellite. Entry needs only the Satellites layer; debris can be tracked but not entered, and voice stays aircraft-only. Double-clicking the tracked satellite or the entry buttons (reading ORBIT) enter it. The camera sits on the satellite's per-frame SGP4 position and looks straight down, turned to the ground track (north-up for a satellite with none). The optical zoom steps 1x to 8x with a focus pull, the arrows tilt the view up to 45 degrees off nadir, and Home or 0 returns to nadir. Off nadir the tilt reads its total angle first and the swath readout becomes an approximation marked with ≈. While in orbit the map leases flat Esri imagery, the cloud pass stays off and the view opens in the optical style. The orbital HUD names the satellite with its owner's flag (from the flag-icons package, loaded per country) or an organisation badge, its operator, class, NORAD and COSPAR ids and TLE epoch with the element lines on request, altitude, orbital speed and the sub-satellite point. CURRENTLY OVER names the country and its first-level division, or the sea, and from 8x the most prominent populated place nearby, looked up offline from the bundled Natural Earth packs (populated places are a new pack). The satellites layer hides the ridden satellite's own marks, dims the other orbit rings and ignores scene clicks and Escape while held. Exit restores the camera's field of view and the satellite's follow camera. C no longer enters or leaves Cockpit, bridge or orbit: it is left to the app-wide CCTV shortcut, and Escape or the exit button leave first person. --- DATA_SOURCES.md | 11 + README.md | 2 +- docs/CURRENT-STATE.md | 124 +- package-lock.json | 7 + package.json | 1 + scripts/build-admin-packs.mjs | 58 +- scripts/check-package-boundaries.mjs | 9 +- scripts/package-boundaries.json | 23 +- src/cameraHandoff.test.mjs | 2 +- src/cockpitCloudEffects.js | 17 +- src/cockpitCloudEffects.test.mjs | 8 + src/cockpitLook.js | 61 + src/cockpitLook.test.mjs | 58 + src/cockpitMarkup.test.mjs | 13 +- src/cockpitOrbital.js | 413 +++++++ src/cockpitOrbital.test.mjs | 302 +++++ src/contextModePolicy.js | 24 + src/contextModePolicy.test.mjs | 14 + src/data/local_data/natural_earth/README.md | 21 + .../natural_earth/populated_places.json | 1 + src/data/placeBelow.js | 369 ++++++ src/data/placeBelow.test.mjs | 280 +++++ src/i18n/locales/ar.json | 40 +- src/i18n/locales/de.json | 40 +- src/i18n/locales/es.json | 40 +- src/i18n/locales/fr.json | 40 +- src/i18n/locales/it.json | 40 +- src/i18n/locales/ja.json | 40 +- src/i18n/locales/pt.json | 40 +- src/i18n/locales/ru.json | 40 +- src/i18n/locales/uk.json | 40 +- src/i18n/locales/zh-CN.json | 40 +- src/i18n/locales/zh-Hant.json | 40 +- src/i18n/same-as-english.json | 49 +- src/layers/satellites/cockpitSubject.js | 318 +++++ src/layers/satellites/cockpitSubject.test.mjs | 542 ++++++++ src/layers/satellites/controls.js | 8 +- src/layers/satellites/index.js | 3 + src/layers/satellites/ingestion.js | 2 + src/layers/satellites/interaction.js | 5 + src/layers/satellites/lifecycle.js | 4 + src/layers/satellites/orbits.js | 64 +- src/layers/satellites/rendering.js | 23 +- src/layers/satellites/state.js | 11 + src/layers/satellites/tracking.js | 3 + src/ui/applicationShell.js | 10 + src/ui/cockpitBodyClasses.test.mjs | 5 +- src/ui/cockpitBridge.test.mjs | 84 +- src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitCamera.js | 4 + src/ui/cockpitController.js | 84 +- src/ui/cockpitCoordinator.js | 48 +- src/ui/cockpitEntryGate.test.mjs | 110 +- src/ui/cockpitInput.js | 61 +- src/ui/cockpitInstruments.js | 5 + src/ui/cockpitOrbital.test.mjs | 263 ++++ src/ui/cockpitOrbitalView.js | 549 +++++++++ src/ui/cockpitOrbitalView.test.mjs | 1090 +++++++++++++++++ src/ui/cockpitPresentation.js | 18 +- src/ui/cockpitTrackingController.js | 105 +- src/ui/ownerFlag.js | 110 ++ src/ui/ownerFlag.test.mjs | 108 ++ src/ui/stores/cockpit.js | 38 + src/ui/styles/cockpitOrbital.css | 499 ++++++++ src/ui/views/CockpitView.vue | 51 +- src/ui/views/panels/CockpitOrbital.vue | 279 +++++ style.css | 1 + 67 files changed, 6642 insertions(+), 172 deletions(-) create mode 100644 src/cockpitOrbital.js create mode 100644 src/cockpitOrbital.test.mjs create mode 100644 src/data/local_data/natural_earth/populated_places.json create mode 100644 src/data/placeBelow.js create mode 100644 src/data/placeBelow.test.mjs create mode 100644 src/layers/satellites/cockpitSubject.js create mode 100644 src/layers/satellites/cockpitSubject.test.mjs create mode 100644 src/ui/cockpitOrbital.test.mjs create mode 100644 src/ui/cockpitOrbitalView.js create mode 100644 src/ui/cockpitOrbitalView.test.mjs create mode 100644 src/ui/ownerFlag.js create mode 100644 src/ui/ownerFlag.test.mjs create mode 100644 src/ui/styles/cockpitOrbital.css create mode 100644 src/ui/views/panels/CockpitOrbital.vue diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 52f72641c..cde514841 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -233,6 +233,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | +| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder | @@ -317,6 +318,16 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver wiring (see below). +### Natural Earth populated places (`natural_earth/populated_places.json`) + +Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned +`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one +row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max` +(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half +its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving +the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild: +`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README. + ### Sky background (`sky/`) Sources, licences, the changes CC BY asks to state, hashes and build commands diff --git a/README.md b/README.md index b957046ff..d4b53dc24 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ -**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. +**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab92553b2..653ba1d99 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1530,14 +1530,13 @@ With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a selected vessel; Contacts and every other layer may be off, before or during the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode -change still in flight blocks entry until it settles. `C` enters it (when -the bridge cannot be entered, `C` stays the CCTV shortcut), and the -entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`) -and under the selected vessel's Intel HUD readout in the bottom-right corner -(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber -layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no -subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone, -so no other feed being off makes it partial. +change still in flight blocks entry until it settles. Double-clicking the +selected vessel enters it, and the entry button reads BRIDGE with a ship icon +both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD +readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the +HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends +the bridge: the layer then has no subject (`getCockpitSubject` returns null). +The bridge picture uses AIS alone, so no other feed being off makes it partial. Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only; voice `enter` on a bridge is refused before it touches tracking, with the reason to exit the bridge first. @@ -1605,6 +1604,97 @@ through the layer's own focus. Tracking an aircraft or losing the vessel selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs` covers entry, readouts, refusal and exit. +### Orbital view + +With a satellite tracked and no aircraft tracked, Cockpit can ride the +satellite instead. Entry needs only the Satellites layer +(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other +layer may be off. A Context mode change in flight blocks entry until it +settles. Debris (rocket bodies, fragments and dead satellites; see the +satellite class toggles) can be tracked but never entered: no ORBIT entry is +offered and double-click refuses it. A tracked aircraft still takes +entry first, and a tracked satellite +takes it before a selected vessel. Double-clicking the tracked satellite +enters it, and the entry button reads +ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel +HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays +aircraft-only; its state reports an orbital view in use as a `satellite` +subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD +id. + +The camera sits on the satellite's own per-frame SGP4 position +(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's +centre track-up: the ground track points to the top of the frame, so the +picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A +satellite with no ground track (a geostationary one) is shown north-up, and +over a pole the previous frame's up is kept. It updates on every rendered +frame, with no ground clamp. Entry opens in the plain optical vision style +(the other styles stay one click away) and leases the map for flat imagery: +with Google 3D tiles shown, which stay coarse from hundreds of kilometres up, +the map switches to Esri imagery, and exit puts it back unless the operator +picked another map meanwhile; another owner already holding the map (an +imagery comparison) keeps it as it is. The Cockpit cloud pass is off in orbit +(it fades out above 24 km); cloud cover from orbit is the Weather layer's, +and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked +companions, its own orbit ring and its detection bracket, and draws every +other orbit ring (the ISS ring included) at 30% of its alpha; other +satellites' points and labels stay. Scene clicks and the layer's Escape are +ignored while it is ridden. The optical zoom steps 1×, 2×, 4× and 8× with `+` +(or `=`) and `-` (or `_`) and the panel's buttons: the 1× frame is 60° +vertical in low orbit and fits the Earth's disk with margin from high orbits, +each step divides the tangent of the half-angle, and Cesium's `frustum.fov` +is set from it for the canvas aspect. A step animates over 400 ms with a +focus-pull blur and a reticle lock tick, and cuts at once under +`prefers-reduced-motion`. The arrows tilt the view off nadir in 5° steps +along the frame's axes (Up/Down tilt forward/aft along the ground track, +Left/Right across it, each axis to ±45°; Shift takes no larger step); Home, +`0` or NADIR return to nadir. While tilted the panel shows the total angle +from nadir, acos(cos along · cos across), before each axis +(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view) +and a NADIR button, and the swath readout turns approximate +(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the +nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of +the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the +footprint without ≈. After a zoom step lands, the focus pull holds until the +imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then +the reticle locks. The look keys and zoom keys keep the bridge's focus +guards. + +The HUD (`#cockpit-hud[data-subject='satellite']`, body class +`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class, +altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a +ground track) with a pointer that turns to keep north marked N on the frame, +zoom and swath. Under +the vision selector a "CURRENTLY OVER" band names what the sub-satellite point +is over, looked up offline from the bundled Natural Earth packs +(`src/data/placeBelow.js`: on land the first-level division as Natural Earth +names it and the country in the reader's language through `Intl.DisplayNames`, +"Sichuan, China", and from 8× the most prominent populated place within half +the view's smaller side, so it is in the picture, "Sichuan, China · near +Chengdu", from a bundled pack of Natural Earth populated places loaded on +first use; else the smallest named sea, gulf, strait, channel, sound or bay; +else the ocean; nothing in a coastline gap), at most every 2 s and only after +20 km of ground travel, or at once when a zoom step changes how much ground +the view shows. A division or populated-places pack that fails to load drops +only that part (the country still shows); a lookup that fails outright hides +the band rather than leave the last place up, and the next HUD beat looks +again. The readout card puts +the owner country's flag (an SVG from `flag-icons`, fetched only for the shown +country) or a text badge for an organisation before the name, the operator +under it, and a NORAD · COSPAR · TLE-epoch line whose TLE button unfolds the +two element lines; each row is left out when the satellites layer does not +know it. The +aircraft and bridge instruments, tapes, compass, radar, look controls, +position readout, route card and Contact card are hidden; the vision-style +switcher, briefing card (Local and Regional for the sub-satellite point; the +regional brief refreshes at most once a minute) and the reset and EXIT ORBIT +buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT +ORBIT) restores the camera's own field of view exactly and returns to the +satellite's follow camera through the layer's `refocusTrackedById`. Losing +the subject (tracking stopped, Satellites off) or tracking another target +ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` +covers entry, camera, zoom, look, HUD and exit. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -3495,7 +3585,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center -> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus +> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus > transfer and failure-safe shortcut routing. The exit control sits at the > bottom-center compass position. A Cockpit-only control strip sits > directly above Live Signals, anchored 12px under the REC readout it shares @@ -5458,7 +5548,8 @@ silently demoting every later lookup for the session. - **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class. - Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. - The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. -- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia. - **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. @@ -6358,15 +6449,16 @@ apply to `public/` assets or built-preview serving. ending, by its own switch or one of its layers going off, restores the pre-Contacts layers but keeps the ridden one and the view; Live Signals then drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible - Cockpit actions, the `C` shortcut and voice use the same gate; voice entry - still opens Contacts first, as its layer-targeted entry navigates through - Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`; - see [Ship bridge view](#ship-bridge-view)). -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. + Cockpit actions, double-click and voice use the same gate; voice entry still + opens Contacts first, as its layer-targeted entry navigates through Contacts. + The ship bridge and the orbital view have their own own-layer gates + (`bridgeEntryAllowed`, `orbitEntryAllowed`; see + [Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)). +- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action 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; `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. -- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. diff --git a/package-lock.json b/package-lock.json index dd60d3383..87c2cb80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", @@ -2411,6 +2412,12 @@ } } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", diff --git a/package.json b/package.json index ffc554d7b..f6362281f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", diff --git a/scripts/build-admin-packs.mjs b/scripts/build-admin-packs.mjs index d7f647384..9fe319ee0 100644 --- a/scripts/build-admin-packs.mjs +++ b/scripts/build-admin-packs.mjs @@ -7,13 +7,16 @@ * Natural Earth 10m admin-1 states and provinces (public domain). * src/data/local_data/us_census_counties/counties.json * US Census Bureau cartographic boundary counties (public domain). + * src/data/local_data/natural_earth/populated_places.json + * Natural Earth 10m populated places as points, for the orbital view's + * "near " (`src/data/placeBelow.js`; public domain). * * Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs * byte for byte. Downloads are cached outside the repository (default: the * system temp directory) and are never committed. * * Usage: - * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties] + * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties|places] * * The provenance READMEs beside each pack record these parameters. */ @@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({ elsewhereMinPopulation: 50_000, cityStateMaxKm2: 1_000, }, + // Every populated place, kept as a point: name, position and the two + // prominence ranks the orbital view picks "near " by. + places: { decimals: 3 }, }); const args = process.argv.slice(2); @@ -688,6 +694,47 @@ async function buildAdmin1() { }; } +async function buildPlaces() { + const params = PARAMS.places; + const source = await fetchPinned('places'); + const factor = 10 ** params.decimals; + const round = (value) => Math.round(value * factor) / factor; + const places = JSON.parse(source.bytes.toString('utf8')) + .features.map((f) => f.properties) + .filter( + (p) => + p.name && + Number.isFinite(p.latitude) && + Number.isFinite(p.longitude) && + Number.isFinite(p.scalerank), + ) + .map((p) => [ + p.name, + round(p.latitude), + round(p.longitude), + p.scalerank, + Number.isFinite(p.rank_max) ? p.rank_max : 0, + ]) + .sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0])); + return { + meta: { + title: 'Natural Earth populated places', + source: 'ne_10m_populated_places_simple', + url: SOURCES.places.url, + sha256: source.digest, + commit: NE_COMMIT, + license: + 'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)', + schema: + 'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]', + decimals: params.decimals, + curation: params, + script: 'scripts/build-admin-packs.mjs', + }, + places, + }; +} + async function buildCounties() { const params = PARAMS.counties; const source = await fetchPinned('counties'); @@ -768,4 +815,13 @@ if (isMain) { 'src/data/local_data/us_census_counties/counties.json', await buildCounties(), ); + if (!only || only === 'places') { + const relative = 'src/data/local_data/natural_earth/populated_places.json'; + const pack = await buildPlaces(); + const text = JSON.stringify(pack) + '\n'; + await writeFile(path.join(ROOT, relative), text); + console.log( + `${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`, + ); + } } diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 700dc9a1a..ad5d2bdb2 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -109,7 +109,14 @@ export async function checkPackageBoundaries(root) { // template blocks belong to the component file itself. if (info.id.startsWith('\0')) return; const id = info.id.split('?')[0]; - if (!allowed.has(id)) { + // A declared dependency also owns the asset files it ships (a + // flag-icons SVG imported as a URL) that a glob resolves in place. + const dependencyAsset = group.external.some((external) => + id.startsWith( + `${normalizePath(path.join(root, 'node_modules', external))}/`, + ), + ); + if (!allowed.has(id) && !dependencyAsset) { throw new Error( `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f5443b2e5..466606b92 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -40,6 +40,8 @@ "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRadar.vue", + "src/ui/views/panels/CockpitOrbital.vue", + "src/ui/ownerFlag.js", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -158,7 +160,7 @@ "src/ui/i18n.js", "src/ui/surfaceKeyboard.js" ], - "external": ["vue", "pinia", "vue-i18n"] + "external": ["vue", "pinia", "vue-i18n", "flag-icons"] }, "viewer": { "exports": ["./application/viewer"], @@ -891,6 +893,11 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -908,6 +915,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/ui/panelRailGeometry.js", @@ -981,6 +989,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", @@ -1033,6 +1042,11 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -1637,6 +1651,7 @@ "src/data/satellitePass.js", "src/sources/tle.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -2411,6 +2426,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", @@ -2705,6 +2721,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -2854,6 +2871,8 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -3675,6 +3694,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -4185,6 +4205,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index a668180fe..1c41139c8 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,7 +42,7 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || (!vessel && !entity?.position)) return false;', + 'if (!info || (!vessel && !satellite && !entity?.position)) return false;', 'this.onCameraTakeover?.();', 'this.viewer.camera.cancelFlight();', 'this.viewer.trackedEntity = undefined;', diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index c9117b7d3..c1cc796b4 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) { return value === '1'; } +/** + * Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit: + * the pass fades out above 24 km (weatherAltitudeFactors), so a satellite + * would pay for it and see nothing. The saved preference is left as it is + * for the next aircraft or bridge; cloud cover from orbit is the Weather + * layer's. + * @param {string|null|undefined} layerId + */ +export function cockpitCloudsShowFor(layerId) { + return layerId !== 'satellites'; +} + /** * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. @@ -207,10 +219,12 @@ class CockpitCloudEffectsController { this.enabled = this.readEnabledPreference(); this.onResize = () => this.resize(); + this.orbit = false; this.onCockpitMode = (event) => { const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode'); + this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId); if (active && this.enabled) this.start(); else this.stop(); }; @@ -344,7 +358,8 @@ class CockpitCloudEffectsController { this.destroyed || !this.enabled || this.frame !== null || - this.suspended + this.suspended || + this.orbit ) return; if (!this.gl) { diff --git a/src/cockpitCloudEffects.test.mjs b/src/cockpitCloudEffects.test.mjs index 18a0ed394..651b3fb27 100644 --- a/src/cockpitCloudEffects.test.mjs +++ b/src/cockpitCloudEffects.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { cockpitCloudRenderSize, + cockpitCloudsShowFor, cockpitWeatherRefreshDue, cockpitWeatherEnabledFromStoredValue, } from './cockpitCloudEffects.js'; @@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true); }); +test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => { + assert.equal(cockpitCloudsShowFor('satellites'), false); + for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) { + assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId)); + } +}); + test('cockpit weather refreshes after time or meaningful movement', () => { const anchor = { latitude: 30, longitude: -97 }; assert.equal(cockpitWeatherRefreshDue({ diff --git a/src/cockpitLook.js b/src/cockpitLook.js index 8292cde25..e0b133b34 100644 --- a/src/cockpitLook.js +++ b/src/cockpitLook.js @@ -181,3 +181,64 @@ export function lookReadoutParts(look, restPitchDeg) { angleDeg: Math.abs(pitch), }; } + +/** + * The orbital view's off-nadir limit on each axis. The orbital look reuses + * the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts + * the view east (positive) or west, pitch tilts it north (positive) or + * south. Single 5° steps only; the bridge keeps its own limits. + */ +export const ORBITAL_LOOK_LIMIT_DEG = 45; + +/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */ +function clampOrbitalAxis(deg) { + if (!Number.isFinite(deg)) return 0; + const value = Math.min( + ORBITAL_LOOK_LIMIT_DEG, + Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg), + ); + return value === 0 ? 0 : value; +} + +/** + * Clamp an orbital look to the off-nadir limit on both axes. + * @param {{yawDeg: number, pitchDeg: number}} look East and north tilt. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function clampOrbitalLook(look) { + return { + yawDeg: clampOrbitalAxis(look?.yawDeg), + pitchDeg: clampOrbitalAxis(look?.pitchDeg), + }; +} + +/** + * Apply one arrow step to an orbital look: the bridge's stepping on the + * same 5° grid, then the off-nadir clamp. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function stepOrbitalLook(target, step) { + return clampOrbitalLook(stepLook(clampOrbitalLook(target), step)); +} + +/** + * The parts of an off-nadir readout, for the Cockpit to word: the north or + * south tilt and the east or west tilt, each null at rest, in whole degrees. + * @param {{yawDeg: number, pitchDeg: number}} look Orbital look target. + * @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number, + * eastWest: 'E'|'W'|null, eastWestDeg: number}} + */ +export function orbitalLookReadoutParts(look) { + const { yawDeg, pitchDeg } = clampOrbitalLook(look); + const north = Math.round(pitchDeg); + const east = Math.round(yawDeg); + return { + northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null, + northSouthDeg: Math.abs(north), + eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null, + eastWestDeg: Math.abs(east), + }; +} diff --git a/src/cockpitLook.test.mjs b/src/cockpitLook.test.mjs index f1f4a239c..27f79e319 100644 --- a/src/cockpitLook.test.mjs +++ b/src/cockpitLook.test.mjs @@ -5,12 +5,16 @@ import assert from 'node:assert/strict'; import { LOOK_COARSE_STEP_DEG, LOOK_STEP_DEG, + ORBITAL_LOOK_LIMIT_DEG, clampLookPitch, + clampOrbitalLook, easeLook, lookIsOffset, lookKeyStep, lookReadoutParts, + orbitalLookReadoutParts, stepLook, + stepOrbitalLook, wrapLookYaw, } from './cockpitLook.js'; @@ -135,3 +139,57 @@ test('the readout names the side, the bearing and the elevation', () => { 'astern', ); }); + +test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => { + let look = { yawDeg: 0, pitchDeg: 0 }; + for (let index = 0; index < 3; index += 1) + look = stepOrbitalLook(look, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 }); + look = stepOrbitalLook(look, lookKeyStep('ArrowRight')); + assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 }); + for (let index = 0; index < 20; index += 1) { + look = stepOrbitalLook(look, lookKeyStep('ArrowDown')); + look = stepOrbitalLook(look, lookKeyStep('ArrowLeft')); + } + assert.deepEqual(look, { + yawDeg: -ORBITAL_LOOK_LIMIT_DEG, + pitchDeg: -ORBITAL_LOOK_LIMIT_DEG, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), { + yawDeg: 45, + pitchDeg: 0, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), { + yawDeg: 45, + pitchDeg: -45, + }); +}); + +test('the orbital readout names north or south and east or west', () => { + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), { + northSouth: 'N', + northSouthDeg: 10, + eastWest: 'E', + eastWestDeg: 15, + }); + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), { + northSouth: null, + northSouthDeg: 0, + eastWest: 'W', + eastWestDeg: 5, + }); + assert.equal( + orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth, + 'S', + ); +}); + +test('the bridge look keeps its own limits beside the orbital clamp', () => { + const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 }); + assert.equal( + stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true)) + .yawDeg, + 90, + ); +}); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index aa06193ba..06db3aeff 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-exit"/); assert.match( html, - /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, + /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); const topCenterActions = html.match( /